Files
LangBot/src/langbot/pkg/platform/sources/telegram.py
2026-03-21 22:10:23 +08:00

446 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import time
import telegram
import telegram.ext
from telegram import Update
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters
import telegramify_markdown
import typing
import traceback
import base64
import pydantic
from langbot.pkg.utils import httpclient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
components = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
components.append({'type': 'text', 'text': component.text})
elif isinstance(component, platform_message.Image):
photo_bytes = None
if component.base64:
photo_bytes = base64.b64decode(component.base64)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
photo_bytes = await response.read()
elif component.path:
with open(component.path, 'rb') as f:
photo_bytes = f.read()
components.append({'type': 'photo', 'photo': photo_bytes})
elif isinstance(component, platform_message.File):
file_bytes = None
if component.base64:
# Strip data URI prefix if present (e.g. "data:application/pdf;base64,...")
b64_data = component.base64
if ';base64,' in b64_data:
b64_data = b64_data.split(';base64,', 1)[1]
file_bytes = base64.b64decode(b64_data)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
file_bytes = await response.read()
elif component.path:
with open(component.path, 'rb') as f:
file_bytes = f.read()
file_name = getattr(component, 'name', None) or 'file'
components.append({'type': 'document', 'document': file_bytes, 'filename': file_name})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))
return components
@staticmethod
async def target2yiri(message: telegram.Message, bot: telegram.Bot, bot_account_id: str):
message_components = []
def parse_message_text(text: str) -> list[platform_message.MessageComponent]:
msg_components = []
if f'@{bot_account_id}' in text:
msg_components.append(platform_message.At(target=bot_account_id))
text = text.replace(f'@{bot_account_id}', '')
msg_components.append(platform_message.Plain(text=text))
return msg_components
if message.text:
message_text = message.text
message_components.extend(parse_message_text(message_text))
if message.photo:
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.photo[-1].get_file()
file_bytes = None
file_format = ''
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
file_format = 'image/jpeg'
message_components.append(
platform_message.Image(
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}'
)
)
if message.voice:
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.voice.get_file()
file_bytes = None
file_format = message.voice.mime_type or 'audio/ogg'
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
message_components.append(
platform_message.Voice(
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
length=message.voice.duration,
)
)
if message.document:
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.document.get_file()
file_name = message.document.file_name or 'document'
file_size = message.document.file_size or 0
file_format = message.document.mime_type or 'application/octet-stream'
file_bytes = None
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
message_components.append(
platform_message.File(
name=file_name,
size=file_size,
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
)
)
return platform_message.MessageChain(message_components)
class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.MessageEvent, bot: telegram.Bot):
return event.source_platform_object
@staticmethod
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
lb_message = await TelegramMessageConverter.target2yiri(event.message, bot, bot_account_id)
if event.effective_chat.type == 'private':
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event.effective_chat.id,
nickname=event.effective_chat.first_name,
remark=str(event.effective_chat.id),
),
message_chain=lb_message,
time=event.message.date.timestamp(),
source_platform_object=event,
)
elif event.effective_chat.type == 'group' or 'supergroup':
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=event.effective_chat.id,
member_name=event.effective_chat.title,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=event.effective_chat.id,
name=event.effective_chat.title,
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=lb_message,
time=event.message.date.timestamp(),
source_platform_object=event,
)
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: telegram.Bot = pydantic.Field(exclude=True)
application: telegram.ext.Application = pydantic.Field(exclude=True)
message_converter: TelegramMessageConverter = TelegramMessageConverter()
event_converter: TelegramEventConverter = TelegramEventConverter()
config: dict
msg_stream_id: dict # 流式消息id字典key为流式消息idvalue为首次消息源id用于在流式消息时判断编辑那条消息
seq: int # 消息中识别消息顺序直接以seq作为标识
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.message.from_user.is_bot:
return
try:
lb_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id)
await self.listeners[type(lb_event)](lb_event, self)
await self.is_stream_output_supported()
except Exception:
await self.logger.error(f'Error in telegram callback: {traceback.format_exc()}')
application = ApplicationBuilder().token(config['token']).build()
bot = application.bot
application.add_handler(
MessageHandler(
filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE | filters.Document.ALL,
telegram_callback,
)
)
super().__init__(
config=config,
logger=logger,
msg_stream_id={},
seq=1,
bot=bot,
application=application,
bot_account_id='',
listeners={},
)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
components = await TelegramMessageConverter.yiri2target(message, self.bot)
chat_id_str, _, thread_id_str = str(target_id).partition('#')
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
for component in components:
component_type = component.get('type')
args = {'chat_id': chat_id}
if message_thread_id is not None:
args['message_thread_id'] = message_thread_id
if component_type == 'text':
text = component.get('text', '')
if self.config['markdown_card'] is True:
text = telegramify_markdown.markdownify(content=text)
args['parse_mode'] = 'MarkdownV2'
args['text'] = text
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)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
assert isinstance(message_source.source_platform_object, Update)
components = await TelegramMessageConverter.yiri2target(message, self.bot)
for component in components:
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)
def _process_markdown(self, text: str) -> str:
if self.config.get('markdown_card', False):
return telegramify_markdown.markdownify(content=text)
return text
def _build_message_args(self, chat_id: int, text: str, message_thread_id: int = None, **extra_args) -> dict:
args = {'chat_id': chat_id, 'text': self._process_markdown(text), **extra_args}
if message_thread_id:
args['message_thread_id'] = message_thread_id
if self.config.get('markdown_card', False):
args['parse_mode'] = 'MarkdownV2'
return args
async def create_message_card(self, message_id, event):
assert isinstance(event.source_platform_object, Update)
update = event.source_platform_object
chat_id = update.effective_chat.id
chat_type = update.effective_chat.type
message_thread_id = update.message.message_thread_id
if chat_type == 'private':
draft_id = int(time.time() * 1000)
self.msg_stream_id[message_id] = ('private', draft_id)
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
await self.bot.send_message_draft(**args)
else:
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
send_msg = await self.bot.send_message(**args)
self.msg_stream_id[message_id] = ('group', send_msg.message_id)
return True
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
message_id = bot_message.resp_message_id
msg_seq = bot_message.msg_sequence
assert isinstance(message_source.source_platform_object, Update)
update = message_source.source_platform_object
chat_id = update.effective_chat.id
message_thread_id = update.message.message_thread_id
if message_id not in self.msg_stream_id:
return
chat_mode, draft_id = self.msg_stream_id[message_id]
components = await TelegramMessageConverter.yiri2target(message, self.bot)
if not components or components[0]['type'] != 'text':
if is_final and bot_message.tool_calls is None:
self.msg_stream_id.pop(message_id)
return
content = components[0]['text']
if chat_mode == 'private':
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)
await self.bot.send_message_draft(**args)
if is_final and bot_message.tool_calls is None:
del args['draft_id']
await self.bot.send_message(**args)
self.msg_stream_id.pop(message_id)
else:
stream_id = draft_id
if (msg_seq - 1) % 8 == 0 or is_final:
args = {
'message_id': stream_id,
'chat_id': chat_id,
'text': self._process_markdown(content),
}
if self.config.get('markdown_card', False):
args['parse_mode'] = 'MarkdownV2'
await self.bot.edit_message_text(**args)
if is_final and bot_message.tool_calls is None:
self.msg_stream_id.pop(message_id)
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
if not isinstance(event.source_platform_object, Update):
return None
message = event.source_platform_object.message
if not message:
return None
# specifically handle telegram forum topic and private thread(not supported by official client yet but supported by bot api)
if message.message_thread_id:
# check if it is a group
if isinstance(event, platform_events.GroupMessage):
return f'{event.group.id}#{message.message_thread_id}'
elif isinstance(event, platform_events.FriendMessage):
return f'{event.sender.id}#{message.message_thread_id}'
return None
async def is_stream_output_supported(self) -> bool:
is_stream = False
if self.config.get('enable-stream-reply', None):
is_stream = True
return is_stream
async def is_muted(self, group_id: int) -> bool:
return False
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners.pop(event_type)
async def run_async(self):
await self.application.initialize()
self.bot_account_id = (await self.bot.get_me()).username
await self.application.updater.start_polling(allowed_updates=Update.ALL_TYPES)
await self.application.start()
await self.logger.info('Telegram adapter running')
async def kill(self) -> bool:
if self.application.running:
await self.application.stop()
if self.application.updater:
await self.application.updater.stop()
await self.logger.info('Telegram adapter stopped')
return True