From 1eedee0c405123115ace30e400c38370a0a27846 Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 12 Mar 2026 06:23:02 +0700 Subject: [PATCH 1/4] add reply context extraction for Telegram messages --- nanobot/channels/telegram.py | 40 +++++++++++++++- tests/test_telegram_channel.py | 86 +++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 9f93843..ccb1518 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -20,6 +20,7 @@ from nanobot.config.schema import TelegramConfig from nanobot.utils.helpers import split_message TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit +TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message def _strip_md(s: str) -> str: @@ -451,6 +452,7 @@ class TelegramChannel(BaseChannel): @staticmethod def _build_message_metadata(message, user) -> dict: """Build common Telegram inbound metadata payload.""" + reply_to = getattr(message, "reply_to_message", None) return { "message_id": message.message_id, "user_id": user.id, @@ -459,8 +461,37 @@ class TelegramChannel(BaseChannel): "is_group": message.chat.type != "private", "message_thread_id": getattr(message, "message_thread_id", None), "is_forum": bool(getattr(message.chat, "is_forum", False)), + "reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None, } + @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.""" + 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 + if getattr(reply, "photo", None): + return "[Reply to: (image)]" + if getattr(reply, "document", None): + return "[Reply to: (document)]" + if getattr(reply, "voice", None): + return "[Reply to: (voice)]" + if getattr(reply, "video_note", None) or getattr(reply, "video", None): + return "[Reply to: (video)]" + if getattr(reply, "audio", None): + return "[Reply to: (audio)]" + if getattr(reply, "animation", None): + return "[Reply to: (animation)]" + return "[Reply to: (no text)]" + async def _ensure_bot_identity(self) -> tuple[int | None, str | None]: """Load bot identity once and reuse it for mention/reply checks.""" if self._bot_user_id is not None or self._bot_username is not None: @@ -542,10 +573,14 @@ 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=message.text, + content=content, metadata=self._build_message_metadata(message, user), session_key=self._derive_topic_session_key(message), ) @@ -625,6 +660,9 @@ class TelegramChannel(BaseChannel): logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") + reply_ctx = self._extract_reply_context(message) + if reply_ctx is not None: + content_parts.insert(0, reply_ctx) 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 678512d..30b9e4f 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -1,10 +1,11 @@ +import asyncio from types import SimpleNamespace import pytest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.telegram import TelegramChannel +from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel from nanobot.config.schema import TelegramConfig @@ -336,3 +337,86 @@ async def test_group_policy_open_accepts_plain_group_message() -> None: assert len(handled) == 1 assert channel._app.bot.get_me_calls == 0 + + +def test_extract_reply_context_no_reply() -> None: + """When there is no reply_to_message, _extract_reply_context returns None.""" + message = SimpleNamespace(reply_to_message=None) + assert TelegramChannel._extract_reply_context(message) is None + + +def test_extract_reply_context_with_text() -> None: + """When reply has text, return prefixed string.""" + reply = SimpleNamespace(text="Hello world", caption=None) + message = SimpleNamespace(reply_to_message=reply) + assert TelegramChannel._extract_reply_context(message) == "[Reply to: Hello world]" + + +def test_extract_reply_context_with_caption_only() -> None: + """When reply has only caption (no text), caption is used.""" + reply = SimpleNamespace(text=None, caption="Photo caption") + message = SimpleNamespace(reply_to_message=reply) + assert TelegramChannel._extract_reply_context(message) == "[Reply to: Photo caption]" + + +def test_extract_reply_context_truncation() -> None: + """Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN.""" + long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100) + reply = SimpleNamespace(text=long_text, caption=None) + message = SimpleNamespace(reply_to_message=reply) + result = TelegramChannel._extract_reply_context(message) + assert result is not None + assert result.startswith("[Reply to: ") + assert result.endswith("...]") + 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, + ) + 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) 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)]" + + +@pytest.mark.asyncio +async def test_on_message_includes_reply_context() -> None: + """When user replies to a message, content passed to bus starts with 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 + channel._start_typing = lambda _chat_id: None + + reply = SimpleNamespace(text="Hello", message_id=2, from_user=SimpleNamespace(id=1)) + update = _make_telegram_update(text="translate this", reply_to_message=reply) + await channel._on_message(update, None) + + assert len(handled) == 1 + assert handled[0]["content"].startswith("[Reply to: Hello]") + assert "translate this" in handled[0]["content"] From 3f799531cc0df2f00fe0241b4203b56dbb75fa80 Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 12 Mar 2026 06:43:59 +0700 Subject: [PATCH 2/4] Add media download functionality --- nanobot/channels/telegram.py | 133 ++++++++++++++++++-------------- tests/test_telegram_channel.py | 134 ++++++++++++++++++++++++++++++++- 2 files changed, 210 insertions(+), 57 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ccb1518..6f4422a 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -477,21 +477,75 @@ class TelegramChannel(BaseChannel): + ("..." 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 + # 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)]" + return "[Reply to: (image — not attached)]" if getattr(reply, "document", None): - return "[Reply to: (document)]" + return "[Reply to: (document — not attached)]" if getattr(reply, "voice", None): - return "[Reply to: (voice)]" + return "[Reply to: (voice — not attached)]" if getattr(reply, "video_note", None) or getattr(reply, "video", None): - return "[Reply to: (video)]" + return "[Reply to: (video — not attached)]" if getattr(reply, "audio", None): - return "[Reply to: (audio)]" + return "[Reply to: (audio — not attached)]" if getattr(reply, "animation", None): - return "[Reply to: (animation)]" + return "[Reply to: (animation — not attached)]" return "[Reply to: (no text)]" + async def _download_message_media( + self, msg, *, add_failure_content: bool = False + ) -> tuple[list[str], list[str]]: + """Download media from a message (current or reply). Returns (media_paths, content_parts).""" + media_file = None + media_type = None + if getattr(msg, "photo", None): + media_file = msg.photo[-1] + media_type = "image" + elif getattr(msg, "voice", None): + media_file = msg.voice + media_type = "voice" + elif getattr(msg, "audio", None): + media_file = msg.audio + media_type = "audio" + elif getattr(msg, "document", None): + media_file = msg.document + media_type = "file" + elif getattr(msg, "video", None): + media_file = msg.video + media_type = "video" + elif getattr(msg, "video_note", None): + media_file = msg.video_note + media_type = "video" + elif getattr(msg, "animation", None): + media_file = msg.animation + media_type = "animation" + if not media_file or not self._app: + return [], [] + try: + file = await self._app.bot.get_file(media_file.file_id) + ext = self._get_extension( + media_type, + getattr(media_file, "mime_type", None), + getattr(media_file, "file_name", None), + ) + media_dir = get_media_dir("telegram") + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" + await file.download_to_drive(str(file_path)) + path_str = str(file_path) + if media_type in ("voice", "audio"): + transcription = await self.transcribe_audio(file_path) + if transcription: + logger.info("Transcribed {}: {}...", media_type, transcription[:50]) + return [path_str], [f"[transcription: {transcription}]"] + return [path_str], [f"[{media_type}: {path_str}]"] + return [path_str], [f"[{media_type}: {path_str}]"] + except Exception as e: + logger.warning("Failed to download message media: {}", e) + if add_failure_content: + return [], [f"[{media_type}: download failed]"] + return [], [] + async def _ensure_bot_identity(self) -> tuple[int | None, str | None]: """Load bot identity once and reuse it for mention/reply checks.""" if self._bot_user_id is not None or self._bot_username is not None: @@ -612,56 +666,25 @@ class TelegramChannel(BaseChannel): if message.caption: content_parts.append(message.caption) - # Handle media files - media_file = None - media_type = None - - if message.photo: - media_file = message.photo[-1] # Largest photo - media_type = "image" - elif message.voice: - media_file = message.voice - media_type = "voice" - elif message.audio: - media_file = message.audio - media_type = "audio" - elif message.document: - media_file = message.document - media_type = "file" - - # Download media if present - if media_file and self._app: - try: - file = await self._app.bot.get_file(media_file.file_id) - ext = self._get_extension( - media_type, - getattr(media_file, 'mime_type', None), - getattr(media_file, 'file_name', None), - ) - media_dir = get_media_dir("telegram") - - file_path = media_dir / f"{media_file.file_id[:16]}{ext}" - await file.download_to_drive(str(file_path)) - - media_paths.append(str(file_path)) - - if media_type in ("voice", "audio"): - transcription = await self.transcribe_audio(file_path) - if transcription: - logger.info("Transcribed {}: {}...", media_type, transcription[:50]) - content_parts.append(f"[transcription: {transcription}]") - else: - content_parts.append(f"[{media_type}: {file_path}]") - else: - content_parts.append(f"[{media_type}: {file_path}]") - - logger.debug("Downloaded {} to {}", media_type, file_path) - except Exception as e: - logger.error("Failed to download media: {}", e) - content_parts.append(f"[{media_type}: download failed]") + # Download current message media + current_media_paths, current_media_parts = await self._download_message_media( + message, add_failure_content=True + ) + media_paths.extend(current_media_paths) + content_parts.extend(current_media_parts) + 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 = getattr(message, "reply_to_message", None) reply_ctx = self._extract_reply_context(message) - if reply_ctx is not None: + 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) content = "\n".join(content_parts) if content_parts else "[empty message]" diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 30b9e4f..75824ac 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -1,5 +1,7 @@ import asyncio +from pathlib import Path from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -43,6 +45,12 @@ class _FakeBot: async def send_chat_action(self, **kwargs) -> None: pass + async def get_file(self, file_id: str): + """Return a fake file that 'downloads' to a path (for reply-to-media tests).""" + async def _fake_download(path) -> None: + pass + return SimpleNamespace(download_to_drive=_fake_download) + class _FakeApp: def __init__(self, on_start_polling) -> None: @@ -389,14 +397,14 @@ def test_extract_reply_context_no_text_no_media() -> None: def test_extract_reply_context_reply_to_photo() -> None: - """When reply has photo but no text/caption, return (image) placeholder.""" + """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)]" + assert TelegramChannel._extract_reply_context(message) == "[Reply to: (image — not attached)]" @pytest.mark.asyncio @@ -420,3 +428,125 @@ async def test_on_message_includes_reply_context() -> None: assert len(handled) == 1 assert handled[0]["content"].startswith("[Reply to: Hello]") assert "translate this" in handled[0]["content"] + + +@pytest.mark.asyncio +async def test_download_message_media_returns_path_when_download_succeeds( + monkeypatch, tmp_path +) -> None: + """_download_message_media returns (paths, content_parts) when bot.get_file and download succeed.""" + 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=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + channel._app.bot.get_file = AsyncMock( + return_value=SimpleNamespace(download_to_drive=AsyncMock(return_value=None)) + ) + + msg = SimpleNamespace( + photo=[SimpleNamespace(file_id="fid123", mime_type="image/jpeg")], + voice=None, + audio=None, + document=None, + video=None, + video_note=None, + animation=None, + ) + paths, parts = await channel._download_message_media(msg) + assert len(paths) == 1 + assert len(parts) == 1 + assert "fid123" in paths[0] + assert "[image:" in parts[0] + + +@pytest.mark.asyncio +async def test_on_message_attaches_reply_to_media_when_available(monkeypatch, tmp_path) -> None: + """When user replies to a message with media, that media is downloaded and attached to the turn.""" + 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_photo = SimpleNamespace( + text=None, + caption=None, + photo=[SimpleNamespace(file_id="reply_photo_fid", mime_type="image/jpeg")], + document=None, + voice=None, + audio=None, + video=None, + video_note=None, + animation=None, + ) + update = _make_telegram_update( + text="what is the image?", + reply_to_message=reply_with_photo, + ) + await channel._on_message(update, None) + + assert len(handled) == 1 + assert handled[0]["content"].startswith("[Reply to: [image:") + assert "what is the image?" in handled[0]["content"] + assert len(handled[0]["media"]) == 1 + assert "reply_photo_fid" in handled[0]["media"][0] + + +@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.""" + 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: + handled.append(kwargs) + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + reply_with_photo = SimpleNamespace( + text=None, + caption=None, + photo=[SimpleNamespace(file_id="x", mime_type="image/jpeg")], + document=None, + voice=None, + audio=None, + video=None, + video_note=None, + animation=None, + ) + update = _make_telegram_update(text="what is this?", reply_to_message=reply_with_photo) + 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"] == [] From 6141b950377de035ce5b7ced244ae2047624c198 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 06:00:39 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20feishu=20bot=20mention=20detection?= =?UTF-8?q?=20=E2=80=94=20user=5Fid=20can=20be=20None,=20not=20just=20empt?= =?UTF-8?q?y=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nanobot/channels/feishu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 780227a..2eb6a6a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -362,8 +362,8 @@ class FeishuChannel(BaseChannel): mid = getattr(mention, "id", None) if not mid: continue - # Bot mentions have an empty user_id with a valid open_id - if getattr(mid, "user_id", None) == "" and (getattr(mid, "open_id", None) or "").startswith("ou_"): + # Bot mentions have no user_id (None or "") but a valid open_id + if not getattr(mid, "user_id", None) and (getattr(mid, "open_id", None) or "").startswith("ou_"): return True return False From 64888b4b09175bc41497d343802d352f522be3af Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 06:16:57 +0000 Subject: [PATCH 4/4] 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"