diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index c12d417b3..eaf9a90af 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -413,7 +413,11 @@ async def edit_message( """ return SendResult(success=False, error="Not supported") - async def send_typing(self, chat_id: str) -> None: + async def send_typing( + self, + chat_id: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: """ Send a typing indicator. @@ -427,6 +431,7 @@ async def send_image( image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """ Send an image natively via the platform API. @@ -437,7 +442,12 @@ async def send_image( """ # Fallback: send URL as text (subclasses override for native images) text = f"{caption}\n{image_url}" if caption else image_url - return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + return await self.send( + chat_id=chat_id, + content=text, + reply_to=reply_to, + metadata=metadata, + ) async def send_animation( self, @@ -445,6 +455,7 @@ async def send_animation( animation_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """ Send an animated GIF natively via the platform API. @@ -453,7 +464,13 @@ async def send_animation( (e.g., Telegram send_animation) so they auto-play inline. Default falls back to send_image. """ - return await self.send_image(chat_id=chat_id, image_url=animation_url, caption=caption, reply_to=reply_to) + return await self.send_image( + chat_id=chat_id, + image_url=animation_url, + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) @staticmethod def _is_animation_url(url: str) -> bool: @@ -515,6 +532,7 @@ async def send_voice( audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """ Send an audio file as a native voice message via the platform API. @@ -526,7 +544,12 @@ async def send_voice( text = f"🔊 Audio: {audio_path}" if caption: text = f"{caption}\n{text}" - return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + return await self.send( + chat_id=chat_id, + content=text, + reply_to=reply_to, + metadata=metadata, + ) async def send_video( self, @@ -534,6 +557,7 @@ async def send_video( video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """ Send a video natively via the platform API. @@ -544,7 +568,12 @@ async def send_video( text = f"🎬 Video: {video_path}" if caption: text = f"{caption}\n{text}" - return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + return await self.send( + chat_id=chat_id, + content=text, + reply_to=reply_to, + metadata=metadata, + ) async def send_document( self, @@ -553,6 +582,7 @@ async def send_document( caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """ Send a document/file natively via the platform API. @@ -563,7 +593,12 @@ async def send_document( text = f"📎 File: {file_path}" if caption: text = f"{caption}\n{text}" - return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + return await self.send( + chat_id=chat_id, + content=text, + reply_to=reply_to, + metadata=metadata, + ) async def send_image_file( self, @@ -571,6 +606,7 @@ async def send_image_file( image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """ Send a local image file natively via the platform API. @@ -582,7 +618,12 @@ async def send_image_file( text = f"🖼️ Image: {image_path}" if caption: text = f"{caption}\n{text}" - return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + return await self.send( + chat_id=chat_id, + content=text, + reply_to=reply_to, + metadata=metadata, + ) @staticmethod def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: @@ -620,7 +661,12 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: return media, cleaned - async def _keep_typing(self, chat_id: str, interval: float = 2.0) -> None: + async def _keep_typing( + self, + chat_id: str, + interval: float = 2.0, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: """ Continuously send typing indicator until cancelled. @@ -629,7 +675,7 @@ async def _keep_typing(self, chat_id: str, interval: float = 2.0) -> None: """ try: while True: - await self.send_typing(chat_id) + await self.send_typing(chat_id, metadata=metadata) await asyncio.sleep(interval) except asyncio.CancelledError: pass # Normal cancellation when handler completes diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 39267fba3..f15e88275 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -235,6 +235,7 @@ async def send_voice( audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send audio as a Discord file attachment.""" if not self._client: @@ -265,7 +266,13 @@ async def send_voice( except Exception as e: print(f"[{self.name}] Failed to send audio: {e}") - return await super().send_voice(chat_id, audio_path, caption, reply_to) + return await super().send_voice( + chat_id, + audio_path, + caption, + reply_to, + metadata=metadata, + ) async def send_image_file( self, @@ -273,6 +280,7 @@ async def send_image_file( image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a local image file natively as a Discord file attachment.""" if not self._client: @@ -302,7 +310,13 @@ async def send_image_file( except Exception as e: print(f"[{self.name}] Failed to send local image: {e}") - return await super().send_image_file(chat_id, image_path, caption, reply_to) + return await super().send_image_file( + chat_id, + image_path, + caption, + reply_to, + metadata=metadata, + ) async def send_image( self, @@ -310,6 +324,7 @@ async def send_image( image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an image natively as a Discord file attachment.""" if not self._client: @@ -354,10 +369,22 @@ async def send_image( except ImportError: print(f"[{self.name}] aiohttp not installed, falling back to URL. Run: pip install aiohttp") - return await super().send_image(chat_id, image_url, caption, reply_to) + return await super().send_image( + chat_id, + image_url, + caption, + reply_to, + metadata=metadata, + ) except Exception as e: print(f"[{self.name}] Failed to send image attachment, falling back to URL: {e}") - return await super().send_image(chat_id, image_url, caption, reply_to) + return await super().send_image( + chat_id, + image_url, + caption, + reply_to, + metadata=metadata, + ) async def send_typing(self, chat_id: str, metadata=None) -> None: """Send typing indicator.""" diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 62e7e4b63..5816e5f69 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -569,7 +569,11 @@ async def send( return SendResult(success=True) return SendResult(success=False, error="RPC send failed") - async def send_typing(self, chat_id: str) -> None: + async def send_typing( + self, + chat_id: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: """Send a typing indicator.""" params: Dict[str, Any] = { "account": self.account, @@ -587,6 +591,7 @@ async def send_image( chat_id: str, image_url: str, caption: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: """Send an image. Supports http(s):// and file:// URLs.""" @@ -633,6 +638,7 @@ async def send_document( file_path: str, caption: Optional[str] = None, filename: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: """Send a document/file attachment.""" diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 3449971f0..7da031685 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -195,6 +195,7 @@ async def send_image_file( image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a local image file to Slack by uploading it.""" if not self._app: @@ -216,7 +217,13 @@ async def send_image_file( except Exception as e: print(f"[{self.name}] Failed to send local image: {e}") - return await super().send_image_file(chat_id, image_path, caption, reply_to) + return await super().send_image_file( + chat_id, + image_path, + caption, + reply_to, + metadata=metadata, + ) async def send_image( self, @@ -224,6 +231,7 @@ async def send_image( image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an image to Slack by uploading the URL as a file.""" if not self._app: @@ -250,7 +258,12 @@ async def send_image( except Exception as e: # Fall back to sending the URL as text text = f"{caption}\n{image_url}" if caption else image_url - return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + return await self.send( + chat_id=chat_id, + content=text, + reply_to=reply_to, + metadata=metadata, + ) async def send_voice( self, @@ -258,6 +271,7 @@ async def send_voice( audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an audio file to Slack.""" if not self._app: @@ -282,6 +296,7 @@ async def send_video( video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a video file to Slack.""" if not self._app: @@ -302,7 +317,13 @@ async def send_video( except Exception as e: print(f"[{self.name}] Failed to send video: {e}") - return await super().send_video(chat_id, video_path, caption, reply_to) + return await super().send_video( + chat_id, + video_path, + caption, + reply_to, + metadata=metadata, + ) async def send_document( self, @@ -311,6 +332,7 @@ async def send_document( caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a document/file attachment to Slack.""" if not self._app: @@ -333,7 +355,14 @@ async def send_document( except Exception as e: print(f"[{self.name}] Failed to send document: {e}") - return await super().send_document(chat_id, file_path, caption, file_name, reply_to) + return await super().send_document( + chat_id, + file_path, + caption, + file_name, + reply_to, + metadata=metadata, + ) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Get information about a Slack channel.""" diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 1ec64b4e0..2322563b1 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -332,6 +332,7 @@ async def send_image_file( image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a local image file natively as a Telegram photo.""" if not self._bot: @@ -343,16 +344,24 @@ async def send_image_file( return SendResult(success=False, error=f"Image file not found: {image_path}") with open(image_path, "rb") as image_file: + _photo_thread = metadata.get("thread_id") if metadata else None msg = await self._bot.send_photo( chat_id=int(chat_id), photo=image_file, caption=caption[:1024] if caption else None, reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=int(_photo_thread) if _photo_thread else None, ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: print(f"[{self.name}] Failed to send local image: {e}") - return await super().send_image_file(chat_id, image_path, caption, reply_to) + return await super().send_image_file( + chat_id, + image_path, + caption, + reply_to, + metadata=metadata, + ) async def send_image( self, @@ -396,12 +405,19 @@ async def send_image( photo=image_data, caption=caption[:1024] if caption else None, reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=int(_photo_thread) if _photo_thread else None, ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e2: logger.error("[%s] File upload send_photo also failed: %s", self.name, e2) # Final fallback: send URL as text - return await super().send_image(chat_id, image_url, caption, reply_to) + return await super().send_image( + chat_id, + image_url, + caption, + reply_to, + metadata=metadata, + ) async def send_animation( self, @@ -428,7 +444,13 @@ async def send_animation( except Exception as e: print(f"[{self.name}] Failed to send animation, falling back to photo: {e}") # Fallback: try as a regular photo - return await self.send_image(chat_id, animation_url, caption, reply_to) + return await self.send_image( + chat_id, + animation_url, + caption, + reply_to, + metadata=metadata, + ) async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: """Send typing indicator.""" diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index f2000add8..35772b28d 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -451,13 +451,20 @@ async def send_image( image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Download image URL to cache, send natively via bridge.""" try: local_path = await cache_image_from_url(image_url) return await self._send_media_to_bridge(chat_id, local_path, "image", caption) except Exception: - return await super().send_image(chat_id, image_url, caption, reply_to) + return await super().send_image( + chat_id, + image_url, + caption, + reply_to, + metadata=metadata, + ) async def send_image_file( self, @@ -465,6 +472,7 @@ async def send_image_file( image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a local image file natively via bridge.""" return await self._send_media_to_bridge(chat_id, image_path, "image", caption) @@ -475,6 +483,7 @@ async def send_video( video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a video natively via bridge — plays inline in WhatsApp.""" return await self._send_media_to_bridge(chat_id, video_path, "video", caption) @@ -486,6 +495,7 @@ async def send_document( caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a document/file as a downloadable attachment via bridge.""" return await self._send_media_to_bridge( @@ -635,4 +645,3 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv except Exception as e: print(f"[{self.name}] Error building event: {e}") return None - diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 145b6576f..4e5730b6a 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -1,8 +1,14 @@ """Tests for gateway/platforms/base.py — MessageEvent, media extraction, message truncation.""" +import asyncio +import ast import os +from pathlib import Path from unittest.mock import patch +import pytest + +from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, @@ -368,3 +374,117 @@ def test_custom_mode_uses_env_vars(self): with patch.dict(os.environ, env): delay = BasePlatformAdapter._get_human_delay() assert 0.1 <= delay <= 0.2 + + +# --------------------------------------------------------------------------- +# metadata forwarding contract +# --------------------------------------------------------------------------- + + +class _RecordingAdapter(BasePlatformAdapter): + def __init__(self): + config = PlatformConfig(enabled=True, token="test") + super().__init__(config=config, platform=Platform.TELEGRAM) + self.typing_calls = [] + self.send_calls = [] + + async def connect(self): + return True + + async def disconnect(self): + pass + + async def send(self, chat_id, content, reply_to=None, metadata=None): + self.send_calls.append( + { + "chat_id": chat_id, + "content": content, + "reply_to": reply_to, + "metadata": metadata, + } + ) + + async def send_typing(self, chat_id, metadata=None): + self.typing_calls.append({"chat_id": chat_id, "metadata": metadata}) + raise asyncio.CancelledError + + async def get_chat_info(self, *args): + return {} + + +@pytest.mark.asyncio +async def test_keep_typing_accepts_metadata_and_forwards_it(): + adapter = _RecordingAdapter() + metadata = {"thread_id": "42"} + + await adapter._keep_typing("chat-123", metadata=metadata, interval=0) + + assert adapter.typing_calls == [{"chat_id": "chat-123", "metadata": metadata}] + + +@pytest.mark.asyncio +async def test_base_fallback_media_helpers_accept_metadata(): + adapter = _RecordingAdapter() + metadata = {"thread_id": "42"} + + await adapter.send_image("chat-123", "https://example.com/cat.png", metadata=metadata) + await adapter.send_animation("chat-123", "https://example.com/cat.gif", metadata=metadata) + await adapter.send_voice("chat-123", "/tmp/voice.ogg", metadata=metadata) + await adapter.send_video("chat-123", "/tmp/video.mp4", metadata=metadata) + await adapter.send_document("chat-123", "/tmp/file.txt", metadata=metadata) + await adapter.send_image_file("chat-123", "/tmp/image.png", metadata=metadata) + + assert [call["metadata"] for call in adapter.send_calls] == [metadata] * 6 + + +@pytest.mark.parametrize( + ("relative_path", "class_name", "method_name"), + [ + ("gateway/platforms/base.py", "BasePlatformAdapter", "_keep_typing"), + ("gateway/platforms/base.py", "BasePlatformAdapter", "send_typing"), + ("gateway/platforms/base.py", "BasePlatformAdapter", "send_image"), + ("gateway/platforms/base.py", "BasePlatformAdapter", "send_animation"), + ("gateway/platforms/base.py", "BasePlatformAdapter", "send_voice"), + ("gateway/platforms/base.py", "BasePlatformAdapter", "send_video"), + ("gateway/platforms/base.py", "BasePlatformAdapter", "send_document"), + ("gateway/platforms/base.py", "BasePlatformAdapter", "send_image_file"), + ("gateway/platforms/discord.py", "DiscordAdapter", "send_typing"), + ("gateway/platforms/discord.py", "DiscordAdapter", "send_voice"), + ("gateway/platforms/discord.py", "DiscordAdapter", "send_image"), + ("gateway/platforms/discord.py", "DiscordAdapter", "send_image_file"), + ("gateway/platforms/signal.py", "SignalAdapter", "send_typing"), + ("gateway/platforms/signal.py", "SignalAdapter", "send_image"), + ("gateway/platforms/signal.py", "SignalAdapter", "send_document"), + ("gateway/platforms/slack.py", "SlackAdapter", "send_typing"), + ("gateway/platforms/slack.py", "SlackAdapter", "send_image"), + ("gateway/platforms/slack.py", "SlackAdapter", "send_image_file"), + ("gateway/platforms/slack.py", "SlackAdapter", "send_voice"), + ("gateway/platforms/slack.py", "SlackAdapter", "send_video"), + ("gateway/platforms/slack.py", "SlackAdapter", "send_document"), + ("gateway/platforms/telegram.py", "TelegramAdapter", "send_typing"), + ("gateway/platforms/telegram.py", "TelegramAdapter", "send_image"), + ("gateway/platforms/telegram.py", "TelegramAdapter", "send_animation"), + ("gateway/platforms/telegram.py", "TelegramAdapter", "send_voice"), + ("gateway/platforms/telegram.py", "TelegramAdapter", "send_image_file"), + ("gateway/platforms/whatsapp.py", "WhatsAppAdapter", "send_typing"), + ("gateway/platforms/whatsapp.py", "WhatsAppAdapter", "send_image"), + ("gateway/platforms/whatsapp.py", "WhatsAppAdapter", "send_image_file"), + ("gateway/platforms/whatsapp.py", "WhatsAppAdapter", "send_video"), + ("gateway/platforms/whatsapp.py", "WhatsAppAdapter", "send_document"), + ], +) +def test_gateway_metadata_forwarding_signatures_accept_metadata( + relative_path, class_name, method_name +): + source = Path(relative_path).read_text() + tree = ast.parse(source, filename=relative_path) + + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + for item in node.body: + if isinstance(item, ast.AsyncFunctionDef) and item.name == method_name: + params = [arg.arg for arg in item.args.args] + assert "metadata" in params + return + + pytest.fail(f"{class_name}.{method_name} not found in {relative_path}") diff --git a/tests/tools/test_vision_tools.py b/tests/tools/test_vision_tools.py index 3bdd30178..955323c24 100644 --- a/tests/tools/test_vision_tools.py +++ b/tests/tools/test_vision_tools.py @@ -241,9 +241,12 @@ async def test_download_failure_logs_exc_info(self, tmp_path, caplog): @pytest.mark.asyncio async def test_analysis_error_logs_exc_info(self, caplog): """When vision_analyze_tool encounters an error, it should log with exc_info.""" + mock_client = MagicMock() with patch("tools.vision_tools._validate_image_url", return_value=True), \ patch("tools.vision_tools._download_image", new_callable=AsyncMock, side_effect=Exception("download boom")), \ + patch("tools.vision_tools._aux_async_client", mock_client), \ + patch("tools.vision_tools.DEFAULT_VISION_MODEL", "test/model"), \ caplog.at_level(logging.ERROR, logger="tools.vision_tools"): result = await vision_analyze_tool(