fix(telegram): resolve real-client event and media delivery regressions

This commit is contained in:
RockChinQ
2026-10-11 10:14:11 +08:00
parent 696cfc9e35
commit 0b5b4cca59
6 changed files with 342 additions and 28 deletions
@@ -60,6 +60,18 @@ class TestQueryToEventEnvelope:
assert event.conversation_id == 'conv-uuid-123'
@pytest.mark.parametrize('launcher_id', [123456789, -100123456789, 'launcher-123'])
def test_query_to_event_accepts_platform_launcher_ids(self, mock_query, launcher_id):
"""Telegram private and group IDs are numeric at the Query boundary."""
mock_query.launcher_id = launcher_id
mock_query.session.using_conversation.uuid = None
event = QueryEntryAdapter.query_to_event(mock_query)
context = QueryEntryAdapter._build_conversation_context(mock_query)
assert context.launcher_id == str(launcher_id)
assert event.conversation_id == f'person_{launcher_id}'
def test_query_to_event_prefers_variable_conversation_id_when_conversation_uuid_missing(self, mock_query):
"""Pipeline variables can provide the conversation identity for state scope."""
mock_query.session.using_conversation.uuid = None
@@ -402,7 +402,10 @@ async def test_telegram_converter_maps_non_message_updates():
}
}
)
reaction_event = await TelegramEventConverter.target2yiri(reaction, make_adapter().bot, 'test_bot')
adapter = make_adapter()
reaction_handler = next(h for h in adapter.application.handlers[0] if isinstance(h, MessageReactionHandler))
assert reaction_handler.check_update(reaction) is True
reaction_event = await TelegramEventConverter.target2yiri(reaction, adapter.bot, 'test_bot')
assert isinstance(reaction_event, platform_events.MessageReactionEvent)
assert reaction_event.reaction == '👍'
assert reaction_event.is_add is True
@@ -471,6 +474,7 @@ async def test_telegram_converter_maps_bot_status_events():
)
muted_event = await TelegramEventConverter.target2yiri(muted, make_adapter().bot, 'test_bot')
assert isinstance(muted_event, platform_events.BotMutedEvent)
assert muted_event.duration is None
unmuted = make_update(
{
@@ -489,6 +493,30 @@ async def test_telegram_converter_maps_bot_status_events():
unmuted_event = await TelegramEventConverter.target2yiri(unmuted, make_adapter().bot, 'test_bot')
assert isinstance(unmuted_event, platform_events.BotUnmutedEvent)
partial_unmute = make_update(
{
'my_chat_member': {
**base_member,
'old_chat_member': restricted_member,
'new_chat_member': {**restricted_member, 'can_send_messages': True},
}
}
)
partial_event = await TelegramEventConverter.target2yiri(partial_unmute, make_adapter().bot, 'test_bot')
assert isinstance(partial_event, platform_events.BotUnmutedEvent)
removed = make_update(
{
'my_chat_member': {
**base_member,
'old_chat_member': restricted_member,
'new_chat_member': {'user': restricted_member['user'], 'status': 'kicked', 'until_date': 0},
}
}
)
removed_event = await TelegramEventConverter.target2yiri(removed, make_adapter().bot, 'test_bot')
assert isinstance(removed_event, platform_events.BotRemovedFromGroupEvent)
@pytest.mark.asyncio
async def test_telegram_reply_message_sends_text_image_and_file_components():
@@ -627,3 +655,152 @@ def test_runtime_bot_maps_telegram_eba_events_to_plugin_events():
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__
@pytest.mark.asyncio
@pytest.mark.parametrize('with_text', [True, False])
async def test_stream_completion_delivers_attachments_once(with_text):
adapter = make_adapter()
bot = SimpleNamespace(
send_message=AsyncMock(return_value=SimpleNamespace(message_id=900)),
edit_message_text=AsyncMock(),
send_photo=AsyncMock(return_value=SimpleNamespace(message_id=901)),
send_document=AsyncMock(return_value=SimpleNamespace(message_id=902)),
)
object.__setattr__(adapter, 'bot', bot)
adapter.msg_stream_id['stream'] = ('message', 999, True)
source = platform_events.MessageReceivedEvent(
message_id=88, source_platform_object=make_update({'message': base_message_payload(message_id=88)})
)
parts = [platform_message.Plain(text='done')] if with_text else []
parts.extend(
[
platform_message.Image(base64='data:image/png;base64,' + base64.b64encode(b'image').decode()),
platform_message.File(
name='result.txt', base64='data:text/plain;base64,' + base64.b64encode(b'result').decode()
),
]
)
chunk = SimpleNamespace(resp_message_id='stream', msg_sequence=2, tool_calls=None)
chain = platform_message.MessageChain(parts)
await adapter.reply_message_chunk(source, chunk, chain, is_final=False)
bot.send_photo.assert_not_awaited()
bot.send_document.assert_not_awaited()
await adapter.reply_message_chunk(source, chunk, chain, quote_origin=True, is_final=True)
await adapter.reply_message_chunk(source, chunk, chain, quote_origin=True, is_final=True)
bot.send_photo.assert_awaited_once()
bot.send_document.assert_awaited_once()
assert bot.send_document.await_args.kwargs['document'].filename == 'result.txt'
assert bot.send_photo.await_args.kwargs['reply_to_message_id'] == 88
assert 'stream' not in adapter.msg_stream_id
@pytest.mark.asyncio
@pytest.mark.parametrize('reply', [False, True])
async def test_telegram_send_returns_message_result(reply):
adapter = make_adapter()
bot = SimpleNamespace(send_message=AsyncMock(return_value=SimpleNamespace(message_id=901)))
object.__setattr__(adapter, 'bot', bot)
chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
if reply:
source = platform_events.MessageReceivedEvent(
message_id=88, source_platform_object=make_update({'message': base_message_payload(message_id=88)})
)
result = await adapter.reply_message(source, chain, quote_origin=True)
assert bot.send_message.await_args.kwargs['reply_to_message_id'] == 88
else:
result = await adapter.send_message('group', '-100123#7', chain)
assert bot.send_message.await_args.kwargs['message_thread_id'] == 7
assert isinstance(result, platform_events.MessageResult)
assert result.message_id == 901
assert result.raw == {'message_ids': [901]}
@pytest.mark.parametrize('until, expected', [(0, None), (1060, 60), (900, 0)])
def test_restriction_duration_uses_event_time(until, expected):
from langbot.pkg.platform.adapters.telegram.event_converter import _restriction_duration
assert (
_restriction_duration(
datetime.datetime.fromtimestamp(until, datetime.UTC),
datetime.datetime.fromtimestamp(1000, datetime.UTC),
)
== expected
)
@pytest.mark.asyncio
@pytest.mark.parametrize('new_reaction', [[], [{'type': 'emoji', 'emoji': '❤'}]])
async def test_reaction_removal_keeps_removed_emoji(new_reaction):
update = make_update(
{
'message_reaction': {
'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'},
'message_id': 77,
'date': 1000,
'user': {'id': 3, 'is_bot': False, 'first_name': 'Reactor'},
'old_reaction': [{'type': 'emoji', 'emoji': '👍'}, *new_reaction],
'new_reaction': new_reaction,
}
}
)
event = await TelegramEventConverter.target2yiri(update, make_adapter().bot, 'test_bot')
assert event.reaction == '👍'
assert event.is_add is False
@pytest.mark.asyncio
@pytest.mark.parametrize('reply', [False, True])
async def test_rejected_photo_falls_back_without_resending_other_attachments(reply):
adapter = make_adapter()
bot = SimpleNamespace(
send_photo=AsyncMock(side_effect=telegram.error.BadRequest('Image_process_failed')),
send_document=AsyncMock(return_value=SimpleNamespace(message_id=902)),
)
object.__setattr__(adapter, 'bot', bot)
chain = platform_message.MessageChain(
[
platform_message.File(name='result.txt', base64='data:text/plain;base64,cmVzdWx0'),
platform_message.Image(base64='data:image/png;base64,aW1hZ2U='),
]
)
if reply:
source = platform_events.MessageReceivedEvent(
message_id=88, source_platform_object=make_update({'message': base_message_payload(message_id=88)})
)
result = await adapter.reply_message(source, chain, quote_origin=True)
else:
result = await adapter.send_message('private', '123', chain)
assert result.message_id == 902
assert bot.send_document.await_count == 2
assert [call.kwargs['document'].filename for call in bot.send_document.await_args_list] == ['result.txt', 'image']
assert bot.send_document.await_args.kwargs['document'].input_file_content == b'image'
if reply:
assert bot.send_document.await_args.kwargs['reply_to_message_id'] == 88
@pytest.mark.asyncio
async def test_attachment_only_completion_removes_thinking_placeholder():
adapter = make_adapter()
object.__setattr__(
adapter,
'bot',
SimpleNamespace(
send_document=AsyncMock(return_value=SimpleNamespace(message_id=902)),
delete_message=AsyncMock(),
),
)
adapter.msg_stream_id['stream'] = ('message', 999, False)
source = platform_events.MessageReceivedEvent(
message_id=88, source_platform_object=make_update({'message': base_message_payload(message_id=88)})
)
await adapter.reply_message_chunk(
source,
SimpleNamespace(resp_message_id='stream', msg_sequence=1, tool_calls=None),
platform_message.MessageChain(
[platform_message.File(name='result.txt', base64='data:text/plain;base64,cmVzdWx0')]
),
is_final=True,
)
adapter.bot.delete_message.assert_awaited_once_with(chat_id=123, message_id=999)
assert 'stream' not in adapter.msg_stream_id