fix(telegram): preserve group mentions and expose per-turn addressing
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user