From 6c26727bb3fddb95099c3cde6cbc61c01d5de180 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Thu, 21 May 2026 09:26:46 +0300 Subject: [PATCH] fix(gateway): extend observe+attribution to location and media handlers _handle_location_message and _handle_media_message were skipped when the observe-unmentioned-group-messages feature landed (a9db0e2c7). Both handlers now: 1. Check _should_observe_unmentioned_group_message on the skipped path and call _observe_unmentioned_group_message so group chatter is stored as shared session context even when the bot is not addressed. 2. Call _apply_telegram_group_observe_attribution on the triggered path so the dispatched event uses the shared (user_id=None) group session instead of the per-user session, letting the model see previously observed context. For stickers the attribution is applied after _handle_sticker completes (which overwrites event.text with the vision description); for all other media types it is applied once after caption cleaning. Four new tests cover the observe and attribution paths for both handlers. --- gateway/platforms/telegram.py | 27 ++- tests/gateway/test_telegram_group_gating.py | 183 ++++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index a5fd88a6bad..799a836df73 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -4783,6 +4783,8 @@ class TelegramAdapter(BasePlatformAdapter): if not msg: return if not self._should_process_message(msg): + if self._should_observe_unmentioned_group_message(msg): + self._observe_unmentioned_group_message(msg, MessageType.LOCATION, update_id=update.update_id) return venue = getattr(msg, "venue", None) @@ -4812,6 +4814,7 @@ class TelegramAdapter(BasePlatformAdapter): event = self._build_message_event(msg, MessageType.LOCATION, update_id=update.update_id) event.text = "\n".join(parts) + event = self._apply_telegram_group_observe_attribution(event) await self.handle_message(event) # ------------------------------------------------------------------ @@ -4956,8 +4959,23 @@ class TelegramAdapter(BasePlatformAdapter): if not update.message: return if not self._should_process_message(update.message): + if self._should_observe_unmentioned_group_message(update.message): + _m = update.message + if _m.sticker: + _observe_type = MessageType.STICKER + elif _m.photo: + _observe_type = MessageType.PHOTO + elif _m.video: + _observe_type = MessageType.VIDEO + elif _m.audio: + _observe_type = MessageType.AUDIO + elif _m.voice: + _observe_type = MessageType.VOICE + else: + _observe_type = MessageType.DOCUMENT + self._observe_unmentioned_group_message(_m, _observe_type, update_id=update.update_id) return - + msg = update.message # Determine media type @@ -4985,9 +5003,14 @@ class TelegramAdapter(BasePlatformAdapter): # Handle stickers: describe via vision tool with caching if msg.sticker: await self._handle_sticker(msg, event) + event = self._apply_telegram_group_observe_attribution(event) await self.handle_message(event) return - + + # Apply observe attribution after caption is set; sticker is handled above + # because _handle_sticker overwrites event.text with its vision description. + event = self._apply_telegram_group_observe_attribution(event) + # Download photo to local image cache so the vision tool can access it # even after Telegram's ephemeral file URLs expire (~1 hour). if msg.photo: diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 03a663fa6a6..5ba1b48ade4 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -700,3 +700,186 @@ def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path): assert config is not None assert __import__("os").environ["TELEGRAM_IGNORED_THREADS"] == "31,42" + + +# --------------------------------------------------------------------------- +# Helpers for location / media observe+attribution tests +# --------------------------------------------------------------------------- + +def _group_location_message( + *, + chat_id=-100, + from_user_id=111, + from_user_name="Alice Example", + lat=37.7749, + lon=-122.4194, +): + return SimpleNamespace( + message_id=50, + text=None, + caption=None, + entities=[], + caption_entities=[], + message_thread_id=None, + is_topic_message=False, + chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=False), + from_user=SimpleNamespace( + id=from_user_id, full_name=from_user_name, + first_name=from_user_name.split()[0], + ), + reply_to_message=None, + date=None, + location=SimpleNamespace(latitude=lat, longitude=lon), + venue=None, + sticker=None, + photo=None, + video=None, + audio=None, + voice=None, + document=None, + ) + + +def _group_voice_message( + *, + chat_id=-100, + from_user_id=111, + from_user_name="Alice Example", + caption=None, +): + return SimpleNamespace( + message_id=51, + text=None, + caption=caption, + entities=[], + caption_entities=[], + message_thread_id=None, + is_topic_message=False, + chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=False), + from_user=SimpleNamespace( + id=from_user_id, full_name=from_user_name, + first_name=from_user_name.split()[0], + ), + reply_to_message=None, + date=None, + location=None, + venue=None, + sticker=None, + photo=None, + video=None, + audio=None, + voice=SimpleNamespace( + get_file=AsyncMock(side_effect=Exception("simulated download failure")) + ), + document=None, + ) + + +# --------------------------------------------------------------------------- +# Observe + attribution parity: location messages +# --------------------------------------------------------------------------- + +def test_unmentioned_location_message_observed_in_group(): + async def _run(): + adapter = _make_adapter( + require_mention=True, + allowed_chats=["-100"], + group_allowed_chats=["-100"], + observe_unmentioned_group_messages=True, + ) + store = _FakeSessionStore() + adapter._session_store = store + update = SimpleNamespace( + update_id=2001, + message=_group_location_message(), + effective_message=None, + ) + + await adapter._handle_location_message(update, SimpleNamespace()) + + adapter._message_handler.assert_not_awaited() + assert len(store.messages) == 1 + _, message, _ = store.messages[0] + assert message["observed"] is True + assert store.sources[0].user_id is None + + asyncio.run(_run()) + + +def test_triggered_location_message_uses_shared_session_in_observe_mode(): + async def _run(): + adapter = _make_adapter( + require_mention=False, + group_allowed_chats=["-100"], + observe_unmentioned_group_messages=True, + ) + adapter.handle_message = AsyncMock() + update = SimpleNamespace( + update_id=2002, + message=_group_location_message(), + effective_message=None, + ) + + await adapter._handle_location_message(update, SimpleNamespace()) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.call_args[0][0] + assert event.source.user_id is None + assert "[Alice Example|111]" in event.text + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# Observe + attribution parity: media messages (voice as representative) +# --------------------------------------------------------------------------- + +def test_unmentioned_voice_message_observed_in_group(): + async def _run(): + adapter = _make_adapter( + require_mention=True, + allowed_chats=["-100"], + group_allowed_chats=["-100"], + observe_unmentioned_group_messages=True, + ) + store = _FakeSessionStore() + adapter._session_store = store + update = SimpleNamespace( + update_id=3001, + message=_group_voice_message(), + effective_message=None, + ) + + await adapter._handle_media_message(update, SimpleNamespace()) + + adapter._message_handler.assert_not_awaited() + assert len(store.messages) == 1 + _, message, _ = store.messages[0] + assert message["observed"] is True + assert store.sources[0].user_id is None + + asyncio.run(_run()) + + +def test_triggered_voice_message_uses_shared_session_in_observe_mode(): + async def _run(): + adapter = _make_adapter( + require_mention=False, + group_allowed_chats=["-100"], + observe_unmentioned_group_messages=True, + ) + adapter.handle_message = AsyncMock() + update = SimpleNamespace( + update_id=3002, + message=_group_voice_message(caption="check this audio"), + effective_message=None, + ) + + await adapter._handle_media_message(update, SimpleNamespace()) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.call_args[0][0] + assert event.source.user_id is None + assert "[Alice Example|111]" in event.text + + asyncio.run(_run())