fix(telegram): preserve group mentions and expose per-turn addressing

This commit is contained in:
Glucksberg
2026-09-04 22:18:13 -03:00
committed by Teknium
parent 43ddf50b04
commit bc233501aa
4 changed files with 138 additions and 5 deletions
+8 -5
View File
@@ -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) --
@@ -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
@@ -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())
@@ -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: