mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-17 11:14:19 +00:00
feat: route telegram eba events to plugins
This commit is contained in:
@@ -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()
|
||||
@@ -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__
|
||||
|
||||
Reference in New Issue
Block a user