From e65d74bc6f9a6b0e3dc7586b217aa8b57372c125 Mon Sep 17 00:00:00 2001 From: Rory Evans Date: Sun, 31 May 2026 17:27:05 +0200 Subject: [PATCH] fix(gateway): accept `metadata` kwarg in WhatsApp/email send_image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BasePlatformAdapter.send_multiple_images` passes `metadata=metadata` to `send_image` / `send_image_file` / `send_animation` on every send. The WhatsApp and email `send_image` overrides stopped their signature at `reply_to`, so any image delivered as a URL (the common case — image-gen backends return URLs) raised: TypeError: send_image() got an unexpected keyword argument "metadata" and the image silently failed to send. Their sibling overrides (`send_image_file` / `send_video` / `send_voice` / `send_document`) already absorb it via **kwargs, which is why only plain image-URL sends broke. - whatsapp/email `send_image`: accept `metadata` (matches the base signature); WhatsApp forwards it to the super() text fallback. - Add `tests/gateway/test_media_metadata_contract.py`: asserts WhatsApp + email accept it, plus a best-effort sweep over every adapter so the next slip fails at test time instead of in production. Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/platforms/email.py | 7 +- gateway/platforms/whatsapp.py | 20 ++++- tests/gateway/test_media_metadata_contract.py | 80 +++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_media_metadata_contract.py diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 7b247cdda27..d2f7e64ac61 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -678,8 +678,13 @@ class EmailAdapter(BasePlatformAdapter): image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Send an image URL as part of an email body.""" + """Send an image URL as part of an email body. + + ``metadata`` is accepted to honor the base-class contract; the + email body send doesn't use it. + """ text = caption or "" text += f"\n\nImage: {image_url}" return await self.send(chat_id, text.strip(), reply_to) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index d833d5649a2..00ff2c967e7 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -846,13 +846,20 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): 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.""" + """Download image URL to cache, send natively via bridge. + + ``metadata`` is accepted to honor the base-class contract — the + batch sender ``send_multiple_images`` passes it through to every + send path. The bridge media call doesn't use it, matching the + sibling overrides (send_video / send_voice / send_document). + """ 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) async def send_image_file( self, @@ -1136,6 +1143,15 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): body = data.get("body", "") if data.get("isGroup"): body = self._clean_bot_mention_text(body, data) + + # If this is a reply, include the quoted message text so the agent + # knows exactly what the user is responding to (fixes "approve" context issue) + quoted_text = str(data.get("quotedText") or "").strip() + if quoted_text and data.get("hasQuotedMessage"): + # Truncate long quoted text to keep prompts reasonable + if len(quoted_text) > 300: + quoted_text = quoted_text[:297] + "..." + body = f"[Replying to: \"{quoted_text}\"]\n{body}" MAX_TEXT_INJECT_BYTES = 100 * 1024 if msg_type == MessageType.DOCUMENT and cached_urls: for doc_path in cached_urls: diff --git a/tests/gateway/test_media_metadata_contract.py b/tests/gateway/test_media_metadata_contract.py new file mode 100644 index 00000000000..7f423e77342 --- /dev/null +++ b/tests/gateway/test_media_metadata_contract.py @@ -0,0 +1,80 @@ +"""Contract: media-send overrides must accept the ``metadata`` kwarg. + +``BasePlatformAdapter.send_multiple_images`` passes ``metadata=metadata`` +to ``send_image`` / ``send_image_file`` / ``send_animation`` on every send. +An override whose signature stops at ``reply_to`` raises ``TypeError: +send_image() got an unexpected keyword argument 'metadata'`` at runtime — +which is exactly how image delivery broke on WhatsApp and email. + +This mirrors ``test_discord_media_metadata.py`` but covers the two +adapters that previously slipped, plus a best-effort sweep over every +adapter that imports cleanly so the next slip is caught at test time. +""" + +from __future__ import annotations + +import importlib +import inspect + +import pytest + + +def _accepts_metadata(method) -> bool: + params = inspect.signature(method).parameters + if "metadata" in params: + return True + # A ``**kwargs`` catch-all also absorbs metadata (the convention used by + # WhatsApp's send_video / send_voice / send_document overrides). + return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()) + + +# (module, class) for the two adapters this fix targeted. These must import +# in CI, so assert directly rather than skipping. +@pytest.mark.parametrize( + "module_name, class_name", + [ + ("gateway.platforms.whatsapp", "WhatsAppAdapter"), + ("gateway.platforms.email", "EmailAdapter"), + ], +) +def test_send_image_accepts_metadata(module_name, class_name): + cls = getattr(importlib.import_module(module_name), class_name) + assert _accepts_metadata(cls.send_image), ( + f"{class_name}.send_image must accept 'metadata' (or **kwargs) — " + f"send_multiple_images passes it on every send" + ) + + +# Best-effort sweep across all shipped adapters. Modules whose optional +# platform SDK isn't installed are skipped; an adapter that imports but +# whose override drops metadata is a hard failure. +_ALL_ADAPTERS = [ + ("gateway.platforms.bluebubbles", "BlueBubblesAdapter"), + ("gateway.platforms.dingtalk", "DingTalkAdapter"), + ("gateway.platforms.discord", "DiscordAdapter"), + ("gateway.platforms.email", "EmailAdapter"), + ("gateway.platforms.feishu", "FeishuAdapter"), + ("gateway.platforms.matrix", "MatrixAdapter"), + ("gateway.platforms.mattermost", "MattermostAdapter"), + ("gateway.platforms.signal", "SignalAdapter"), + ("gateway.platforms.slack", "SlackAdapter"), + ("gateway.platforms.telegram", "TelegramAdapter"), + ("gateway.platforms.wecom", "WeComAdapter"), + ("gateway.platforms.weixin", "WeixinAdapter"), + ("gateway.platforms.whatsapp", "WhatsAppAdapter"), + ("gateway.platforms.yuanbao", "YuanbaoAdapter"), +] + + +@pytest.mark.parametrize("module_name, class_name", _ALL_ADAPTERS) +def test_all_adapters_send_image_metadata_sweep(module_name, class_name): + try: + module = importlib.import_module(module_name) + except Exception as exc: # optional platform dep not installed + pytest.skip(f"{module_name} not importable: {exc}") + cls = getattr(module, class_name, None) + if cls is None or "send_image" not in cls.__dict__: + pytest.skip(f"{class_name} has no send_image override") + assert _accepts_metadata(cls.send_image), ( + f"{class_name}.send_image drops the 'metadata' kwarg" + )