From 64888b4b09175bc41497d343802d352f522be3af Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 06:16:57 +0000 Subject: [PATCH] Simplify reply context extraction, fix slash commands broken by reply injection, attach reply media regardless of caption --- nanobot/channels/telegram.py | 54 +++++------------ tests/test_telegram_channel.py | 103 ++++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 66 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 9373294..916685b 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -468,32 +468,14 @@ class TelegramChannel(BaseChannel): @staticmethod def _extract_reply_context(message) -> str | None: - """Extract content from the message being replied to, if any. Truncated to TELEGRAM_REPLY_CONTEXT_MAX_LEN.""" + """Extract text from the message being replied to, if any.""" reply = getattr(message, "reply_to_message", None) if not reply: return None - text = getattr(reply, "text", None) or getattr(reply, "caption", None) - if text: - truncated = ( - text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] - + ("..." if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN else "") - ) - return f"[Reply to: {truncated}]" - # Reply has no text/caption; use type placeholder when it has media. - # Note: replied-to media is not attached to this message, so the agent won't receive it. - if getattr(reply, "photo", None): - return "[Reply to: (image — not attached)]" - if getattr(reply, "document", None): - return "[Reply to: (document — not attached)]" - if getattr(reply, "voice", None): - return "[Reply to: (voice — not attached)]" - if getattr(reply, "video_note", None) or getattr(reply, "video", None): - return "[Reply to: (video — not attached)]" - if getattr(reply, "audio", None): - return "[Reply to: (audio — not attached)]" - if getattr(reply, "animation", None): - return "[Reply to: (animation — not attached)]" - return "[Reply to: (no text)]" + text = getattr(reply, "text", None) or getattr(reply, "caption", None) or "" + if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN: + text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..." + return f"[Reply to: {text}]" if text else None async def _download_message_media( self, msg, *, add_failure_content: bool = False @@ -629,14 +611,10 @@ class TelegramChannel(BaseChannel): message = update.message user = update.effective_user self._remember_thread_context(message) - reply_ctx = self._extract_reply_context(message) - content = message.text or "" - if reply_ctx: - content = reply_ctx + "\n\n" + content await self._handle_message( sender_id=self._sender_id(user), chat_id=str(message.chat_id), - content=content, + content=message.text or "", metadata=self._build_message_metadata(message, user), session_key=self._derive_topic_session_key(message), ) @@ -677,17 +655,17 @@ class TelegramChannel(BaseChannel): if current_media_paths: logger.debug("Downloaded message media to {}", current_media_paths[0]) - # Reply context: include replied-to content; if reply has media, try to attach it + # Reply context: text and/or media from the replied-to message reply = getattr(message, "reply_to_message", None) - reply_ctx = self._extract_reply_context(message) - if reply_ctx is not None and reply is not None: - if "not attached)]" in reply_ctx: - reply_media_paths, reply_media_parts = await self._download_message_media(reply) - if reply_media_paths and reply_media_parts: - reply_ctx = f"[Reply to: {reply_media_parts[0]}]" - media_paths = reply_media_paths + media_paths - logger.debug("Attached replied-to media: {}", reply_media_paths[0]) - content_parts.insert(0, reply_ctx) + if reply is not None: + reply_ctx = self._extract_reply_context(message) + reply_media, reply_media_parts = await self._download_message_media(reply) + if reply_media: + media_paths = reply_media + media_paths + logger.debug("Attached replied-to media: {}", reply_media[0]) + tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None) + if tag: + content_parts.insert(0, tag) content = "\n".join(content_parts) if content_parts else "[empty message]" logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 75824ac..897f77d 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -379,32 +379,11 @@ def test_extract_reply_context_truncation() -> None: assert len(result) == len("[Reply to: ]") + TELEGRAM_REPLY_CONTEXT_MAX_LEN + len("...") -def test_extract_reply_context_no_text_no_media() -> None: - """When reply has no text/caption and no media, return (no text) placeholder.""" - reply = SimpleNamespace( - text=None, - caption=None, - photo=None, - document=None, - voice=None, - video_note=None, - video=None, - audio=None, - animation=None, - ) +def test_extract_reply_context_no_text_returns_none() -> None: + """When reply has no text/caption, _extract_reply_context returns None (media handled separately).""" + reply = SimpleNamespace(text=None, caption=None) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: (no text)]" - - -def test_extract_reply_context_reply_to_photo() -> None: - """When reply has photo but no text/caption, return (image — not attached) placeholder.""" - reply = SimpleNamespace( - text=None, - caption=None, - photo=[SimpleNamespace(file_id="x")], - ) - message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: (image — not attached)]" + assert TelegramChannel._extract_reply_context(message) is None @pytest.mark.asyncio @@ -518,13 +497,12 @@ async def test_on_message_attaches_reply_to_media_when_available(monkeypatch, tm @pytest.mark.asyncio async def test_on_message_reply_to_media_fallback_when_download_fails() -> None: - """When reply has media but download fails, keep placeholder and do not attach.""" + """When reply has media but download fails, no media attached and no reply tag.""" channel = TelegramChannel( TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), MessageBus(), ) channel._app = _FakeApp(lambda: None) - # No get_file on bot -> download will fail channel._app.bot.get_file = None handled = [] async def capture_handle(**kwargs) -> None: @@ -547,6 +525,75 @@ async def test_on_message_reply_to_media_fallback_when_download_fails() -> None: await channel._on_message(update, None) assert len(handled) == 1 - assert "[Reply to: (image — not attached)]" in handled[0]["content"] assert "what is this?" in handled[0]["content"] assert handled[0]["media"] == [] + + +@pytest.mark.asyncio +async def test_on_message_reply_to_caption_and_media(monkeypatch, tmp_path) -> None: + """When replying to a message with caption + photo, both text context and media are included.""" + media_dir = tmp_path / "media" / "telegram" + media_dir.mkdir(parents=True) + monkeypatch.setattr( + "nanobot.channels.telegram.get_media_dir", + lambda channel=None: media_dir if channel else tmp_path / "media", + ) + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + app = _FakeApp(lambda: None) + app.bot.get_file = AsyncMock( + return_value=SimpleNamespace(download_to_drive=AsyncMock(return_value=None)) + ) + channel._app = app + handled = [] + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + reply_with_caption_and_photo = SimpleNamespace( + text=None, + caption="A cute cat", + photo=[SimpleNamespace(file_id="cat_fid", mime_type="image/jpeg")], + document=None, + voice=None, + audio=None, + video=None, + video_note=None, + animation=None, + ) + update = _make_telegram_update( + text="what breed is this?", + reply_to_message=reply_with_caption_and_photo, + ) + await channel._on_message(update, None) + + assert len(handled) == 1 + assert "[Reply to: A cute cat]" in handled[0]["content"] + assert "what breed is this?" in handled[0]["content"] + assert len(handled[0]["media"]) == 1 + assert "cat_fid" in handled[0]["media"][0] + + +@pytest.mark.asyncio +async def test_forward_command_does_not_inject_reply_context() -> None: + """Slash commands forwarded via _forward_command must not include reply context.""" + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + handled = [] + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + channel._handle_message = capture_handle + + reply = SimpleNamespace(text="some old message", message_id=2, from_user=SimpleNamespace(id=1)) + update = _make_telegram_update(text="/new", reply_to_message=reply) + await channel._forward_command(update, None) + + assert len(handled) == 1 + assert handled[0]["content"] == "/new"