From dfcf9d10e477fe8cb2cf93312b5e6ffeb55a84fb Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 7 May 2026 16:09:23 +0800 Subject: [PATCH] fix: handle telegram eba non-message updates --- .../pkg/platform/adapters/telegram/adapter.py | 119 ++++++++++------ .../adapters/telegram/event_converter.py | 122 +++++++++-------- .../platform/adapters/telegram/manifest.yaml | 2 - .../adapters/telegram/message_converter.py | 1 - .../platform/test_telegram_eba_adapter.py | 127 ++++++++++++++++++ 5 files changed, 268 insertions(+), 103 deletions(-) create mode 100644 tests/unit_tests/platform/test_telegram_eba_adapter.py diff --git a/src/langbot/pkg/platform/adapters/telegram/adapter.py b/src/langbot/pkg/platform/adapters/telegram/adapter.py index 074bbf53..42cc00f9 100644 --- a/src/langbot/pkg/platform/adapters/telegram/adapter.py +++ b/src/langbot/pkg/platform/adapters/telegram/adapter.py @@ -13,15 +13,21 @@ import traceback import telegram import telegram.ext from telegram import Update -from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters +from telegram.ext import ( + ApplicationBuilder, + CallbackQueryHandler, + ChatMemberHandler, + ContextTypes, + MessageHandler, + MessageReactionHandler, + filters, +) import telegramify_markdown import pydantic -from langbot.pkg.utils import httpclient import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events -import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter @@ -58,8 +64,14 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): - if not update.message and not update.edited_message and not update.chat_member \ - and not update.my_chat_member and not update.callback_query and not update.message_reaction: + if ( + not update.message + and not update.edited_message + and not update.chat_member + and not update.my_chat_member + and not update.callback_query + and not update.message_reaction + ): return # Skip messages from the bot itself @@ -68,23 +80,16 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo try: # Legacy event type callbacks (compat with existing botmgr FriendMessage / GroupMessage listeners) - if update.message and (platform_events.FriendMessage in self.listeners - or platform_events.GroupMessage in self.listeners): + if update.message and ( + platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners + ): legacy_event = await self.legacy_event_converter.target2yiri(update, self.bot, self.bot_account_id) if legacy_event and type(legacy_event) in self.listeners: await self.listeners[type(legacy_event)](legacy_event, self) - # EBA wildcard event callback (Event base class registered as wildcard) - if platform_events.Event in self.listeners: - eba_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id) - if eba_event: - await self.listeners[platform_events.Event](eba_event, self) - - # EBA specific event type callback - if platform_events.EBAEvent in self.listeners: - eba_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id) - if eba_event: - await self.listeners[platform_events.EBAEvent](eba_event, self) + eba_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id) + if eba_event: + await self._dispatch_eba_event(eba_event) except Exception: await self.logger.error(f'Error in telegram callback: {traceback.format_exc()}') @@ -106,6 +111,25 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo telegram_callback, ) ) + application.add_handler( + ChatMemberHandler( + telegram_callback, + ChatMemberHandler.CHAT_MEMBER, + ) + ) + application.add_handler( + ChatMemberHandler( + telegram_callback, + ChatMemberHandler.MY_CHAT_MEMBER, + ) + ) + application.add_handler(CallbackQueryHandler(telegram_callback)) + application.add_handler( + MessageReactionHandler( + telegram_callback, + MessageReactionHandler.MESSAGE_REACTION, + ) + ) super().__init__( config=config, @@ -122,35 +146,33 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo def get_supported_events(self) -> list[str]: return [ - "message.received", - "message.edited", - "message.deleted", - "message.reaction", - "group.member_joined", - "group.member_left", - "group.member_banned", - "group.info_updated", - "bot.invited_to_group", - "bot.removed_from_group", + 'message.received', + 'message.edited', + 'message.reaction', + 'group.member_joined', + 'group.member_left', + 'group.member_banned', + 'bot.invited_to_group', + 'bot.removed_from_group', ] def get_supported_apis(self) -> list[str]: return [ - "send_message", - "reply_message", - "edit_message", - "delete_message", - "forward_message", - "get_group_info", - "get_group_member_list", - "get_group_member_info", - "get_user_info", - "get_file_url", - "mute_member", - "unmute_member", - "kick_member", - "leave_group", - "call_platform_api", + 'send_message', + 'reply_message', + 'edit_message', + 'delete_message', + 'forward_message', + 'get_group_info', + 'get_group_member_list', + 'get_group_member_info', + 'get_user_info', + 'get_file_url', + 'mute_member', + 'unmute_member', + 'kick_member', + 'leave_group', + 'call_platform_api', ] # ---- Message Send / Reply (preserving original logic) ---- @@ -337,6 +359,14 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo # ---- Event Listeners ---- + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + """Dispatch once, preferring the most specific registered listener.""" + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + def register_listener( self, event_type: typing.Type[platform_events.Event], @@ -366,7 +396,8 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo handler = PLATFORM_API_MAP.get(action) if handler is None: from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError - raise NotSupportedError(f"call_platform_api:{action}") + + raise NotSupportedError(f'call_platform_api:{action}') return await handler(self.bot, params) # ---- Lifecycle ---- diff --git a/src/langbot/pkg/platform/adapters/telegram/event_converter.py b/src/langbot/pkg/platform/adapters/telegram/event_converter.py index 41ff40e6..f6659430 100644 --- a/src/langbot/pkg/platform/adapters/telegram/event_converter.py +++ b/src/langbot/pkg/platform/adapters/telegram/event_converter.py @@ -12,7 +12,6 @@ from telegram import Update import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities -import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter @@ -22,7 +21,7 @@ def _make_user(tg_user: telegram.User) -> platform_entities.User: """Convert a Telegram User to a unified User entity.""" return platform_entities.User( id=tg_user.id, - nickname=tg_user.first_name or "", + nickname=tg_user.first_name or '', username=tg_user.username, is_bot=tg_user.is_bot, ) @@ -32,7 +31,7 @@ def _make_user_group(tg_chat: telegram.Chat) -> platform_entities.UserGroup: """Convert a Telegram Chat to a unified UserGroup entity.""" return platform_entities.UserGroup( id=tg_chat.id, - name=tg_chat.title or tg_chat.first_name or "", + name=tg_chat.title or tg_chat.first_name or '', description=tg_chat.description if hasattr(tg_chat, 'description') else None, ) @@ -69,8 +68,10 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): import time # ---- Message event ---- - if update.message and update.message.text is not None or ( - update.message and (update.message.photo or update.message.voice or update.message.document) + if ( + update.message + and update.message.text is not None + or (update.message and (update.message.photo or update.message.voice or update.message.document)) ): return await TelegramEventConverter._convert_message(update, bot, bot_account_id) @@ -89,15 +90,15 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): # ---- Callback query (button clicks, etc.) ---- if update.callback_query: return platform_events.PlatformSpecificEvent( - type="platform.specific", + type='platform.specific', timestamp=time.time(), - adapter_name="telegram", - action="callback_query", + adapter_name='telegram', + action='callback_query', data={ - "callback_query_id": update.callback_query.id, - "data": update.callback_query.data, - "from_user_id": update.callback_query.from_user.id if update.callback_query.from_user else None, - "message_id": update.callback_query.message.message_id if update.callback_query.message else None, + 'callback_query_id': update.callback_query.id, + 'data': update.callback_query.data, + 'from_user_id': update.callback_query.from_user.id if update.callback_query.from_user else None, + 'message_id': update.callback_query.message.message_id if update.callback_query.message else None, }, source_platform_object=update, ) @@ -108,23 +109,25 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): # ---- Fallback: wrap as PlatformSpecificEvent ---- return platform_events.PlatformSpecificEvent( - type="platform.specific", + type='platform.specific', timestamp=time.time(), - adapter_name="telegram", - action="unknown_update", - data={"update_id": update.update_id}, + adapter_name='telegram', + action='unknown_update', + data={'update_id': update.update_id}, source_platform_object=update, ) @staticmethod async def _convert_message( - update: Update, bot: telegram.Bot, bot_account_id: str, + update: Update, + bot: telegram.Bot, + bot_account_id: str, ) -> platform_events.MessageReceivedEvent: """Convert a Telegram message to MessageReceivedEvent.""" message = update.message lb_message = await TelegramMessageConverter.target2yiri(message, bot, bot_account_id) - sender = _make_user(message.from_user) if message.from_user else platform_entities.User(id="") + sender = _make_user(message.from_user) if message.from_user else platform_entities.User(id='') chat = message.chat ct = _chat_type(chat) @@ -133,9 +136,9 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): group = _make_user_group(chat) return platform_events.MessageReceivedEvent( - type="message.received", + type='message.received', timestamp=message.date.timestamp() if message.date else 0.0, - adapter_name="telegram", + adapter_name='telegram', message_id=message.message_id, message_chain=lb_message, sender=sender, @@ -147,13 +150,15 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def _convert_edited_message( - update: Update, bot: telegram.Bot, bot_account_id: str, + update: Update, + bot: telegram.Bot, + bot_account_id: str, ) -> platform_events.MessageEditedEvent: """Convert a Telegram edited message to MessageEditedEvent.""" message = update.edited_message lb_message = await TelegramMessageConverter.target2yiri(message, bot, bot_account_id) - editor = _make_user(message.from_user) if message.from_user else platform_entities.User(id="") + editor = _make_user(message.from_user) if message.from_user else platform_entities.User(id='') chat = message.chat ct = _chat_type(chat) @@ -162,9 +167,9 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): group = _make_user_group(chat) return platform_events.MessageEditedEvent( - type="message.edited", + type='message.edited', timestamp=message.edit_date.timestamp() if message.edit_date else 0.0, - adapter_name="telegram", + adapter_name='telegram', message_id=message.message_id, new_content=lb_message, editor=editor, @@ -182,22 +187,27 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): cm = update.chat_member chat = cm.chat group = _make_user_group(chat) - member = _make_user(cm.new_chat_member.user) if cm.new_chat_member else platform_entities.User(id="") + member = _make_user(cm.new_chat_member.user) if cm.new_chat_member else platform_entities.User(id='') inviter = _make_user(cm.from_user) if cm.from_user else None old_status = cm.old_chat_member.status if cm.old_chat_member else None new_status = cm.new_chat_member.status if cm.new_chat_member else None # Member joined - if old_status in (None, 'left', 'kicked') and new_status in ('member', 'administrator', 'creator', 'restricted'): + if old_status in (None, 'left', 'kicked') and new_status in ( + 'member', + 'administrator', + 'creator', + 'restricted', + ): return platform_events.MemberJoinedEvent( - type="group.member_joined", + type='group.member_joined', timestamp=cm.date.timestamp() if cm.date else time.time(), - adapter_name="telegram", + adapter_name='telegram', group=group, member=member, inviter=inviter, - join_type="invite" if inviter and inviter.id != member.id else "direct", + join_type='invite' if inviter and inviter.id != member.id else 'direct', source_platform_object=update, ) @@ -205,9 +215,9 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): if old_status in ('member', 'administrator', 'creator', 'restricted') and new_status in ('left', 'kicked'): is_kicked = new_status == 'kicked' return platform_events.MemberLeftEvent( - type="group.member_left", + type='group.member_left', timestamp=cm.date.timestamp() if cm.date else time.time(), - adapter_name="telegram", + adapter_name='telegram', group=group, member=member, is_kicked=is_kicked, @@ -223,9 +233,9 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): if hasattr(restricted, 'until_date') and restricted.until_date: duration = int(restricted.until_date.timestamp() - time.time()) return platform_events.MemberBannedEvent( - type="group.member_banned", + type='group.member_banned', timestamp=cm.date.timestamp() if cm.date else time.time(), - adapter_name="telegram", + adapter_name='telegram', group=group, member=member, operator=inviter, @@ -235,15 +245,15 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): # Other chat_member changes -> PlatformSpecificEvent return platform_events.PlatformSpecificEvent( - type="platform.specific", + type='platform.specific', timestamp=cm.date.timestamp() if cm.date else time.time(), - adapter_name="telegram", - action="chat_member_updated", + adapter_name='telegram', + action='chat_member_updated', data={ - "old_status": old_status, - "new_status": new_status, - "chat_id": chat.id, - "user_id": member.id, + 'old_status': old_status, + 'new_status': new_status, + 'chat_id': chat.id, + 'user_id': member.id, }, source_platform_object=update, ) @@ -264,9 +274,9 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): # Bot invited to group if old_status in (None, 'left', 'kicked') and new_status in ('member', 'administrator'): return platform_events.BotInvitedToGroupEvent( - type="bot.invited_to_group", + type='bot.invited_to_group', timestamp=mcm.date.timestamp() if mcm.date else time.time(), - adapter_name="telegram", + adapter_name='telegram', group=group, inviter=inviter, source_platform_object=update, @@ -275,9 +285,9 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): # Bot removed from group if old_status in ('member', 'administrator', 'creator') and new_status in ('left', 'kicked'): return platform_events.BotRemovedFromGroupEvent( - type="bot.removed_from_group", + type='bot.removed_from_group', timestamp=mcm.date.timestamp() if mcm.date else time.time(), - adapter_name="telegram", + adapter_name='telegram', group=group, operator=inviter, source_platform_object=update, @@ -291,9 +301,9 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): if hasattr(restricted, 'until_date') and restricted.until_date: duration = int(restricted.until_date.timestamp() - time.time()) return platform_events.BotMutedEvent( - type="bot.muted", + type='bot.muted', timestamp=mcm.date.timestamp() if mcm.date else time.time(), - adapter_name="telegram", + adapter_name='telegram', group=group, operator=inviter, duration=duration, @@ -301,14 +311,14 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): ) return platform_events.PlatformSpecificEvent( - type="platform.specific", + type='platform.specific', timestamp=mcm.date.timestamp() if mcm.date else time.time(), - adapter_name="telegram", - action="my_chat_member_updated", + adapter_name='telegram', + action='my_chat_member_updated', data={ - "old_status": old_status, - "new_status": new_status, - "chat_id": chat.id, + 'old_status': old_status, + 'new_status': new_status, + 'chat_id': chat.id, }, source_platform_object=update, ) @@ -330,7 +340,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): elif hasattr(r, 'custom_emoji_id'): new_emojis.append(str(r.custom_emoji_id)) - user = platform_entities.User(id="") + user = platform_entities.User(id='') if reaction.user: user = _make_user(reaction.user) @@ -338,12 +348,12 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): group = _make_user_group(chat) if ct == platform_entities.ChatType.GROUP else None return platform_events.MessageReactionEvent( - type="message.reaction", + type='message.reaction', timestamp=reaction.date.timestamp() if reaction.date else time.time(), - adapter_name="telegram", + adapter_name='telegram', message_id=reaction.message_id, user=user, - reaction=new_emojis[0] if new_emojis else "", + reaction=new_emojis[0] if new_emojis else '', is_add=len(new_emojis) > 0, chat_type=ct, chat_id=chat.id, diff --git a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml index 772f7671..927aaf20 100644 --- a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml @@ -41,12 +41,10 @@ spec: supported_events: - message.received - message.edited - - message.deleted - message.reaction - group.member_joined - group.member_left - group.member_banned - - group.info_updated - bot.invited_to_group - bot.removed_from_group diff --git a/src/langbot/pkg/platform/adapters/telegram/message_converter.py b/src/langbot/pkg/platform/adapters/telegram/message_converter.py index 90ce7655..e55da28f 100644 --- a/src/langbot/pkg/platform/adapters/telegram/message_converter.py +++ b/src/langbot/pkg/platform/adapters/telegram/message_converter.py @@ -6,7 +6,6 @@ Migrated from the original sources/telegram.py TelegramMessageConverter. Logic u from __future__ import annotations import base64 -import typing import telegram diff --git a/tests/unit_tests/platform/test_telegram_eba_adapter.py b/tests/unit_tests/platform/test_telegram_eba_adapter.py new file mode 100644 index 00000000..f4229459 --- /dev/null +++ b/tests/unit_tests/platform/test_telegram_eba_adapter.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import pathlib + +import pytest +import yaml +from telegram.ext import CallbackQueryHandler, ChatMemberHandler, MessageHandler, MessageReactionHandler + +from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +def make_adapter() -> TelegramAdapter: + return TelegramAdapter( + { + 'token': '123456:ABCDEF_fake_token_for_object_parsing', + 'markdown_card': False, + 'enable-stream-reply': False, + }, + DummyLogger(), + ) + + +def test_telegram_adapter_registers_all_declared_update_handlers(): + adapter = make_adapter() + + handlers = adapter.application.handlers[0] + + assert sum(isinstance(handler, MessageHandler) for handler in handlers) == 2 + assert sum(isinstance(handler, ChatMemberHandler) for handler in handlers) == 2 + assert any(isinstance(handler, CallbackQueryHandler) for handler in handlers) + assert any(isinstance(handler, MessageReactionHandler) for handler in handlers) + + +@pytest.mark.asyncio +async def test_telegram_adapter_dispatches_only_most_specific_eba_listener(): + adapter = make_adapter() + calls: list[str] = [] + + async def wildcard_listener(event, adapter): + calls.append('event') + + async def eba_listener(event, adapter): + calls.append('eba') + + async def message_listener(event, adapter): + calls.append('message.received') + + adapter.register_listener(platform_events.Event, wildcard_listener) + adapter.register_listener(platform_events.EBAEvent, eba_listener) + adapter.register_listener(platform_events.MessageReceivedEvent, message_listener) + + event = platform_events.MessageReceivedEvent( + message_id=1, + message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]), + sender=platform_entities.User(id=1), + chat_id=1, + ) + + await adapter._dispatch_eba_event(event) + + assert calls == ['message.received'] + + +@pytest.mark.asyncio +async def test_telegram_adapter_dispatch_falls_back_to_eba_then_event_listener(): + adapter = make_adapter() + calls: list[str] = [] + + async def wildcard_listener(event, adapter): + calls.append('event') + + async def eba_listener(event, adapter): + calls.append('eba') + + adapter.register_listener(platform_events.Event, wildcard_listener) + adapter.register_listener(platform_events.EBAEvent, eba_listener) + + event = platform_events.MessageEditedEvent( + message_id=1, + new_content=platform_message.MessageChain([platform_message.Plain(text='edited')]), + editor=platform_entities.User(id=1), + chat_id=1, + ) + + await adapter._dispatch_eba_event(event) + assert calls == ['eba'] + + adapter.unregister_listener(platform_events.EBAEvent, eba_listener) + await adapter._dispatch_eba_event(event) + assert calls == ['eba', 'event'] + + +def test_telegram_supported_events_match_manifest(): + adapter_events = make_adapter().get_supported_events() + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'telegram' + / 'manifest.yaml' + ) + manifest_events = yaml.safe_load(manifest_path.read_text())['spec']['supported_events'] + + assert adapter_events == manifest_events + assert 'message.deleted' not in adapter_events + assert 'group.info_updated' not in adapter_events