fix(telegram): handle channel post updates

This commit is contained in:
Brandon Seaver 2026-05-13 20:56:10 -04:00 committed by Teknium
parent 17b8121e29
commit 704872a62f
2 changed files with 143 additions and 14 deletions

View file

@ -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),

View file

@ -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"