From 5c182c0f299d70db556cf558aee26e1f75a0638c Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 7 May 2026 17:02:49 +0800 Subject: [PATCH] feat: route telegram eba events to plugins --- docs/event-based-agents/01-event-system.md | 7 +- .../03-adapter-structure.md | 3 + docs/event-based-agents/04-event-routing.md | 3 + .../pkg/platform/adapters/telegram/adapter.py | 43 ++- .../adapters/telegram/event_converter.py | 10 + .../platform/adapters/telegram/manifest.yaml | 3 + src/langbot/pkg/platform/botmgr.py | 41 +++ tests/e2e/live_telegram_eba_probe.py | 164 +++++++++ .../platform/test_telegram_eba_adapter.py | 313 ++++++++++++++++++ 9 files changed, 573 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/live_telegram_eba_probe.py diff --git a/docs/event-based-agents/01-event-system.md b/docs/event-based-agents/01-event-system.md index 86cb5a8c..feb73c48 100644 --- a/docs/event-based-agents/01-event-system.md +++ b/docs/event-based-agents/01-event-system.md @@ -469,7 +469,9 @@ class PlatformSpecificEvent(Event): | `friend.added` | N | Y | Y | N | N | N | Y | Y | N | | `bot.invited_to_group` | Y | Y | Y | Y | Y | Y | Y | N | Y | | `bot.removed_from_group` | Y | Y | Y | Y | N | N | Y | N | Y | -| `bot.muted` | N | N | Y | N | N | N | N | N | N | +| `bot.muted` | Y | N | Y | N | N | N | N | N | N | +| `bot.unmuted` | Y | N | Y | N | N | N | N | N | N | +| `platform.specific` | Y | Y | Y | Y | Y | Y | Y | Y | Y | > 注:此表为初步评估,具体以各平台 SDK/API 文档为准,实施时逐个确认。 @@ -545,6 +547,9 @@ spec: - group.info_updated - bot.invited_to_group - bot.removed_from_group + - bot.muted + - bot.unmuted + - platform.specific platform_specific_events: - chat_member_updated - chat_join_request diff --git a/docs/event-based-agents/03-adapter-structure.md b/docs/event-based-agents/03-adapter-structure.md index ec77cc56..4943b8ad 100644 --- a/docs/event-based-agents/03-adapter-structure.md +++ b/docs/event-based-agents/03-adapter-structure.md @@ -323,6 +323,9 @@ spec: - group.info_updated - bot.invited_to_group - bot.removed_from_group + - bot.muted + - bot.unmuted + - platform.specific supported_apis: required: diff --git a/docs/event-based-agents/04-event-routing.md b/docs/event-based-agents/04-event-routing.md index 40dbac05..e0348898 100644 --- a/docs/event-based-agents/04-event-routing.md +++ b/docs/event-based-agents/04-event-routing.md @@ -721,6 +721,9 @@ interface BotConfig { - friend.added - bot.invited_to_group - bot.removed_from_group +- bot.muted +- bot.unmuted +- platform.specific ───────────────── - message.* (所有消息事件) - feedback.* (所有反馈事件) diff --git a/src/langbot/pkg/platform/adapters/telegram/adapter.py b/src/langbot/pkg/platform/adapters/telegram/adapter.py index 42cc00f9..fc92ab96 100644 --- a/src/langbot/pkg/platform/adapters/telegram/adapter.py +++ b/src/langbot/pkg/platform/adapters/telegram/adapter.py @@ -154,6 +154,9 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo 'group.member_banned', 'bot.invited_to_group', 'bot.removed_from_group', + 'bot.muted', + 'bot.unmuted', + 'platform.specific', ] def get_supported_apis(self) -> list[str]: @@ -221,27 +224,41 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo components = await TelegramMessageConverter.yiri2target(message, self.bot) for component in components: - if component['type'] == 'text': + component_type = component.get('type') + args = { + 'chat_id': message_source.source_platform_object.effective_chat.id, + } + + if message_source.source_platform_object.message.message_thread_id: + args['message_thread_id'] = message_source.source_platform_object.message.message_thread_id + + if quote_origin: + args['reply_to_message_id'] = message_source.source_platform_object.message.id + + if component_type == 'text': if self.config['markdown_card'] is True: content = telegramify_markdown.markdownify( content=component['text'], ) else: content = component['text'] - args = { - 'chat_id': message_source.source_platform_object.effective_chat.id, - 'text': content, - } if self.config['markdown_card'] is True: args['parse_mode'] = 'MarkdownV2' - - if message_source.source_platform_object.message.message_thread_id: - args['message_thread_id'] = message_source.source_platform_object.message.message_thread_id - - if quote_origin: - args['reply_to_message_id'] = message_source.source_platform_object.message.id - - await self.bot.send_message(**args) + args['text'] = content + await self.bot.send_message(**args) + elif component_type == 'photo': + photo = component.get('photo') + if photo is None: + continue + args['photo'] = telegram.InputFile(photo) + await self.bot.send_photo(**args) + elif component_type == 'document': + doc = component.get('document') + if doc is None: + continue + filename = component.get('filename', 'file') + args['document'] = telegram.InputFile(doc, filename=filename) + await self.bot.send_document(**args) # ---- Streaming Output (preserving original logic) ---- diff --git a/src/langbot/pkg/platform/adapters/telegram/event_converter.py b/src/langbot/pkg/platform/adapters/telegram/event_converter.py index f6659430..10dabe39 100644 --- a/src/langbot/pkg/platform/adapters/telegram/event_converter.py +++ b/src/langbot/pkg/platform/adapters/telegram/event_converter.py @@ -310,6 +310,16 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): source_platform_object=update, ) + if old_status == 'restricted' and new_status in ('member', 'administrator') and mcm.new_chat_member: + return platform_events.BotUnmutedEvent( + type='bot.unmuted', + timestamp=mcm.date.timestamp() if mcm.date else time.time(), + adapter_name='telegram', + group=group, + operator=inviter, + source_platform_object=update, + ) + return platform_events.PlatformSpecificEvent( type='platform.specific', timestamp=mcm.date.timestamp() if mcm.date else time.time(), diff --git a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml index 927aaf20..b1950fab 100644 --- a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml @@ -47,6 +47,9 @@ spec: - group.member_banned - bot.invited_to_group - bot.removed_from_group + - bot.muted + - bot.unmuted + - platform.specific supported_apis: required: diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 8e99618c..6d90741a 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -18,6 +18,7 @@ from ..entity.errors import platform as platform_errors from .logger import EventLogger import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.entities.events as plugin_events import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter @@ -77,6 +78,31 @@ class RuntimeBot: PIPELINE_DISCARD = '__discard__' PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded' + @staticmethod + def _eba_event_to_plugin_event(event: platform_events.EBAEvent) -> plugin_events.BaseEventModel | None: + """Map a platform EBA event to a plugin EventListener event model.""" + event_mapping: list[tuple[type[platform_events.EBAEvent], type[plugin_events.BaseEventModel]]] = [ + (platform_events.MessageReceivedEvent, plugin_events.MessageReceived), + (platform_events.MessageEditedEvent, plugin_events.MessageEdited), + (platform_events.MessageDeletedEvent, plugin_events.MessageDeleted), + (platform_events.MessageReactionEvent, plugin_events.MessageReactionReceived), + (platform_events.FeedbackReceivedEvent, plugin_events.FeedbackReceived), + (platform_events.MemberJoinedEvent, plugin_events.GroupMemberJoined), + (platform_events.MemberLeftEvent, plugin_events.GroupMemberLeft), + (platform_events.MemberBannedEvent, plugin_events.GroupMemberBanned), + (platform_events.BotInvitedToGroupEvent, plugin_events.BotInvitedToGroup), + (platform_events.BotRemovedFromGroupEvent, plugin_events.BotRemovedFromGroup), + (platform_events.BotMutedEvent, plugin_events.BotMuted), + (platform_events.BotUnmutedEvent, plugin_events.BotUnmuted), + (platform_events.PlatformSpecificEvent, plugin_events.PlatformSpecificEventReceived), + ] + + for platform_event_type, plugin_event_type in event_mapping: + if isinstance(event, platform_event_type): + return plugin_event_type.from_platform_event(event) + + return None + def resolve_pipeline_uuid( self, launcher_type: str, @@ -366,6 +392,21 @@ class RuntimeBot: self.adapter.register_listener(platform_events.FeedbackEvent, on_feedback) + async def on_eba_event( + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ): + plugin_event = self._eba_event_to_plugin_event(event) + if plugin_event is None: + return + + try: + await self.ap.plugin_connector.emit_event(plugin_event) + except Exception: + await self.logger.error(f'Failed to dispatch EBA event to plugins: {traceback.format_exc()}') + + self.adapter.register_listener(platform_events.EBAEvent, on_eba_event) + async def run(self): async def exception_wrapper(): try: diff --git a/tests/e2e/live_telegram_eba_probe.py b/tests/e2e/live_telegram_eba_probe.py new file mode 100644 index 00000000..760d1622 --- /dev/null +++ b/tests/e2e/live_telegram_eba_probe.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import os +from pathlib import Path + +import telegram + +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 events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warning] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +PNG_1X1 = base64.b64decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' +) + + +def summarize_event(event: platform_events.EBAEvent) -> dict: + data = { + 'type': event.type, + 'adapter_name': event.adapter_name, + 'timestamp': event.timestamp, + } + for field in ( + 'message_id', + 'chat_id', + 'chat_type', + 'reaction', + 'is_add', + 'action', + 'data', + ): + if hasattr(event, field): + value = getattr(event, field) + if hasattr(value, 'value'): + value = value.value + data[field] = value + return data + + +async def run_probe(token: str, log_path: Path, timeout: int): + adapter = TelegramAdapter( + { + 'token': token, + 'markdown_card': False, + 'enable-stream-reply': False, + }, + ProbeLogger(), + ) + events: list[platform_events.EBAEvent] = [] + first_message = asyncio.Event() + + async def listener(event, adapter): + events.append(event) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open('a', encoding='utf-8') as fp: + fp.write(json.dumps(summarize_event(event), ensure_ascii=False) + '\n') + print('TELEGRAM_EBA_EVENT', json.dumps(summarize_event(event), ensure_ascii=False)) + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + await adapter.run_async() + + try: + print('READY: send a private or group message to the Telegram test bot now.') + await asyncio.wait_for(first_message.wait(), timeout=timeout) + source = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent)) + + await adapter.reply_message( + source, + platform_message.MessageChain( + [ + platform_message.Plain(text='EBA live reply: text'), + platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()), + platform_message.File( + name='eba-live.txt', + size=8, + base64='data:text/plain;base64,' + base64.b64encode(b'eba-live').decode(), + ), + ] + ), + quote_origin=True, + ) + await adapter.send_message( + source.chat_type.value if hasattr(source.chat_type, 'value') else str(source.chat_type), + source.chat_id, + platform_message.MessageChain([platform_message.Plain(text='EBA live send_message OK')]), + ) + + edit_probe = await adapter.bot.send_message(chat_id=source.chat_id, text='EBA edit/delete probe') + await adapter.edit_message( + str(source.chat_type), + source.chat_id, + edit_probe.message_id, + platform_message.MessageChain([platform_message.Plain(text='EBA edit probe edited')]), + ) + await adapter.delete_message(str(source.chat_type), source.chat_id, edit_probe.message_id) + + await adapter.bot.send_message( + chat_id=source.chat_id, + text='EBA callback probe', + reply_markup=telegram.InlineKeyboardMarkup( + [[telegram.InlineKeyboardButton('callback probe', callback_data='eba-callback-probe')]] + ), + ) + + if str(source.chat_type) == 'ChatType.GROUP' or getattr(source.chat_type, 'value', '') == 'group': + group_info = await adapter.get_group_info(source.chat_id) + print('GROUP_INFO', group_info.model_dump()) + members = await adapter.get_group_member_list(source.chat_id) + print('GROUP_MEMBER_LIST_COUNT', len(members)) + await adapter.call_platform_api('send_chat_action', {'chat_id': source.chat_id, 'action': 'typing'}) + count = await adapter.call_platform_api('get_chat_member_count', {'chat_id': source.chat_id}) + print('GROUP_MEMBER_COUNT', count) + + print('READY: click the callback button or react to a bot message if you want live callback/reaction events.') + await asyncio.sleep(max(5, timeout // 3)) + finally: + await adapter.kill() + summary = { + 'events': [summarize_event(event) for event in events], + 'event_types': [event.type for event in events], + } + print('SUMMARY', json.dumps(summary, ensure_ascii=False)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--token', default=os.getenv('TELEGRAM_BOT_TOKEN', '')) + parser.add_argument('--log', default='data/temp/live_telegram_eba_probe.jsonl') + parser.add_argument('--timeout', type=int, default=90) + args = parser.parse_args() + + if not args.token: + raise SystemExit('Set TELEGRAM_BOT_TOKEN or pass --token') + + log_path = Path(args.log) + if log_path.exists(): + log_path.unlink() + asyncio.run(run_probe(args.token, log_path, args.timeout)) + + +if __name__ == '__main__': + main() diff --git a/tests/unit_tests/platform/test_telegram_eba_adapter.py b/tests/unit_tests/platform/test_telegram_eba_adapter.py index f4229459..cf624254 100644 --- a/tests/unit_tests/platform/test_telegram_eba_adapter.py +++ b/tests/unit_tests/platform/test_telegram_eba_adapter.py @@ -1,16 +1,25 @@ from __future__ import annotations +import base64 +import datetime import pathlib +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest import yaml +import telegram from telegram.ext import CallbackQueryHandler, ChatMemberHandler, MessageHandler, MessageReactionHandler +from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter +from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter +from langbot.pkg.platform.botmgr import RuntimeBot 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 +from langbot_plugin.api.entities import events as plugin_events class DummyLogger(AbstractEventLogger): @@ -38,6 +47,23 @@ def make_adapter() -> TelegramAdapter: ) +def make_update(data: dict) -> telegram.Update: + payload = {'update_id': 1000, **data} + return telegram.Update.de_json(payload, make_adapter().bot) + + +def base_message_payload(**overrides): + payload = { + 'message_id': 10, + 'date': int(datetime.datetime.now(datetime.UTC).timestamp()), + 'chat': {'id': 123, 'type': 'private', 'first_name': 'Chat User'}, + 'from': {'id': 456, 'is_bot': False, 'first_name': 'Sender', 'username': 'sender'}, + 'text': 'hello', + } + payload.update(overrides) + return payload + + def test_telegram_adapter_registers_all_declared_update_handlers(): adapter = make_adapter() @@ -125,3 +151,290 @@ def test_telegram_supported_events_match_manifest(): assert adapter_events == manifest_events assert 'message.deleted' not in adapter_events assert 'group.info_updated' not in adapter_events + + +@pytest.mark.asyncio +async def test_telegram_converter_maps_message_and_edited_message_events(): + update = make_update({'message': base_message_payload(text='hello @test_bot')}) + event = await TelegramEventConverter.target2yiri(update, make_adapter().bot, 'test_bot') + + assert isinstance(event, platform_events.MessageReceivedEvent) + assert event.type == 'message.received' + assert event.chat_type == platform_entities.ChatType.PRIVATE + assert event.chat_id == 123 + assert event.sender.id == 456 + assert platform_message.At in event.message_chain + assert isinstance(event.message_chain[0], platform_message.At) + assert isinstance(event.message_chain[1], platform_message.Plain) + assert event.message_chain[1].text == 'hello ' + + group_chat = {'id': -100123, 'type': 'supergroup', 'title': 'Test Group'} + edited_payload = base_message_payload(chat=group_chat, text='edited') + edited_payload['edit_date'] = edited_payload['date'] + 1 + edited = make_update({'edited_message': edited_payload}) + edited_event = await TelegramEventConverter.target2yiri(edited, make_adapter().bot, 'test_bot') + + assert isinstance(edited_event, platform_events.MessageEditedEvent) + assert edited_event.type == 'message.edited' + assert edited_event.chat_type == platform_entities.ChatType.GROUP + assert edited_event.group.name == 'Test Group' + assert str(edited_event.new_content) == 'edited' + + +@pytest.mark.asyncio +async def test_telegram_converter_maps_non_message_updates(): + chat_member = make_update( + { + 'chat_member': { + 'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'}, + 'from': {'id': 1, 'is_bot': False, 'first_name': 'Admin'}, + 'date': int(datetime.datetime.now(datetime.UTC).timestamp()), + 'old_chat_member': { + 'user': {'id': 2, 'is_bot': False, 'first_name': 'Member'}, + 'status': 'left', + }, + 'new_chat_member': { + 'user': {'id': 2, 'is_bot': False, 'first_name': 'Member'}, + 'status': 'member', + }, + } + } + ) + joined = await TelegramEventConverter.target2yiri(chat_member, make_adapter().bot, 'test_bot') + assert isinstance(joined, platform_events.MemberJoinedEvent) + assert joined.type == 'group.member_joined' + + callback = make_update( + { + 'callback_query': { + 'id': 'cb-1', + 'from': {'id': 3, 'is_bot': False, 'first_name': 'Clicker'}, + 'chat_instance': 'ci', + 'data': 'button-data', + 'message': base_message_payload(message_id=77), + } + } + ) + callback_event = await TelegramEventConverter.target2yiri(callback, make_adapter().bot, 'test_bot') + assert isinstance(callback_event, platform_events.PlatformSpecificEvent) + assert callback_event.action == 'callback_query' + assert callback_event.data['callback_query_id'] == 'cb-1' + assert callback_event.data['data'] == 'button-data' + + reaction = make_update( + { + 'message_reaction': { + 'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'}, + 'message_id': 77, + 'date': int(datetime.datetime.now(datetime.UTC).timestamp()), + 'user': {'id': 3, 'is_bot': False, 'first_name': 'Reactor'}, + 'old_reaction': [], + 'new_reaction': [{'type': 'emoji', 'emoji': '👍'}], + } + } + ) + reaction_event = await TelegramEventConverter.target2yiri(reaction, make_adapter().bot, 'test_bot') + assert isinstance(reaction_event, platform_events.MessageReactionEvent) + assert reaction_event.reaction == '👍' + assert reaction_event.is_add is True + + +@pytest.mark.asyncio +async def test_telegram_converter_maps_bot_status_events(): + base_member = { + 'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'}, + 'from': {'id': 1, 'is_bot': False, 'first_name': 'Admin'}, + 'date': int(datetime.datetime.now(datetime.UTC).timestamp()), + } + restricted_member = { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'restricted', + 'is_member': True, + 'can_send_messages': False, + 'can_send_audios': False, + 'can_send_documents': False, + 'can_send_photos': False, + 'can_send_videos': False, + 'can_send_video_notes': False, + 'can_send_voice_notes': False, + 'can_send_polls': False, + 'can_send_other_messages': False, + 'can_add_web_page_previews': False, + 'can_change_info': False, + 'can_invite_users': False, + 'can_pin_messages': False, + 'can_manage_topics': False, + 'until_date': 0, + } + invited = make_update( + { + 'my_chat_member': { + **base_member, + 'old_chat_member': { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'left', + }, + 'new_chat_member': { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'member', + }, + } + } + ) + invited_event = await TelegramEventConverter.target2yiri(invited, make_adapter().bot, 'test_bot') + assert isinstance(invited_event, platform_events.BotInvitedToGroupEvent) + + muted = make_update( + { + 'my_chat_member': { + **base_member, + 'old_chat_member': { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'member', + }, + 'new_chat_member': { + **restricted_member, + }, + } + } + ) + muted_event = await TelegramEventConverter.target2yiri(muted, make_adapter().bot, 'test_bot') + assert isinstance(muted_event, platform_events.BotMutedEvent) + + unmuted = make_update( + { + 'my_chat_member': { + **base_member, + 'old_chat_member': { + **restricted_member, + }, + 'new_chat_member': { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'member', + }, + } + } + ) + unmuted_event = await TelegramEventConverter.target2yiri(unmuted, make_adapter().bot, 'test_bot') + assert isinstance(unmuted_event, platform_events.BotUnmutedEvent) + + +@pytest.mark.asyncio +async def test_telegram_reply_message_sends_text_image_and_file_components(): + adapter = make_adapter() + bot = SimpleNamespace( + send_message=AsyncMock(), + send_photo=AsyncMock(), + send_document=AsyncMock(), + ) + object.__setattr__(adapter, 'bot', bot) + update = make_update({'message': base_message_payload(message_id=88)}) + + message_source = platform_events.MessageReceivedEvent( + message_id=88, + source_platform_object=update, + ) + await adapter.reply_message( + message_source, + platform_message.MessageChain( + [ + platform_message.Plain(text='reply text'), + platform_message.Image(base64=base64.b64encode(b'image-bytes').decode('utf-8')), + platform_message.File( + name='test.txt', + size=4, + base64='data:text/plain;base64,' + base64.b64encode(b'test').decode('utf-8'), + ), + ] + ), + quote_origin=True, + ) + + bot.send_message.assert_awaited_once() + bot.send_photo.assert_awaited_once() + bot.send_document.assert_awaited_once() + assert bot.send_message.await_args.kwargs['reply_to_message_id'] == 88 + assert bot.send_photo.await_args.kwargs['reply_to_message_id'] == 88 + assert bot.send_document.await_args.kwargs['document'].filename == 'test.txt' + + +@pytest.mark.asyncio +async def test_telegram_platform_apis_call_underlying_bot_methods(): + bot = SimpleNamespace( + pin_chat_message=AsyncMock(), + unpin_chat_message=AsyncMock(), + unpin_all_chat_messages=AsyncMock(), + get_chat_administrators=AsyncMock( + return_value=[ + SimpleNamespace( + user=SimpleNamespace(id=1, username='admin', first_name='Admin'), + status='administrator', + custom_title='Boss', + ) + ] + ), + set_chat_title=AsyncMock(), + set_chat_description=AsyncMock(), + get_chat_member_count=AsyncMock(return_value=3), + send_chat_action=AsyncMock(), + create_chat_invite_link=AsyncMock( + return_value=SimpleNamespace( + invite_link='https://t.me/+abc', + name='invite', + is_primary=False, + is_revoked=False, + ) + ), + answer_callback_query=AsyncMock(), + ) + + assert await PLATFORM_API_MAP['pin_message'](bot, {'chat_id': 1, 'message_id': 2}) == {'ok': True} + assert await PLATFORM_API_MAP['unpin_message'](bot, {'chat_id': 1, 'message_id': 2}) == {'ok': True} + assert await PLATFORM_API_MAP['unpin_all_messages'](bot, {'chat_id': 1}) == {'ok': True} + admins = await PLATFORM_API_MAP['get_chat_administrators'](bot, {'chat_id': 1}) + assert admins['administrators'][0]['user_id'] == 1 + assert await PLATFORM_API_MAP['set_chat_title'](bot, {'chat_id': 1, 'title': 'New'}) == {'ok': True} + assert await PLATFORM_API_MAP['set_chat_description'](bot, {'chat_id': 1, 'description': 'Desc'}) == {'ok': True} + assert await PLATFORM_API_MAP['get_chat_member_count'](bot, {'chat_id': 1}) == {'count': 3} + assert await PLATFORM_API_MAP['send_chat_action'](bot, {'chat_id': 1, 'action': 'typing'}) == {'ok': True} + invite = await PLATFORM_API_MAP['create_chat_invite_link'](bot, {'chat_id': 1, 'name': 'invite'}) + assert invite['invite_link'] == 'https://t.me/+abc' + assert await PLATFORM_API_MAP['answer_callback_query'](bot, {'callback_query_id': 'cb'}) == {'ok': True} + + +def test_runtime_bot_maps_telegram_eba_events_to_plugin_events(): + group = platform_entities.UserGroup(id='group-1', name='Group') + user = platform_entities.User(id='user-1', nickname='User') + + cases = [ + ( + platform_events.MessageReceivedEvent( + message_id='msg', + message_chain=platform_message.MessageChain([platform_message.Plain(text='hi')]), + sender=user, + chat_id='user-1', + ), + plugin_events.MessageReceived, + ), + ( + platform_events.MessageReactionEvent(message_id='msg', user=user, reaction='👍'), + plugin_events.MessageReactionReceived, + ), + ( + platform_events.MemberJoinedEvent(group=group, member=user), + plugin_events.GroupMemberJoined, + ), + ( + platform_events.BotUnmutedEvent(group=group, operator=user), + plugin_events.BotUnmuted, + ), + ( + platform_events.PlatformSpecificEvent(adapter_name='telegram', action='callback_query', data={'data': 'x'}), + plugin_events.PlatformSpecificEventReceived, + ), + ] + + for platform_event, plugin_event_type in cases: + plugin_event = RuntimeBot._eba_event_to_plugin_event(platform_event) + assert isinstance(plugin_event, plugin_event_type) + assert plugin_event.model_dump()['event_name'] == plugin_event_type.__name__