From c41ab9e52e28c09074e1f070004406eddcb7df33 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 1 Apr 2026 09:16:51 +0300 Subject: [PATCH] feat(telegram): include author context in reply tags (#2605) (#2606) * feat(telegram): include author context in reply tags (#2605) * fix(telegram): handle missing attributes in reply_user safely --- nanobot/channels/telegram.py | 21 ++++++++++--- tests/channels/test_telegram_channel.py | 39 ++++++++++++++++--------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index cacecd735..72d60a19b 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -637,8 +637,7 @@ class TelegramChannel(BaseChannel): "reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None, } - @staticmethod - def _extract_reply_context(message) -> str | None: + async def _extract_reply_context(self, message) -> str | None: """Extract text from the message being replied to, if any.""" reply = getattr(message, "reply_to_message", None) if not reply: @@ -646,7 +645,21 @@ class TelegramChannel(BaseChannel): 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 + + if not text: + return None + + bot_id, _ = await self._ensure_bot_identity() + reply_user = getattr(reply, "from_user", None) + + if bot_id and reply_user and getattr(reply_user, "id", None) == bot_id: + return f"[Reply to bot: {text}]" + elif reply_user and getattr(reply_user, "username", None): + return f"[Reply to @{reply_user.username}: {text}]" + elif reply_user and getattr(reply_user, "first_name", None): + return f"[Reply to {reply_user.first_name}: {text}]" + else: + return f"[Reply to: {text}]" async def _download_message_media( self, msg, *, add_failure_content: bool = False @@ -838,7 +851,7 @@ class TelegramChannel(BaseChannel): # Reply context: text and/or media from the replied-to message reply = getattr(message, "reply_to_message", None) if reply is not None: - reply_ctx = self._extract_reply_context(message) + reply_ctx = await 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 diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 972f8ab6e..c793b1224 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -647,43 +647,56 @@ async def test_group_policy_open_accepts_plain_group_message() -> None: assert channel._app.bot.get_me_calls == 0 -def test_extract_reply_context_no_reply() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_no_reply() -> None: """When there is no reply_to_message, _extract_reply_context returns None.""" + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) message = SimpleNamespace(reply_to_message=None) - assert TelegramChannel._extract_reply_context(message) is None + assert await channel._extract_reply_context(message) is None -def test_extract_reply_context_with_text() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_with_text() -> None: """When reply has text, return prefixed string.""" - reply = SimpleNamespace(text="Hello world", caption=None) + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) + channel._app = _FakeApp(lambda: None) + reply = SimpleNamespace(text="Hello world", caption=None, from_user=SimpleNamespace(id=2, username="testuser", first_name="Test")) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: Hello world]" + assert await channel._extract_reply_context(message) == "[Reply to @testuser: Hello world]" -def test_extract_reply_context_with_caption_only() -> None: +@pytest.mark.asyncio +async 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") + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) + channel._app = _FakeApp(lambda: None) + reply = SimpleNamespace(text=None, caption="Photo caption", from_user=SimpleNamespace(id=2, username=None, first_name="Test")) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) == "[Reply to: Photo caption]" + assert await channel._extract_reply_context(message) == "[Reply to Test: Photo caption]" -def test_extract_reply_context_truncation() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_truncation() -> None: """Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN.""" + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) + channel._app = _FakeApp(lambda: None) long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100) - reply = SimpleNamespace(text=long_text, caption=None) + reply = SimpleNamespace(text=long_text, caption=None, from_user=SimpleNamespace(id=2, username=None, first_name=None)) message = SimpleNamespace(reply_to_message=reply) - result = TelegramChannel._extract_reply_context(message) + result = await channel._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_returns_none() -> None: +@pytest.mark.asyncio +async def test_extract_reply_context_no_text_returns_none() -> None: """When reply has no text/caption, _extract_reply_context returns None (media handled separately).""" + channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus()) reply = SimpleNamespace(text=None, caption=None) message = SimpleNamespace(reply_to_message=reply) - assert TelegramChannel._extract_reply_context(message) is None + assert await channel._extract_reply_context(message) is None @pytest.mark.asyncio