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
@@ -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,