diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 381535ab8ac..06a231f092b 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -5560,6 +5560,52 @@ class TelegramAdapter(BasePlatformAdapter): event.text = self._append_observed_note(event.text, cached.context_note()) logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path) + async def _cache_replied_media(self, msg: Any, event: MessageEvent) -> None: + """Cache media from the message this turn replies to, if any.""" + from gateway.platforms.base import cache_media_bytes + + reply_msg = getattr(msg, "reply_to_message", None) + if reply_msg is None: + return + source, filename, mime, kind = self._observed_media_source(reply_msg) + if source is None: + return + + max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024) + file_size = getattr(source, "file_size", None) + try: + size = int(file_size or 0) + except (TypeError, ValueError): + size = 0 + if not (0 < size <= max_bytes): + return + + try: + file_obj = await source.get_file() + data = bytes(await file_obj.download_as_bytearray()) + if not filename: + filename = os.path.basename(getattr(file_obj, "file_path", "") or "") + cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind) + except Exception as exc: + logger.warning("[Telegram] Failed to cache replied-to media: %s", exc, exc_info=True) + return + + if cached is None: + return + + event.media_urls.append(cached.path) + event.media_types.append(cached.media_type) + if len(event.media_urls) == 1: + if cached.kind == "image": + event.message_type = MessageType.PHOTO + elif cached.kind == "video": + event.message_type = MessageType.VIDEO + event.text = self._append_observed_note( + event.text, + f"[Replied-to {cached.kind} '{cached.display_name}' saved at: {cached.path}]", + ) + logger.info("[Telegram] Cached replied-to %s at %s", cached.kind, cached.path) + def _observed_media_source(self, msg: Message): """Return (telegram_file_source, filename, mime, default_kind) or Nones.""" if msg.photo: @@ -5749,6 +5795,7 @@ class TelegramAdapter(BasePlatformAdapter): event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id) event.text = self._clean_bot_trigger_text(event.text) + await self._cache_replied_media(msg, event) event = self._apply_telegram_group_observe_attribution(event) self._enqueue_text_event(event) @@ -5763,6 +5810,7 @@ class TelegramAdapter(BasePlatformAdapter): event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id) event.text = self._clean_bot_trigger_text(event.text) + await self._cache_replied_media(msg, event) event = self._apply_telegram_group_observe_attribution(event) await self.handle_message(event) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 196564dd14f..69b1bf4d228 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -4973,7 +4973,13 @@ class DiscordAdapter(BasePlatformAdapter): auto_threaded_channel = thread self._threads.mark(thread_id) - all_attachments = list(message.attachments) + snapshot_attachments + referenced_attachments = [] + reference = getattr(message, "reference", None) + resolved_reference = getattr(reference, "resolved", None) if reference else None + if resolved_reference is not None: + referenced_attachments = list(getattr(resolved_reference, "attachments", []) or []) + + all_attachments = list(message.attachments) + snapshot_attachments + referenced_attachments # Determine message type msg_type = MessageType.TEXT diff --git a/tests/e2e/test_discord_adapter.py b/tests/e2e/test_discord_adapter.py index 891d4806821..412163f43cb 100644 --- a/tests/e2e/test_discord_adapter.py +++ b/tests/e2e/test_discord_adapter.py @@ -5,6 +5,7 @@ Covers the fix for slash commands not being recognized when sent via """ import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock import pytest @@ -104,3 +105,51 @@ class TestAutoThreadingPreservesCommand: response = get_response_text(discord_adapter) assert response is not None assert "/new" in response + + +class TestRepliedToMediaDispatch: + async def test_reply_to_image_message_caches_referenced_attachment( + self, discord_adapter, bot_user, monkeypatch + ): + """A text reply to an image-bearing Discord message should give the agent that image.""" + cached_path = "/tmp/replied-discord-image.png" + + async def fake_cache_image_from_url(url, *, ext=".jpg"): + assert url == "https://cdn.discordapp.com/attachments/image.png" + assert ext == ".png" + return cached_path + + monkeypatch.setattr( + "plugins.platforms.discord.adapter.cache_image_from_url", + fake_cache_image_from_url, + ) + discord_adapter.handle_message = AsyncMock() + + attachment = SimpleNamespace( + content_type="image/png", + filename="image.png", + url="https://cdn.discordapp.com/attachments/image.png", + size=1234, + ) + referenced_message = SimpleNamespace( + id=12345, + content="", + attachments=[attachment], + ) + msg = make_discord_message( + content=f"<@{BOT_USER_ID}> what's in this image?", + mentions=[bot_user], + ) + msg.type = 19 + msg.reference = SimpleNamespace(message_id=12345, resolved=referenced_message) + + await discord_adapter._handle_message(msg) + + discord_adapter.handle_message.assert_awaited_once() + await_args = discord_adapter.handle_message.await_args + assert await_args is not None + event = await_args.args[0] + assert event.reply_to_message_id == "12345" + assert event.media_urls == [cached_path] + assert event.media_types == ["image/png"] + assert event.message_type.value == "photo" diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index f5fb112f136..d43124b5636 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -1007,6 +1007,53 @@ def test_triggered_voice_message_uses_shared_session_in_observe_mode(): asyncio.run(_run()) +# --------------------------------------------------------------------------- +# Replied-to media caching +# --------------------------------------------------------------------------- + +def test_text_reply_to_photo_caches_referenced_media(monkeypatch, tmp_path): + async def _run(): + adapter = _make_adapter(require_mention=False) + adapter.handle_message = AsyncMock() + cached_path = tmp_path / "reply_photo.png" + monkeypatch.setattr( + "gateway.platforms.base.cache_image_from_bytes", + lambda _data, ext=".jpg": str(cached_path), + ) + file_obj = SimpleNamespace( + file_path="photos/replied.png", + download_as_bytearray=AsyncMock(return_value=bytearray(b"\x89PNG\r\n\x1a\n reply")), + ) + photo = SimpleNamespace(file_size=1234, get_file=AsyncMock(return_value=file_obj)) + replied = SimpleNamespace( + message_id=51, + text=None, + caption=None, + photo=[photo], + video=None, + audio=None, + voice=None, + document=None, + ) + msg = _group_message("what's in this image?", reply_to_bot=False) + msg.reply_to_message = replied + update = SimpleNamespace(update_id=3010, message=msg, effective_message=msg) + + await adapter._handle_text_message(update, SimpleNamespace()) + await asyncio.sleep(0.05) + + adapter.handle_message.assert_awaited_once() + await_args = adapter.handle_message.await_args + assert await_args is not None + event = await_args.args[0] + assert event.reply_to_message_id == "51" + assert event.media_urls == [str(cached_path)] + assert event.media_types == ["image/png"] + assert event.message_type == MessageType.PHOTO + + asyncio.run(_run()) + + # --------------------------------------------------------------------------- # Observed-media caching (unmentioned group attachments) # ---------------------------------------------------------------------------