From 704872a62f87c642a0c69fcb0f138d48896e0d59 Mon Sep 17 00:00:00 2001 From: Brandon Seaver Date: Wed, 13 May 2026 20:56:10 -0400 Subject: [PATCH] fix(telegram): handle channel post updates --- gateway/platforms/telegram.py | 55 +++++++--- tests/gateway/test_telegram_channel_posts.py | 102 +++++++++++++++++++ 2 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 tests/gateway/test_telegram_channel_posts.py diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 6747381672e..86953a5d290 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -4443,6 +4443,16 @@ class TelegramAdapter(BasePlatformAdapter): except Exception as e: logger.warning("[%s] Forum command lazy-registration failed: %s", self.name, e) + def _effective_update_message(self, update: Update) -> Optional[Message]: + """Return the message-like payload for normal messages and channel posts. + + Telegram exposes channel broadcasts as ``update.channel_post`` rather + than ``update.message``. MessageHandler filters can still dispatch + those updates, so handlers must use ``effective_message`` to avoid + consuming channel posts without ever building a gateway event. + """ + return getattr(update, "effective_message", None) or getattr(update, "message", None) + async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming text messages. @@ -4450,35 +4460,37 @@ class TelegramAdapter(BasePlatformAdapter): rapid successive text messages from the same user/chat and aggregate them into a single MessageEvent before dispatching. """ - if not update.message or not update.message.text: + msg = self._effective_update_message(update) + if not msg or not msg.text: return - if not self._should_process_message(update.message): + if not self._should_process_message(msg): return await self._ensure_forum_commands(update.message) - event = self._build_message_event(update.message, MessageType.TEXT, update_id=update.update_id) + event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id) event.text = self._clean_bot_trigger_text(event.text) self._enqueue_text_event(event) async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming command messages.""" - if not update.message or not update.message.text: + msg = self._effective_update_message(update) + if not msg or not msg.text: return - if not self._should_process_message(update.message, is_command=True): + if not self._should_process_message(msg, is_command=True): return await self._ensure_forum_commands(update.message) - event = self._build_message_event(update.message, MessageType.COMMAND, update_id=update.update_id) + event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id) await self.handle_message(event) async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming location/venue pin messages.""" - if not update.message: + msg = self._effective_update_message(update) + if not msg: return - if not self._should_process_message(update.message): + if not self._should_process_message(msg): return - msg = update.message venue = getattr(msg, "venue", None) location = getattr(venue, "location", None) if venue else getattr(msg, "location", None) @@ -5116,11 +5128,14 @@ class TelegramAdapter(BasePlatformAdapter): chat = message.chat user = message.from_user - # Determine chat type + # Determine chat type. Normalize through ``str`` so tests/mocks and + # python-telegram-bot enum values both work (``ChatType.CHANNEL`` is + # string-like, but mocks often provide plain strings). + telegram_chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower() chat_type = "dm" - if chat.type in {ChatType.GROUP, ChatType.SUPERGROUP}: + if telegram_chat_type in {"group", "supergroup"}: chat_type = "group" - elif chat.type == ChatType.CHANNEL: + elif telegram_chat_type == "channel": chat_type = "channel" # Resolve Telegram topic name and skill binding. @@ -5181,8 +5196,20 @@ class TelegramAdapter(BasePlatformAdapter): chat_id=str(chat.id), chat_name=chat.title or (chat.full_name if hasattr(chat, "full_name") else None), chat_type=chat_type, - user_id=str(user.id) if user else (str(chat.id) if chat_type == "dm" else None), - user_name=user.full_name if user else (chat.full_name if hasattr(chat, "full_name") and chat_type == "dm" else None), + user_id=( + str(user.id) + if user + else (str(chat.id) if chat_type in {"dm", "channel"} else None) + ), + user_name=( + user.full_name + if user + else ( + chat.full_name + if hasattr(chat, "full_name") and chat_type == "dm" + else (chat.title if chat_type == "channel" else None) + ) + ), thread_id=thread_id_str, chat_topic=chat_topic, message_id=str(message.message_id), diff --git a/tests/gateway/test_telegram_channel_posts.py b/tests/gateway/test_telegram_channel_posts.py new file mode 100644 index 00000000000..e6fdda2fb17 --- /dev/null +++ b/tests/gateway/test_telegram_channel_posts.py @@ -0,0 +1,102 @@ +"""Regression tests for Telegram channel_post updates. + +Telegram channel broadcasts are delivered as ``Update.channel_post`` rather than +``Update.message``. The adapter should use ``effective_message`` so channel +posts are converted into Hermes gateway events instead of being silently +ignored. +""" + +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from gateway.config import PlatformConfig +from gateway.platforms.base import MessageType + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + + telegram_mod = MagicMock() + telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + telegram_mod.constants.ChatType.GROUP = "group" + telegram_mod.constants.ChatType.SUPERGROUP = "supergroup" + telegram_mod.constants.ChatType.CHANNEL = "channel" + telegram_mod.constants.ChatType.PRIVATE = "private" + + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, telegram_mod) + + +_ensure_telegram_mock() + +from gateway.platforms.telegram import TelegramAdapter # noqa: E402 + + +def _make_adapter(): + return TelegramAdapter(PlatformConfig(enabled=True, token="***", extra={})) + + +def _make_channel_message(text="channel id test @hermes_bot"): + chat = SimpleNamespace( + id=-1003950368353, + type="channel", + title="wzrd", + full_name=None, + is_forum=False, + ) + return SimpleNamespace( + chat=chat, + from_user=None, + text=text, + caption=None, + entities=[], + caption_entities=[], + message_thread_id=None, + is_topic_message=False, + message_id=11, + reply_to_message=None, + quote=None, + date=None, + forum_topic_created=None, + ) + + +def test_build_message_event_uses_channel_identity_for_channel_posts(): + adapter = _make_adapter() + msg = _make_channel_message() + + event = adapter._build_message_event(msg, MessageType.TEXT, update_id=12345) + + assert event.source.chat_type == "channel" + assert event.source.chat_id == "-1003950368353" + # Channel posts often have no from_user. Preserve an identity so the + # gateway authorization layer can allowlist the channel by numeric ID. + assert event.source.user_id == "-1003950368353" + assert event.source.user_name == "wzrd" + assert event.platform_update_id == 12345 + + +@pytest.mark.asyncio +async def test_text_handler_uses_effective_message_for_channel_post(): + adapter = _make_adapter() + msg = _make_channel_message() + update = SimpleNamespace( + update_id=12345, + message=None, + channel_post=msg, + effective_message=msg, + ) + adapter._enqueue_text_event = MagicMock() + + await adapter._handle_text_message(update, MagicMock()) + + adapter._enqueue_text_event.assert_called_once() + event = adapter._enqueue_text_event.call_args.args[0] + assert event.text == "channel id test @hermes_bot" + assert event.source.chat_type == "channel" + assert event.source.chat_id == "-1003950368353"