diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 73afb13b0f..555ba218cf 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -5657,9 +5657,10 @@ class TelegramAdapter(BasePlatformAdapter): return False async def _build_triggered_event(self, msg, update, msg_type: MessageType) -> MessageEvent: - """Event for an addressed text/command: trigger text cleaned, replied-to media cached, attribution applied.""" + """Keep group conversation addressing; normalize command triggers for dispatch.""" event = self._build_message_event(msg, msg_type, update_id=update.update_id) - event.text = self._clean_bot_trigger_text(event.text) + if msg_type == MessageType.COMMAND or not self._is_group_chat(msg): + event.text = self._clean_bot_trigger_text(event.text) await self._cache_replied_media(msg, event) return self._apply_telegram_group_observe_attribution(event) @@ -5978,13 +5979,13 @@ class TelegramAdapter(BasePlatformAdapter): if self._should_observe_unmentioned_group_message(msg): _event = self._build_message_event(msg, self._media_message_type(msg), update_id=update.update_id) if msg.caption: - _event.text = self._clean_bot_trigger_text(msg.caption) + _event.text = msg.caption await self._cache_observed_media(msg, _event) self._observe_unmentioned_group_message(msg, _event.message_type, update_id=update.update_id, event=_event) return event = self._build_message_event(msg, self._media_message_type(msg), update_id=update.update_id) if msg.caption: - event.text = self._clean_bot_trigger_text(msg.caption) + event.text = msg.caption if self._is_group_chat(msg) else self._clean_bot_trigger_text(msg.caption) # Stickers: _handle_sticker overwrites event.text with its vision description, so observe attribution must run after it. if msg.sticker: await self._handle_sticker(msg, event) @@ -6281,12 +6282,14 @@ class TelegramAdapter(BasePlatformAdapter): is_bot=bool(getattr(user, "is_bot", False)) if user else False) reply_to_id, reply_to_text = self._reply_context(message) from gateway.platforms.base import resolve_channel_prompt # per-channel/topic ephemeral prompt + from plugins.platforms.telegram.telegram_context import group_addressing_prompt _chat_id_str = str(chat.id) + channel_prompt = resolve_channel_prompt(self.config.extra, thread_id_str or _chat_id_str, _chat_id_str if thread_id_str else None) return MessageEvent( text=message.text or "", message_type=msg_type, source=source, raw_message=message, message_id=str(message.message_id), platform_update_id=update_id, reply_to_message_id=reply_to_id, reply_to_text=reply_to_text, auto_skill=topic_skill, - channel_prompt=resolve_channel_prompt(self.config.extra, thread_id_str or _chat_id_str, _chat_id_str if thread_id_str else None), + channel_prompt=group_addressing_prompt(self, message, channel_prompt), timestamp=message.date) # -- Message reactions (processing lifecycle) -- diff --git a/plugins/platforms/telegram/telegram_context.py b/plugins/platforms/telegram/telegram_context.py new file mode 100644 index 0000000000..a4ab9ada1b --- /dev/null +++ b/plugins/platforms/telegram/telegram_context.py @@ -0,0 +1,27 @@ +"""Per-message Telegram addressing facts, outside the cached session prompt.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from telegram import Message + from plugins.platforms.telegram.adapter import TelegramAdapter + + +def group_addressing_prompt( + adapter: "TelegramAdapter", message: "Message", channel_prompt: str | None, +) -> str | None: + if not adapter._is_group_chat(message) or not getattr(adapter, "_bot", None): + return channel_prompt + username = adapter._current_bot_username() + if not username: + return channel_prompt + # Use the same entity-aware check as admission, not the cleaned text or the + # fact that a turn was dispatched (replies/wake words/open groups also pass). + mentioned = "yes" if adapter._message_mentions_bot(message) else "no" + addressing = ( + "Telegram addressing context (current message only):\n" + f"- Your Telegram bot username: @{username}\n" + f"- Current message explicitly mentions you: {mentioned}\n" + "Mentions of other bots do not by themselves ask you to relay the message." + ) + return f"{channel_prompt}\n\n{addressing}" if channel_prompt else addressing diff --git a/tests/gateway/test_telegram_mention_context.py b/tests/gateway/test_telegram_mention_context.py new file mode 100644 index 0000000000..35c36985f0 --- /dev/null +++ b/tests/gateway/test_telegram_mention_context.py @@ -0,0 +1,101 @@ +"""Addressing information must survive routing into the agent's event.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.platforms.base import MessageType +from tests.gateway.test_telegram_group_gating import ( + _dm_message, _group_message, _group_voice_message, _make_adapter, + _mention_entities, +) + + +@pytest.mark.parametrize("media", [False, True]) +@pytest.mark.parametrize("observe", [False, True]) +def test_multi_bot_addressing_survives_real_handlers(media, observe): + async def run(): + text = "@research_bot , @ops_bot are you both listening?" + for username in ("research_bot", "ops_bot", "unrelated_bot"): + adapter = _make_adapter( + bot_username=username, require_mention=True, + exclusive_bot_mentions=True, observe_unmentioned_group_messages=observe, + allowed_chats=["-100"], group_allowed_chats=["-100"], + ) + adapter.config.extra["channel_prompts"] = {"-100": "Keep answers concise."} + events = [] + adapter._enqueue_text_event = events.append + adapter.handle_message = AsyncMock(side_effect=events.append) + adapter._ensure_forum_commands = AsyncMock() + adapter._cache_inbound_av = AsyncMock(return_value=False) + entities = _mention_entities(text, ["@research_bot", "@ops_bot"]) + if media: + msg = _group_voice_message(caption=text) + msg.caption_entities = entities + handler = adapter._handle_media_message + else: + msg = _group_message(text, entities=entities) + handler = adapter._handle_text_message + update = SimpleNamespace(update_id=1001, message=msg, effective_message=None) + + await handler(update, SimpleNamespace()) + + if username == "unrelated_bot": + assert not events + continue + assert len(events) == 1 + event = events[0] + assert text in event.text # Preserve both recipients and their positions. + assert f"Your Telegram bot username: @{username}" in event.channel_prompt + assert "Current message explicitly mentions you: yes" in event.channel_prompt + assert "Keep answers concise." in event.channel_prompt + assert ("observed Telegram group context" in event.channel_prompt) == observe + assert event.source.user_id == (None if observe else "111") + + asyncio.run(run()) + + +@pytest.mark.parametrize("trigger", ["mention", "text_mention", "reply", "wake_word", "open", "code", "command", "dm"]) +def test_addressing_context_reports_original_entities_without_inventing_mentions(trigger): + async def run(): + adapter = _make_adapter(require_mention=trigger != "open", mention_patterns=["^wake\\b"]) + adapter._ensure_forum_commands = AsyncMock() + events = [] + adapter._enqueue_text_event = events.append + adapter.handle_message = AsyncMock(side_effect=events.append) + msg_type = MessageType.TEXT + if trigger == "dm": + msg = _dm_message("hello") + elif trigger == "command": + msg_type = MessageType.COMMAND + msg = _group_message("/new@hermes_bot", entities=[SimpleNamespace(type="bot_command", offset=0, length=15)]) + elif trigger == "text_mention": + msg = _group_message("Hermes hello", entities=[SimpleNamespace(type="text_mention", offset=0, length=6, user=SimpleNamespace(id=999))]) + elif trigger == "mention": + text = "😀 @hermes_bot hello" + msg = _group_message(text, entities=[SimpleNamespace(type="mention", offset=3, length=11)]) + elif trigger == "code": + # Telegram says this is code, not a mention; a reply admits the turn. + msg = _group_message("@hermes_bot", reply_to_bot=True, entities=[SimpleNamespace(type="code", offset=0, length=11)]) + else: + msg = _group_message("wake hello" if trigger == "wake_word" else "hello", reply_to_bot=trigger == "reply") + if msg.reply_to_message: + for attr in ("photo", "video", "voice", "audio", "document"): + setattr(msg.reply_to_message, attr, None) + update = SimpleNamespace(update_id=1002, message=msg, effective_message=None) + handler = adapter._handle_command if msg_type == MessageType.COMMAND else adapter._handle_text_message + await handler(update, SimpleNamespace()) + + assert len(events) == 1 + event = events[0] + if trigger == "dm": + assert not event.channel_prompt + else: + expected = "yes" if trigger in {"mention", "text_mention", "command"} else "no" + assert f"Current message explicitly mentions you: {expected}" in event.channel_prompt + assert event.text == ("/new" if trigger == "command" else msg.text) + assert event.source.user_id == "111" + + asyncio.run(run()) diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 2becfab4ca..6d1e2a19b7 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -578,6 +578,8 @@ telegram: With this setup, a group message like `@research_bot @ops_bot summarize this` is processed by `research_bot` and `ops_bot` only. Other Hermes bots in the group stay silent, even if the message is a reply to one of their earlier messages or would otherwise match a shared wake word. +Group conversation text and media captions retain all mentions, including the receiving bot's own handle. Each turn also includes the bot's Telegram username and whether the original message explicitly mentioned it. This preserves multi-bot addressing without treating replies, wake words, or open-group messages as explicit mentions. Slash commands still use the normal command-trigger cleanup. + Set `exclusive_bot_mentions: false` only for legacy groups where explicit mentions should not override reply and wake-word triggers. To operate several profiles, run the gateway command once per profile. For example: