Feature/social media integration testing#233
Feature/social media integration testing#233pulinduvidmal wants to merge 105 commits intodevelopfrom
Conversation
…upport_for_telegram
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 47 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| max_attachments: int = Field(default=20, description="Maximum number of attachments to keep per session") | ||
| description_max_length: int = Field(default=200, description="Maximum length of attachment description text") | ||
| description_model: str = Field( | ||
| default="gpt-4o", | ||
| description="LiteLLM model used to generate brief descriptions when an attachment is first received (called by the pre-hook)", | ||
| ) | ||
| analysis_model: str = Field( | ||
| default="gpt-4o", | ||
| description="LiteLLM model used by the analyze_attachments tool when the agent requests a full analysis of an attachment", | ||
| ) |
There was a problem hiding this comment.
description_max_length is introduced in _MultimodalConfig, but it isn’t referenced anywhere else in the codebase. Either enforce this limit when generating/storing descriptions (e.g., in describe_attachment_briefly or the multimodal pre-hook) or remove the config option to avoid dead/unused configuration knobs.
| import asyncio | ||
| import json | ||
| import os |
There was a problem hiding this comment.
json is imported but never used in this test module. Remove it to keep the test file clean (and to avoid unused-import lint failures if enabled).
| 3. Click **Apply**. | ||
| 4. Go to **Channels** and add **Microsoft Teams**. accepting the terms. | ||
|
|
There was a problem hiding this comment.
Minor grammar: sentence is split oddly here ("Go to Channels and add Microsoft Teams. accepting the terms."). Consider changing to a single sentence like "...add Microsoft Teams, accepting the terms." or "...add Microsoft Teams and accept the terms.".
| self._log = logging.getLogger("ak.api.slack") | ||
| self._slack_agent = Config.get().slack.agent if Config.get().slack.agent != "" else None | ||
| self._slack_agent_acknowledgement = Config.get().slack.agent_acknowledgement if Config.get().slack.agent_acknowledgement != "" else None | ||
| self._slack_test_mode = Config.get().slack.test_mode |
There was a problem hiding this comment.
Config.get().slack.test_mode is accessed here, but _SlackConfig in ak-py/src/agentkernel/core/config.py does not define a test_mode field. With extra="ignore", test_mode in YAML/env will be discarded and this will raise AttributeError at runtime during handler initialization. Add test_mode: bool = Field(default=False, ...) to _SlackConfig (and document it), or use getattr(Config.get().slack, "test_mode", False) as a backwards-compatible default.
| self._slack_test_mode = Config.get().slack.test_mode | |
| self._slack_test_mode = getattr(Config.get().slack, "test_mode", False) |
| self._telegram_agent = Config.get().telegram.agent if Config.get().telegram.agent != "" else None | ||
| self._bot_token = Config.get().telegram.bot_token | ||
| self._webhook_secret = Config.get().telegram.webhook_secret | ||
| self._api_version = Config.get().telegram.api_version or "bot" | ||
| self._base_url = f"https://api.telegram.org/{self._api_version}{self._bot_token}" | ||
| self._api_base_url = Config.get().telegram.api_base_url | ||
| self._base_url = f"{self._api_base_url}/{self._api_version}{self._bot_token}" | ||
|
|
There was a problem hiding this comment.
Config.get().telegram.api_base_url is used to build _base_url, but _TelegramConfig in ak-py/src/agentkernel/core/config.py does not define api_base_url. This will raise AttributeError at startup, and env vars like AK_TELEGRAM__API_BASE_URL will be ignored due to extra="ignore". Add api_base_url to _TelegramConfig with a default like https://api.telegram.org (and consider normalizing trailing / to avoid double slashes).
| # Bot Framework expects a dict with 'body' as parsed JSON | ||
| req_dict = {"body": json.loads(body_text), "headers": {"Authorization": auth_header}} # Parse JSON | ||
|
|
||
| # Process the activity using Bot Framework adapter | ||
| async def bot_logic(turn_context: TurnContext): | ||
| await self._handle_teams_message(turn_context) | ||
|
|
||
| # Process the request - Bot Framework validates auth internally | ||
| await self._adapter.process_activity(req_dict, auth_header, bot_logic) |
There was a problem hiding this comment.
BotFrameworkAdapter.process_activity(...) expects an Activity (deserialized from the request JSON), not a custom {"body": ..., "headers": ...} dict. As written, this is very likely to fail inside the adapter (and you’re also discarding any InvokeResponse that should be returned for certain activity types). Deserialize with Activity().deserialize(json.loads(body_text)), call process_activity(activity, auth_header, bot_logic), and if an invoke response is returned, convert it into the FastAPI Response (status/body/headers).
| # Bot Framework expects a dict with 'body' as parsed JSON | |
| req_dict = {"body": json.loads(body_text), "headers": {"Authorization": auth_header}} # Parse JSON | |
| # Process the activity using Bot Framework adapter | |
| async def bot_logic(turn_context: TurnContext): | |
| await self._handle_teams_message(turn_context) | |
| # Process the request - Bot Framework validates auth internally | |
| await self._adapter.process_activity(req_dict, auth_header, bot_logic) | |
| # Deserialize the incoming request body into a Bot Framework Activity | |
| activity = Activity().deserialize(json.loads(body_text)) | |
| # Process the activity using Bot Framework adapter | |
| async def bot_logic(turn_context: TurnContext): | |
| await self._handle_teams_message(turn_context) | |
| # Process the request - Bot Framework validates auth internally | |
| invoke_response = await self._adapter.process_activity(activity, auth_header, bot_logic) | |
| # If an InvokeResponse is returned, convert it into a FastAPI Response | |
| if invoke_response: | |
| body = invoke_response.body if hasattr(invoke_response, "body") else None | |
| content = json.dumps(body) if body is not None else None | |
| headers = getattr(invoke_response, "headers", None) | |
| if headers is not None and not isinstance(headers, dict): | |
| headers = None | |
| return Response( | |
| status_code=invoke_response.status, | |
| content=content, | |
| media_type="application/json" if content is not None else None, | |
| headers=headers, | |
| ) | |
| # For non-invoke activities, return a simple 200 OK |
| Usage: | ||
| cd examples/api/slack && ./build.sh local | ||
| uv run pytest server_integration_test.py -s --junitxml=pytest-report.xml | ||
| """ |
There was a problem hiding this comment.
The usage string references server_integration_test.py, but this file is server_test.py (and there’s no server_integration_test.py in the repo). Update the docstring so the command matches the actual filename to avoid confusing users/CI scripts.
| async def _read_channel_messages() -> list[dict]: | ||
| """Read the most recent message from the dedicated test channel.""" | ||
| async with httpx.AsyncClient(timeout=10.0) as client: | ||
| resp = await client.get( | ||
| "https://slack.com/api/conversations.history", | ||
| params={ | ||
| "channel": CHANNEL_ID, | ||
| "limit": 1, | ||
| }, | ||
| headers={"Authorization": f"Bearer {BOT_TOKEN}"}, | ||
| ) | ||
| resp.raise_for_status() | ||
| data = resp.json() | ||
|
|
||
| print( | ||
| f"conversations.history response: ok={data.get('ok')}, " | ||
| f"messages={len(data.get('messages', []))}, " | ||
| f"error={data.get('error', 'none')}" | ||
| ) | ||
|
|
||
| if not data.get("ok"): | ||
| raise RuntimeError(f"Slack API error: {data.get('error', 'unknown')}") | ||
|
|
||
| return data.get("messages", []) |
There was a problem hiding this comment.
conversations.history is queried with limit: 1, and the test assumes the most recent channel message is the acknowledgment from this run. If any other message is posted to the channel (human, another test, Slack system message), the test can read/update/delete the wrong message and become flaky. Consider fetching a larger window (e.g., 10–50 messages) and selecting the message by a unique marker (uuid/run id) included in the webhook/ack text, or by timestamp >= test start time.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 43 out of 49 changed files in this pull request and generated 12 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| failed_files = await self._process_attachments(activity.attachments, requests) | ||
| if failed_files: | ||
| await turn_context.send_activity( | ||
| f"Sorry {user_name}, I could not download the following files: {', '.join(failed_files)}. Please try again." |
There was a problem hiding this comment.
The block returns failed_files + rejected_files + oversized_files, but the user-facing message in _handle_teams_message says they "could not download" all of them. Rejected (audio/video) and oversized files aren’t download failures, so the error text should distinguish these cases (or return structured categories instead of a single list).
| f"Sorry {user_name}, I could not download the following files: {', '.join(failed_files)}. Please try again." | |
| f"Sorry {user_name}, I could not use the following files (they may be unsupported types, too large, or failed to download): {', '.join(failed_files)}. Please try again." |
| @@ -0,0 +1,301 @@ | |||
| import base64 | |||
| import io | |||
There was a problem hiding this comment.
io is imported but never used in this module.
| import io |
| Usage: | ||
| cd examples/api/slack && ./build.sh local | ||
| uv run pytest server_integration_test.py -s --junitxml=pytest-report.xml | ||
| """ |
There was a problem hiding this comment.
The usage instructions reference server_integration_test.py, but this repository only contains server_test.py. This makes the copy/paste instructions incorrect.
| import asyncio | ||
| import json | ||
| import os |
There was a problem hiding this comment.
json is imported but never used (the code calls request.json(), which doesn’t require the stdlib json module). Please remove the unused import to avoid lint/test failures in stricter environments.
| import asyncio | ||
| import json | ||
| import os |
There was a problem hiding this comment.
json is imported but never used (the code calls request.json(), which doesn’t require the stdlib json module). Please remove the unused import to avoid lint/test failures in stricter environments.
| import os | ||
|
|
||
| base_url_env = os.environ.get("AK_WHATSAPP__API_BASE_URL", "").strip() | ||
| if not base_url_env: | ||
| base_url_env = Config.get().whatsapp.api_base_url | ||
| base_url = f"{base_url_env}/{self._api_version}" |
There was a problem hiding this comment.
self._base_url is computed in __init__ from AK_WHATSAPP__API_BASE_URL but _send_message() recomputes a separate base_url (and strips whitespace) instead of using self._base_url. This can lead to inconsistent behavior where media downloads / mark-as-read use self._base_url while sending messages uses the recomputed URL (especially if the env var has whitespace or a trailing slash). Consider normalizing the base URL once in __init__ (strip + rstrip('/')) and reusing it everywhere.
| import os | |
| base_url_env = os.environ.get("AK_WHATSAPP__API_BASE_URL", "").strip() | |
| if not base_url_env: | |
| base_url_env = Config.get().whatsapp.api_base_url | |
| base_url = f"{base_url_env}/{self._api_version}" | |
| # Use the base URL configured on initialization to ensure consistency | |
| base_url = f"{self._base_url.strip().rstrip('/')}/{self._api_version}" |
| ```bash | ||
| export AK_MULTIMODAL__ENABLED=true # Enable multimodal support (default: false) | ||
| export AK_MULTIMODAL__MAX_ATTACHMENTS=5 # Keep last N files in session (default: 5) | ||
| export AK_MULTIMODAL__ATTACHMENT_TTL=604800 # File lifetime in seconds (default: 604800 = 1 week) | ||
| ``` |
There was a problem hiding this comment.
The docs list AK_MULTIMODAL__ATTACHMENT_TTL and state defaults for AK_MULTIMODAL__MAX_ATTACHMENTS, but the current config model only defines multimodal.max_attachments (default 20) and there is no TTL setting/implementation in the code. Either implement TTL + align defaults, or update this block to reflect the actual supported configuration.
| ```bash | ||
| export AK_MULTIMODAL__ENABLED=true # Enable multimodal support (default: false) | ||
| export AK_MULTIMODAL__MAX_ATTACHMENTS=5 # Keep last N files in session (default: 5) | ||
| export AK_MULTIMODAL__ATTACHMENT_TTL=604800 # File lifetime in seconds (default: 604800 = 1 week) | ||
| ``` |
There was a problem hiding this comment.
This README documents AK_MULTIMODAL__ATTACHMENT_TTL and claims AK_MULTIMODAL__MAX_ATTACHMENTS defaults to 5, but the current Agent Kernel config only supports multimodal.max_attachments (default 20) and has no TTL setting. Please update the README to match the implemented config, or add the missing config fields + TTL behavior.
| 2. **Messaging endpoint**: Enter your server's public URL + `/teams/messages`. | ||
| * Example: `https://your-server.com/teams/messages` | ||
| 3. Click **Apply**. | ||
| 4. Go to **Channels** and add **Microsoft Teams**. accepting the terms. |
There was a problem hiding this comment.
Grammar: "Go to Channels and add Microsoft Teams. accepting the terms." should be a complete sentence (e.g., "..., accepting the terms" or "... and add ..., accepting the terms").
| class _APIConfig(BaseModel): | ||
| host: str = Field(default="0.0.0.0", description="API host") | ||
| port: int = Field(default=8000, description="API port") | ||
| enabled_routes: _RoutesConfig = Field(description="API route flags", default_factory=_RoutesConfig) | ||
| custom_router_prefix: str = Field(default="/custom", description="Custom router prefix") | ||
| max_file_size: int = Field(default=2097152, description="Maximum file size in bytes (default: 2 MB)") | ||
| max_file_size: int = Field(default=20971520, description="Maximum file size in bytes (default: 20 MB)") | ||
|
|
||
|
|
||
| class _A2AConfig(BaseModel): | ||
| enabled: bool = Field(default=False, description="Enable A2A") | ||
| agents: List[str] = Field(default=["*"], description="List of agent names to enable A2A") | ||
| url: str = Field(default="http://localhost:8000/a2a", description="A2A URL") | ||
| task_store_type: str = Field(default="in_memory", pattern="^(in_memory|redis)$") | ||
|
|
||
|
|
||
| class _MCPConfig(BaseModel): | ||
| enabled: bool = Field(default=False, description="Enable MCP") | ||
| expose_agents: bool = Field(default=False, description="Expose agents as MCP tools") | ||
| agents: List[str] = Field(default=["*"], description="List of agent names to expose as MCP tool") | ||
| url: str = Field(default="http://localhost:8000/mcp", description="MCP URL") | ||
|
|
||
|
|
||
| class _SlackConfig(BaseModel): | ||
| agent: str = Field(default="", description="Default agent to use for Slack interactions") | ||
| agent_acknowledgement: str = Field( | ||
| default="", | ||
| description="The message to send as an acknowledgement when a Slack message is received", | ||
| ) | ||
| test_mode: bool = Field(default=False, description="Enable test mode for Slack integration") | ||
|
|
||
|
|
||
| class _TeamsConfig(BaseModel): | ||
| agent: str = Field(default="", description="Default agent to use for Microsoft Teams interactions") | ||
| agent_acknowledgement: str = Field( | ||
| default="", | ||
| description="The message to send as an acknowledgement when a Teams message is received", | ||
| ) | ||
| app_id: str = Field(default="", description="Microsoft App ID (Application/Client ID) from Azure AD App Registration") | ||
| app_password: str = Field(default="", description="Microsoft App Password (Client Secret) from Azure AD App Registration") | ||
| tenant_id: str = Field(default="", description="Optional Tenant ID for Single Tenant App registration") |
There was a problem hiding this comment.
PR description frames this as a Slack/Telegram integration test/refactor, but this change set also introduces new public config surface (teams, multimodal, slack.test_mode) and changes defaults like api.max_file_size (2MB -> 20MB). Please update the PR description/type-of-change checklist to reflect these functional additions, since they impact library behavior beyond tests.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 47 out of 54 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import os | ||
|
|
||
| base_url_env = os.getenv("AK_WHATSAPP__API_BASE_URL") | ||
| self._base_url = f"{base_url_env or Config.get().whatsapp.api_base_url}/{self._api_version}" |
There was a problem hiding this comment.
AK_WHATSAPP__API_BASE_URL is being read directly via os.getenv() inside __init__, even though api_base_url is now a first-class config field (Config.get().whatsapp.api_base_url) and will already be populated from the same env var by the settings layer. This duplication also makes precedence/whitespace handling inconsistent (here the env var isn’t .strip()ed). Consider relying on Config.get().whatsapp.api_base_url only (and normalizing it once) and removing the inline import os.
| import os | ||
|
|
||
| base_url_env = os.environ.get("AK_WHATSAPP__API_BASE_URL", "").strip() | ||
| if not base_url_env: | ||
| base_url_env = Config.get().whatsapp.api_base_url | ||
| base_url = f"{base_url_env}/{self._api_version}" | ||
| url = f"{base_url}/{self._phone_number_id}/messages" | ||
|
|
There was a problem hiding this comment.
_send_message() re-reads AK_WHATSAPP__API_BASE_URL and recomputes the base URL even though self._base_url was already constructed in __init__. This duplication makes it easy for the handler to use different base URLs in different code paths (and it adds another inline import os). Consider computing/normalizing the base URL once (in config or __init__) and reusing that value here.
| import os | |
| base_url_env = os.environ.get("AK_WHATSAPP__API_BASE_URL", "").strip() | |
| if not base_url_env: | |
| base_url_env = Config.get().whatsapp.api_base_url | |
| base_url = f"{base_url_env}/{self._api_version}" | |
| url = f"{base_url}/{self._phone_number_id}/messages" | |
| url = f"{self._base_url}/{self._phone_number_id}/messages" |
| _system_pre_hooks: list = [MultimodalPreHookFactory.get(), InputGuardrailFactory.get()] | ||
| _system_post_hooks: list = [OutputGuardrailFactory.get()] |
There was a problem hiding this comment.
_system_pre_hooks is initialized at import/class-definition time via MultimodalPreHookFactory.get(). If multimodal is enabled/disabled via env/config after import (or in tests that monkeypatch config), this list will keep the originally-created hook (often the NoOp hook), so multimodal pre-processing may never run. Consider instantiating the multimodal hook inside Runtime.__init__ (or always registering MultimodalPreHook and letting it check config in on_run) instead of freezing it as a class-level value.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 49 out of 56 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| service = get_gmail_service() | ||
| if not service: | ||
| pytest.skip(f"Valid token.pickle not found at {TOKEN_FILE}. Cannot run real Gmail test.") | ||
|
|
There was a problem hiding this comment.
get_gmail_service() can return None for reasons other than a missing token.pickle (e.g., missing AK_GMAIL__CLIENT_ID/AK_GMAIL__CLIENT_SECRET), but the skip reason here always claims the token file is missing. Consider returning a more specific error/enum from get_gmail_service() or updating the skip message to cover all None cases.
| @@ -0,0 +1,301 @@ | |||
| import base64 | |||
| import io | |||
There was a problem hiding this comment.
io is imported but never used in this module. Please remove the unused import to avoid lint/test failures in stricter environments.
| import io |
| 2. **Messaging endpoint**: Enter your server's public URL + `/teams/messages`. | ||
| * Example: `https://your-server.com/teams/messages` | ||
| 3. Click **Apply**. | ||
| 4. Go to **Channels** and add **Microsoft Teams**. accepting the terms. |
There was a problem hiding this comment.
Grammar: "add Microsoft Teams. accepting the terms." should be a complete sentence (e.g., "...and add Microsoft Teams, accepting the terms.").
| import asyncio | ||
| import json | ||
| import os |
There was a problem hiding this comment.
json is imported but not used in this test module. Please remove the unused import to keep the integration test tidy.
| if config and config.enabled: | ||
| self.override_system_prompt(session=None, prompt=self.get_system_prompt_suffix()) |
There was a problem hiding this comment.
override_system_prompt is declared to require a Session, but _setup_system_prompt() calls it with session=None. This is inconsistent with the abstract method contract and may break implementations that assume a real session. Consider typing the parameter as Session | None (and updating implementations) or refactor init-time prompt injection to not require a session object.
| if config and config.enabled: | |
| self.override_system_prompt(session=None, prompt=self.get_system_prompt_suffix()) | |
| session = getattr(self, "session", None) | |
| if config and config.enabled and session is not None: | |
| self.override_system_prompt(session=session, prompt=self.get_system_prompt_suffix()) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 50 out of 57 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await turn_context.send_activity( | ||
| f"Sorry {user_name}, I could not download the following files: {', '.join(failed_files)}. Please try again." | ||
| ) | ||
| return |
There was a problem hiding this comment.
_process_attachments() returns a combined list of (download failures + rejected audio/video + oversized files), but the caller treats this purely as “could not download” and aborts the whole request. Consider returning a structured result (e.g., separate lists for failed/rejected/oversized) so you can send an accurate user message (and potentially still proceed when only some attachments are rejected).
| await turn_context.send_activity( | |
| f"Sorry {user_name}, I could not download the following files: {', '.join(failed_files)}. Please try again." | |
| ) | |
| return | |
| # Some attachments could not be used (e.g., failed download, rejected type, or oversized), | |
| # but we can still proceed with the rest of the request. | |
| await turn_context.send_activity( | |
| f"Sorry {user_name}, I could not process the following files and will continue without them: {', '.join(failed_files)}." | |
| ) |
Description
Add real Slack/Telegram integration test
Type of Change
Related Issues
Fixes #
Relates to #
Changes Made
Testing
Checklist
Screenshots (if applicable)
Additional Notes