mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-13 15:46:38 +08:00
fix(telegram): resolve real-client event and media delivery regressions
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# Telegram 4.11 adapter acceptance
|
||||
|
||||
Validated on 2026-10-11 using the Telegram Web client and LangBot WebUI through computer use. Bot registration, bot configuration, processor bindings, group membership changes, and test messages were performed through the UI. No direct Bot API calls or database mutations were used to simulate acceptance traffic.
|
||||
|
||||
## Environment
|
||||
|
||||
- Core 4.11.0rc2, SDK 0.7.11rc2 on their main branches.
|
||||
- OSS development mode, standalone Plugin Runtime and standalone Docker-backed Box Runtime.
|
||||
- Dedicated new Telegram bot and dedicated test group; no unrelated conversations used.
|
||||
- Pipeline and Agent: LocalAgent with gemini-3-flash-preview. Plugin processors: RunnerDemo community and observer.
|
||||
|
||||
## Real message coverage
|
||||
|
||||
| Path | Verification |
|
||||
| --- | --- |
|
||||
| Pipeline private text | TG-P01 received an actual reply. |
|
||||
| Pipeline group text and streaming | TG-G04 and TG-STREAM05; observed partial output growing from 12 to 22 to 60 lines, ending with STREAM-END. |
|
||||
| Pipeline private streaming | TG-PRIVATE-STREAM10; observed 8 partial lines progressing to 80 complete lines and STREAM-DONE. |
|
||||
| Pipeline incoming image | TG-IMG01 correctly identified the red left half and blue right half of a synthetic image. |
|
||||
| Pipeline incoming file | TG-FILE01 read marker TG-FILE-7319 and calculated 17 + 25 + 38 = 80. |
|
||||
| Pipeline outgoing media | TG-FILE07 delivered a real green PNG and verified.txt as Telegram attachments. |
|
||||
| Invalid image fallback | TG-FALLBACK08 delivered a-check.txt once and an intentionally invalid nine-byte PNG as a document once, without replaying the preceding file. |
|
||||
| Agent private and group | TG-A01/A02/A03 and TG-COEXIST01 exercised event_reply and real delivery. Historical conversation wording influenced early replies; explicit current instructions were used for subsequent media tests. |
|
||||
| Agent group image input | TG-AGENT-GIMG02 correctly identified left red/right blue. |
|
||||
| Agent group file input/output | TG-AGENT-GFILE01 read the marker and sum and delivered result.txt (27 bytes); run completed in 10.72 seconds. Downloaded the Telegram attachment and verified its contents: Code: TG-FILE-7319 and Sum: 80. |
|
||||
| Agent group image output | TG-AGENT-GOUT01 generated a real 64x64 PNG using Box, delivered it as a photo, and completed in 12.96 seconds. |
|
||||
| Plugin private/group text | TG-PLUGIN-P02 and TG-REACTION-02 echo replies were delivered with completed runs after fixing MessageResult. |
|
||||
| Plugin attachment events | Community processor independently recorded Image and File components, with completed runs. It intentionally sends no reply without its command prefix. |
|
||||
| Independent subscriptions | TG-COEXIST01 produced both the plugin reply and Agent reply. |
|
||||
|
||||
Agent event_reply is a complete send, not token streaming. RunnerDemo community provides progress logs but no streaming or generated-media command; those outputs were tested through Pipeline and Agent instead. This is not a claim that every processor implements every output mode.
|
||||
|
||||
## Real event coverage
|
||||
|
||||
| Event | UI action and observed result |
|
||||
| --- | --- |
|
||||
| message.received | Sent private/group text and attachments. |
|
||||
| message.edited | Edited TG-EDIT01; Agent reported the actual edited event. |
|
||||
| message.reaction | Added then removed thumbs-up; observer payloads retained the emoji with is_add true/false. |
|
||||
| group.member_joined | Added a dedicated existing test bot; observer recorded the event and community sent a welcome. |
|
||||
| group.member_left | Removed that test bot; event had is_kicked true and community sent a departure notice. |
|
||||
| group.member_banned | Restricted that test bot's sending permission; observer recorded the restriction event. |
|
||||
| bot.invited_to_group | Re-added the QA bot; observer completed at 10:04:12. |
|
||||
| bot.removed_from_group | Removed the QA bot while restricted; observer completed at 10:03:55. |
|
||||
| bot.muted | Disabled sending permission; indefinite duration was null after the fix. |
|
||||
| bot.unmuted | Restored sending while another restriction remained; observer completed at 10:02:29. |
|
||||
| platform.specific | Changed administrator status; observer recorded my_chat_member_updated. |
|
||||
|
||||
This covers each declared event type, not every possible Telegram platform-specific subtype. Administrator-only updates were tested with a minimal administrator role in the dedicated group. Replying while muted/removed was not treated as an expected successful action.
|
||||
|
||||
## Fixes driven by acceptance
|
||||
|
||||
1. Normalize numeric conversation IDs before building the Agent protocol context.
|
||||
2. Return MessageResult from Telegram send/reply so successful delivery does not subsequently fail plugin action decoding.
|
||||
3. Deliver terminal stream attachments and remove an empty Thinking placeholder for attachment-only completions.
|
||||
4. Fall back to document delivery for known Telegram photo encoding/dimension rejections, preserving earlier deliveries.
|
||||
5. Register MessageReactionHandler with the correct keyword argument instead of accidentally filtering by chat ID.
|
||||
6. Preserve the removed emoji and distinguish additions from removals.
|
||||
7. Interpret Telegram's Unix-epoch restriction expiry as indefinite; calculate finite duration from event time.
|
||||
8. Recognize partial unmute and removal from a restricted state.
|
||||
|
||||
## Automated and documentation validation
|
||||
|
||||
- 134 focused tests passed across Telegram legacy/new adapters, native stream completion, Omni parity, and Agent event-first protocol.
|
||||
- Ruff and git diff whitespace checks passed.
|
||||
- Chinese, English, and Japanese Telegram guides updated with processor selection, privacy mode, permissions, streaming/media boundaries, event semantics, and troubleshooting.
|
||||
- Sanitized real group screenshot included in all three guides; rendered image verified in the local built documentation.
|
||||
- Documentation tests and production static build passed (38 Node tests; 19 Python tests with one skip; 31 static-build tests).
|
||||
|
||||
After synchronizing the documentation main branch, a stale DingTalk screenshot assertion in the migration test was updated to match the current guide; the full documentation suite passed again.
|
||||
|
||||
Provider-specific errors observed with an earlier model were separated from adapter failures; successful acceptance uses the model stated above. This validation does not assert compatibility with every model provider, Telegram update subtype, or plugin implementation.
|
||||
@@ -335,6 +335,8 @@ class QueryEntryAdapter:
|
||||
|
||||
# Handle launcher_id
|
||||
launcher_id = getattr(query, 'launcher_id', None)
|
||||
if launcher_id is not None:
|
||||
launcher_id = str(launcher_id)
|
||||
|
||||
# Build session_id from launcher info if available
|
||||
session_id = None
|
||||
|
||||
@@ -149,7 +149,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
application.add_handler(
|
||||
MessageReactionHandler(
|
||||
telegram_callback,
|
||||
MessageReactionHandler.MESSAGE_REACTION,
|
||||
message_reaction_types=MessageReactionHandler.MESSAGE_REACTION,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -213,6 +213,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
chat_id: int | str = int(chat_id_str) if chat_id_str.lstrip('-').isdigit() else chat_id_str
|
||||
message_thread_id = int(thread_id_str) if thread_id_str and thread_id_str.isdigit() else None
|
||||
|
||||
sent_messages = []
|
||||
for component in components:
|
||||
component_type = component.get('type')
|
||||
args = {'chat_id': chat_id}
|
||||
@@ -225,20 +226,24 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
text = telegramify_markdown.markdownify(content=text)
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
args['text'] = text
|
||||
await self.bot.send_message(**args)
|
||||
sent_messages.append(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)
|
||||
sent_messages.append(await self._send_photo_or_document(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)
|
||||
sent_messages.append(await self.bot.send_document(**args))
|
||||
|
||||
return platform_events.MessageResult(
|
||||
message_id=sent_messages[-1].message_id if sent_messages else None,
|
||||
raw={'message_ids': [sent.message_id for sent in sent_messages]},
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
@@ -249,6 +254,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
assert isinstance(message_source.source_platform_object, Update)
|
||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||
|
||||
sent_messages = []
|
||||
for component in components:
|
||||
component_type = component.get('type')
|
||||
args = {
|
||||
@@ -271,20 +277,34 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
if self.config['markdown_card'] is True:
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
args['text'] = content
|
||||
await self.bot.send_message(**args)
|
||||
sent_messages.append(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)
|
||||
sent_messages.append(await self._send_photo_or_document(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)
|
||||
sent_messages.append(await self.bot.send_document(**args))
|
||||
|
||||
return platform_events.MessageResult(
|
||||
message_id=sent_messages[-1].message_id if sent_messages else None,
|
||||
raw={'message_ids': [sent.message_id for sent in sent_messages]},
|
||||
)
|
||||
|
||||
async def _send_photo_or_document(self, photo: bytes, args: dict):
|
||||
try:
|
||||
return await self.bot.send_photo(photo=telegram.InputFile(photo), **args)
|
||||
except telegram.error.BadRequest as exc:
|
||||
if 'image_process_failed' not in str(exc).lower() and 'photo_invalid_dimensions' not in str(exc).lower():
|
||||
raise
|
||||
# Preserve rejected image bytes without retrying already-sent parts.
|
||||
await self.logger.warning('Telegram rejected photo encoding; sending the image as a document')
|
||||
return await self.bot.send_document(document=telegram.InputFile(photo, filename='image'), **args)
|
||||
|
||||
# ---- Streaming Output (preserving original logic) ----
|
||||
|
||||
@@ -303,6 +323,14 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
_MAX_STREAM_STATES: typing.ClassVar[int] = 1000
|
||||
|
||||
async def _delete_group_stream_message(self, chat_mode: str, chat_id: int, stream_id: int | None):
|
||||
if chat_mode == 'private' or stream_id is None:
|
||||
return
|
||||
try:
|
||||
await self.bot.delete_message(chat_id=chat_id, message_id=stream_id)
|
||||
except telegram.error.TelegramError:
|
||||
pass
|
||||
|
||||
def _cap_stream_states(self) -> None:
|
||||
while len(self.msg_stream_id) > self._MAX_STREAM_STATES:
|
||||
self.msg_stream_id.pop(next(iter(self.msg_stream_id)), None)
|
||||
@@ -350,6 +378,15 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
if message_id not in self.msg_stream_id:
|
||||
return
|
||||
|
||||
# Media cannot be edited into a text stream. Deliver terminal attachments
|
||||
# through the regular reply path, including attachment-only completions.
|
||||
if is_final and not bot_message.tool_calls:
|
||||
attachments = platform_message.MessageChain(
|
||||
[part for part in message if isinstance(part, (platform_message.Image, platform_message.File))]
|
||||
)
|
||||
if attachments:
|
||||
await self.reply_message(message_source, attachments, quote_origin=quote_origin)
|
||||
|
||||
stream_state = self.msg_stream_id[message_id]
|
||||
chat_mode, stream_id = stream_state[:2]
|
||||
has_visible_content = len(stream_state) > 2 and stream_state[2]
|
||||
@@ -357,6 +394,8 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
if not components or components[0]['type'] != 'text':
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
if not has_visible_content:
|
||||
await self._delete_group_stream_message(chat_mode, chat_id, stream_id)
|
||||
self.msg_stream_id.pop(message_id)
|
||||
return
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Converts all Telegram Update types to unified EBA events, not just messages.
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import datetime
|
||||
|
||||
import telegram
|
||||
from telegram import Update
|
||||
@@ -17,6 +18,13 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
|
||||
from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter
|
||||
|
||||
|
||||
def _restriction_duration(until_date: datetime.datetime | None, event_date: datetime.datetime) -> int | None:
|
||||
"""Telegram uses Unix epoch zero for an indefinite restriction."""
|
||||
if until_date is None or until_date.timestamp() <= 0:
|
||||
return None
|
||||
return max(0, int(until_date.timestamp() - event_date.timestamp()))
|
||||
|
||||
|
||||
def _make_user(tg_user: telegram.User) -> platform_entities.User:
|
||||
"""Convert a Telegram User to a unified User entity."""
|
||||
return platform_entities.User(
|
||||
@@ -229,9 +237,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if new_status == 'restricted' and cm.new_chat_member:
|
||||
restricted = cm.new_chat_member
|
||||
if hasattr(restricted, 'can_send_messages') and not restricted.can_send_messages:
|
||||
duration = None
|
||||
if hasattr(restricted, 'until_date') and restricted.until_date:
|
||||
duration = int(restricted.until_date.timestamp() - time.time())
|
||||
duration = _restriction_duration(restricted.until_date, cm.date)
|
||||
return platform_events.MemberBannedEvent(
|
||||
type='group.member_banned',
|
||||
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
||||
@@ -283,7 +289,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
|
||||
# Bot removed from group
|
||||
if old_status in ('member', 'administrator', 'creator') and new_status in ('left', 'kicked'):
|
||||
if old_status in ('member', 'administrator', 'creator', 'restricted') and new_status in ('left', 'kicked'):
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
type='bot.removed_from_group',
|
||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||
@@ -297,9 +303,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if new_status == 'restricted' and mcm.new_chat_member:
|
||||
restricted = mcm.new_chat_member
|
||||
if hasattr(restricted, 'can_send_messages') and not restricted.can_send_messages:
|
||||
duration = None
|
||||
if hasattr(restricted, 'until_date') and restricted.until_date:
|
||||
duration = int(restricted.until_date.timestamp() - time.time())
|
||||
duration = _restriction_duration(restricted.until_date, mcm.date)
|
||||
return platform_events.BotMutedEvent(
|
||||
type='bot.muted',
|
||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||
@@ -310,7 +314,11 @@ 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:
|
||||
was_muted = old_status == 'restricted' and not mcm.old_chat_member.can_send_messages
|
||||
can_send = new_status in ('member', 'administrator') or (
|
||||
new_status == 'restricted' and mcm.new_chat_member.can_send_messages
|
||||
)
|
||||
if was_muted and can_send:
|
||||
return platform_events.BotUnmutedEvent(
|
||||
type='bot.unmuted',
|
||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||
@@ -341,14 +349,18 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
reaction = update.message_reaction
|
||||
chat = reaction.chat
|
||||
|
||||
# Extract newly added emojis
|
||||
new_emojis = []
|
||||
if reaction.new_reaction:
|
||||
for r in reaction.new_reaction:
|
||||
if hasattr(r, 'emoji'):
|
||||
new_emojis.append(r.emoji)
|
||||
elif hasattr(r, 'custom_emoji_id'):
|
||||
new_emojis.append(str(r.custom_emoji_id))
|
||||
def emojis(items):
|
||||
return [
|
||||
str(r.emoji if hasattr(r, 'emoji') else r.custom_emoji_id)
|
||||
for r in items
|
||||
if hasattr(r, 'emoji') or hasattr(r, 'custom_emoji_id')
|
||||
]
|
||||
|
||||
old_emojis = emojis(reaction.old_reaction or [])
|
||||
new_emojis = emojis(reaction.new_reaction or [])
|
||||
added = [emoji for emoji in new_emojis if emoji not in old_emojis]
|
||||
removed = [emoji for emoji in old_emojis if emoji not in new_emojis]
|
||||
changed = added or removed
|
||||
|
||||
user = platform_entities.User(id='')
|
||||
if reaction.user:
|
||||
@@ -363,8 +375,8 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
adapter_name='telegram-omni',
|
||||
message_id=reaction.message_id,
|
||||
user=user,
|
||||
reaction=new_emojis[0] if new_emojis else '',
|
||||
is_add=len(new_emojis) > 0,
|
||||
reaction=changed[0] if changed else '',
|
||||
is_add=bool(added),
|
||||
chat_type=ct,
|
||||
chat_id=chat.id,
|
||||
group=group,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user