import copy import json import re from typing import Dict, Optional, Union, List, Tuple, Any from app.core.context import MediaInfo, Context from app.core.event import eventmanager from app.helper.agent import ( matches_channel_admin, register_channel_admin_resolver, resolve_config_principal_ids, ) from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.telegram.telegram import Telegram from app.schemas import ( MessageChannel, CommingMessage, Notification, CommandRegisterEventData, NotificationConf, MessageResponse, ) from app.schemas.types import ModuleType, ChainEventType from app.utils.structures import DictUtils register_channel_admin_resolver( MessageChannel.Telegram, lambda config: resolve_config_principal_ids( config, "TELEGRAM_ADMINS", "TELEGRAM_CHAT_ID" ), ) class TelegramModule(_ModuleBase, _MessageBase[Telegram]): """ Telegram 通知模块,负责模块生命周期、消息解析和通知发送。 """ def init_module(self) -> None: """ 初始化模块 """ super().init_service( service_name=Telegram.__name__.lower(), service_type=Telegram ) self._channel = MessageChannel.Telegram @staticmethod def get_name() -> str: """ 获取模块名称 """ return "Telegram" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Notification @staticmethod def get_subtype() -> MessageChannel: """ 获取模块子类型 """ return MessageChannel.Telegram @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 0 def stop(self) -> None: """停止模块""" for client in self.get_instances().values(): try: client.stop() except Exception as err: logger.error(f"停止Telegram模块实例失败:{err}") def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, client in self.get_instances().items(): state = client.get_state() if not state: return False, f"Telegram {name} 未就绪" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: """ 获取模块初始化配置项。 """ pass @staticmethod def _get_admins(config: Optional[dict]) -> List[str]: """ 解析 Telegram 管理员配置,兼容逗号分隔和首尾空白。 """ return [ admin.strip() for admin in str((config or {}).get("TELEGRAM_ADMINS") or "").split(",") if admin.strip() ] @classmethod def _should_reject_admin_command( cls, config: Optional[dict], *user_ids: Optional[Union[str, int]], ) -> bool: """ 判断 Telegram 命令或命令型按钮回调是否应因非管理员身份被拒绝。 """ admins = cls._get_admins(config) if not admins: return False return not matches_channel_admin( MessageChannel.Telegram, config, *user_ids, ) def message_parser( self, source: str, body: Any, form: Any, args: Any ) -> Optional[CommingMessage]: """ 解析消息内容,返回字典,注意以下约定值: userid: 用户ID username: 用户名 text: 内容 :param source: 消息来源 :param body: 请求体 :param form: 表单 :param args: 参数 :return: 渠道、消息体 """ """ 普通消息格式: { 'update_id': , 'message': { 'message_id': , 'from': { 'id': , 'is_bot': False, 'first_name': '', 'username': '', 'language_code': 'zh-hans' }, 'chat': { 'id': , 'first_name': '', 'username': '', 'type': 'private' }, 'date': , 'text': '' } } 按钮回调格式: { 'callback_query': { 'id': '', 'from': {...}, 'message': {...}, 'data': 'callback_data' } } """ # 获取服务配置 client_config = self.get_config(source) if not client_config: return None client: Telegram = self.get_instance(client_config.name) try: message = json.loads(body) while isinstance(message, str): message = json.loads(message) except Exception as err: logger.debug(f"解析Telegram消息失败:{str(err)}") return None if not isinstance(message, dict): logger.debug(f"Telegram消息格式无效:{type(message)}") return None # 兼容某些转发链路使用 Telegram Update 外壳 if "message" in message and isinstance(message.get("message"), dict): message = message.get("message") if message: # 处理按钮回调 if "callback_query" in message: return self._handle_callback_query(message, client_config, client) # 处理普通消息 return self._handle_text_message(message, client_config, client) return None def _handle_callback_query( self, message: dict, client_config: NotificationConf, client: Telegram ) -> Optional[CommingMessage]: """ 处理按钮回调查询 """ callback_query = message.get("callback_query", {}) user_info = callback_query.get("from", {}) callback_data = callback_query.get("data", "") user_id = user_info.get("id") user_name = user_info.get("username") if callback_data and user_id: if str(callback_data).strip().startswith("/") and self._should_reject_admin_command( client_config.config, user_id ): if client: client.answer_callback_query( callback_query_id=callback_query.get("id"), text="只有管理员才有权限执行此命令", show_alert=True, ) return None logger.info( f"收到来自 {client_config.name} 的Telegram按钮回调:" f"userid={user_id}, username={user_name}, callback_data={callback_data}" ) # 将callback_data作为特殊格式的text返回,以便主程序识别这是按钮回调 callback_text = f"CALLBACK:{callback_data}" # 创建包含完整回调信息的CommingMessage return CommingMessage( channel=MessageChannel.Telegram, source=client_config.name, userid=user_id, username=user_name, is_channel_admin=matches_channel_admin( MessageChannel.Telegram, client_config.config, user_id, ), text=callback_text, is_callback=True, callback_data=callback_data, message_id=callback_query.get("message", {}).get("message_id"), chat_id=str( callback_query.get("message", {}).get("chat", {}).get("id", "") ), callback_query=callback_query, ) return None def _handle_text_message( self, msg: dict, client_config: NotificationConf, client: Telegram ) -> Optional[CommingMessage]: """ 处理普通文本消息 """ text = msg.get("text") or msg.get("caption") message_id = msg.get("message_id") user_id = msg.get("from", {}).get("id") user_name = msg.get("from", {}).get("username") chat_id = msg.get("chat", {}).get("id") reply_to_message_id = (msg.get("reply_to_message") or {}).get("message_id") # 将 text_link 实体中的 URL 嵌入到文本中 if text: text = self._embed_entity_links(text, msg.get("entities") or msg.get("caption_entities")) # 将 reply_markup 中的 URL 按钮信息追加到文本中 text = self._append_reply_markup_links(text, msg.get("reply_markup")) images = self._extract_images(msg) audio_refs = self._extract_audio_refs(msg) files = self._extract_files(msg) if user_id: if not text and not images and not audio_refs and not files: logger.debug( f"收到来自 {client_config.name} 的Telegram消息无文本、图片、语音和文件" ) return None logger.info( f"收到来自 {client_config.name} 的Telegram消息:" f"userid={user_id}, username={user_name}, chat_id={chat_id}, text={text}, " f"images={len(images) if images else 0}, audios={len(audio_refs) if audio_refs else 0}, " f"files={len(files) if files else 0}" ) cleaned_text = ( self._clean_bot_mention(text, client.bot_username if client else None) if text else None ) user_list = client_config.config.get("TELEGRAM_USERS") if cleaned_text and cleaned_text.startswith("/"): if self._should_reject_admin_command(client_config.config, user_id): client.send_msg( title="只有管理员才有权限执行此命令", userid=user_id ) return None else: if user_list and str(user_id) not in user_list.split(","): logger.info(f"用户{user_id}不在用户白名单中,无法使用此机器人") client.send_msg( title="你不在用户白名单中,无法使用此机器人", userid=user_id ) return None return CommingMessage( channel=MessageChannel.Telegram, source=client_config.name, userid=user_id, username=user_name, is_channel_admin=matches_channel_admin( MessageChannel.Telegram, client_config.config, user_id, ), text=cleaned_text, message_id=message_id, chat_id=str(chat_id) if chat_id else None, reply_to_message_id=reply_to_message_id, images=images if images else None, audio_refs=audio_refs if audio_refs else None, files=files if files else None, ) return None @staticmethod def _extract_images(msg: dict) -> Optional[List[CommingMessage.MessageImage]]: """ 从Telegram消息中提取图片file_id """ images = [] photo = msg.get("photo") if photo and isinstance(photo, list): largest_photo = photo[-1] file_id = largest_photo.get("file_id") if file_id: images.append( CommingMessage.MessageImage( ref=f"tg://file_id/{file_id}", mime_type="image/jpeg", size=largest_photo.get("file_size"), ) ) document = msg.get("document") if document: file_id = document.get("file_id") mime_type = document.get("mime_type", "") if file_id and mime_type.startswith("image/"): images.append( CommingMessage.MessageImage( ref=f"tg://file_id/{file_id}", name=document.get("file_name"), mime_type=document.get("mime_type"), size=document.get("file_size"), ) ) return images if images else None @staticmethod def _extract_audio_refs(msg: dict) -> Optional[List[str]]: """ 从Telegram消息中提取语音/音频 file_id。 """ audio_refs = [] voice = msg.get("voice") if voice: file_id = voice.get("file_id") if file_id: audio_refs.append(f"tg://voice_file_id/{file_id}") audio = msg.get("audio") if audio: file_id = audio.get("file_id") if file_id: audio_refs.append(f"tg://audio_file_id/{file_id}") return audio_refs if audio_refs else None @staticmethod def _extract_files(msg: dict) -> Optional[List[CommingMessage.MessageAttachment]]: """ 从 Telegram 消息中提取非图片文件附件。 """ document = msg.get("document") if not isinstance(document, dict): return None file_id = document.get("file_id") mime_type = (document.get("mime_type") or "").lower() if not file_id or mime_type.startswith("image/"): return None return [ CommingMessage.MessageAttachment( ref=f"tg://document_file_id/{file_id}", name=document.get("file_name"), mime_type=document.get("mime_type"), size=document.get("file_size"), ) ] @staticmethod def _embed_entity_links(text: str, entities: Optional[List[dict]]) -> str: """ 将 text_link 实体中的 URL 嵌入到文本中 :param text: 原始文本 :param entities: 消息实体列表 :return: 嵌入链接后的文本 """ if not entities: return text text_link_entities = sorted( [e for e in entities if e.get("type") == "text_link" and e.get("url")], key=lambda e: e.get("offset", 0), reverse=True, ) text_utf16 = text.encode("utf-16-le") for entity in text_link_entities: offset = entity.get("offset", 0) length = entity.get("length", 0) url = entity["url"] char_offset = len(text_utf16[:offset * 2].decode("utf-16-le")) char_length = len(text_utf16[offset * 2: (offset + length) * 2].decode("utf-16-le")) display_text = text[char_offset: char_offset + char_length] text = text[:char_offset] + f"{display_text}({url})" + text[char_offset + char_length:] text_utf16 = text.encode("utf-16-le") return text @staticmethod def _append_reply_markup_links(text: Optional[str], reply_markup: Optional[dict]) -> Optional[str]: """ 将 reply_markup 中的 URL 按钮信息追加到文本末尾 :param text: 原始文本 :param reply_markup: 消息的 reply_markup 字段 :return: 追加按钮链接后的文本 """ if not reply_markup: return text inline_keyboard = reply_markup.get("inline_keyboard") if not inline_keyboard: return text button_lines = [] for row in inline_keyboard: for button in row: btn_text = button.get("text", "") btn_url = button.get("url") if btn_url: button_lines.append(f"{btn_text}({btn_url})") if not button_lines: return text buttons_text = "\n".join(button_lines) if text: return f"{text}\n{buttons_text}" return buttons_text @staticmethod def _clean_bot_mention(text: str, bot_username: Optional[str]) -> str: """ 清理消息中的@bot部分,确保文本处理一致性 :param text: 原始消息文本 :param bot_username: bot用户名 :return: 清理后的文本 """ if not text or not bot_username: return text # Remove @bot_username from the beginning and any position in text cleaned = text mention_pattern = f"@{bot_username}" # Remove mention at the beginning with optional following space if cleaned.startswith(mention_pattern): cleaned = cleaned[len(mention_pattern):].lstrip() # Remove mention at any other position cleaned = cleaned.replace(mention_pattern, "").strip() # Clean up multiple spaces cleaned = re.sub(r"\s+", " ", cleaned).strip() return cleaned def post_message(self, message: Notification, **kwargs) -> None: """ 发送消息 :param message: 消息体 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get("telegram_userid") if not userid: logger.warn(f"用户没有指定 Telegram用户ID,消息无法发送") return client: Telegram = self.get_instance(conf.name) if client: if message.file_path: client.send_file( file_path=message.file_path, file_name=message.file_name, title=message.title, text=message.text, userid=userid, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, ) elif message.voice_path: client.send_voice( voice_path=message.voice_path, userid=userid, caption=message.voice_caption, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, ) else: # Telegram 的 reply_markup 不能同时承载 InlineKeyboard 和 ForceReply。 # 普通通知只清空可编辑消息 ID,仍保留原会话作为新消息目标。 has_interaction_context = bool(message.buttons or message.force_reply) original_message_id = ( message.original_message_id if has_interaction_context else None ) client.send_msg( title=message.title, text=message.text, image=message.image, userid=userid, link=message.link, buttons=message.buttons, force_reply=message.force_reply, original_message_id=original_message_id, original_chat_id=message.original_chat_id, disable_web_page_preview=message.disable_web_page_preview, parse_mode=message.parse_mode, ) def post_medias_message( self, message: Notification, medias: List[MediaInfo] ) -> None: """ 发送媒体信息选择列表 :param message: 消息体 :param medias: 媒体列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: Telegram = self.get_instance(conf.name) if client: client.send_medias_msg( title=message.title, medias=medias, userid=message.userid, link=message.link, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, ) def post_torrents_message( self, message: Notification, torrents: List[Context] ) -> None: """ 发送种子信息选择列表 :param message: 消息体 :param torrents: 种子列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: Telegram = self.get_instance(conf.name) if client: client.send_torrents_msg( title=message.title, torrents=torrents, userid=message.userid, link=message.link, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, ) def delete_message( self, channel: MessageChannel, source: str, message_id: int, chat_id: Optional[int] = None, ) -> Optional[bool]: """ 删除消息 :param channel: 消息渠道 :param source: 指定的消息源 :param message_id: 消息ID :param chat_id: 聊天ID :return: 删除是否成功 """ if channel != self._channel: return None success = False for conf in self.get_configs().values(): if source != conf.name: continue client: Telegram = self.get_instance(conf.name) if client: result = client.delete_msg(message_id=message_id, chat_id=chat_id) if result: success = True return success def edit_message( self, channel: MessageChannel, source: str, message_id: Union[str, int], chat_id: Union[str, int], text: str, title: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, metadata: Optional[dict] = None, parse_mode: Optional[str] = None, ) -> Optional[bool]: """ 编辑消息 :param channel: 消息渠道 :param source: 指定的消息源 :param message_id: 消息ID :param chat_id: 聊天ID :param text: 新的消息内容 :param title: 消息标题 :param buttons: 新的按钮列表 :param metadata: 其他元信息 :param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML :return: 编辑是否成功 """ if channel != self._channel: return None for conf in self.get_configs().values(): if source != conf.name: continue client: Telegram = self.get_instance(conf.name) if client: result = client.edit_msg( chat_id=chat_id, message_id=message_id, text=text, title=title, buttons=buttons, parse_mode=parse_mode, ) if result: return True return False def mark_message_processing_started( self, channel: MessageChannel, source: str, userid: Optional[Union[str, int]] = None, message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, text: Optional[str] = None, ) -> Optional[dict]: """ 标记 Telegram 消息正在处理。 Telegram typing 需要周期性续发,因此在模块接口中启动保活任务。 """ if channel != self._channel: return None client_config = self.get_config(source) if not client_config: return None client: Telegram = self.get_instance(client_config.name) if not client: return None started = client.start_typing(chat_id=chat_id, userid=userid) if not started: return None return { "channel": channel.value, "source": source, "userid": userid, "message_id": message_id, "chat_id": chat_id, "metadata": {"kind": "typing"}, } def mark_message_processing_finished( self, channel: MessageChannel, source: str, userid: Optional[Union[str, int]] = None, message_id: Optional[Union[str, int]] = None, chat_id: Optional[Union[str, int]] = None, status: Optional[dict] = None, ) -> Optional[bool]: """ 结束 Telegram typing 状态。 """ if channel != self._channel: return None if status: chat_id = status.get("chat_id") or chat_id userid = status.get("userid") or userid client_config = self.get_config(source) if not client_config: return False client: Telegram = self.get_instance(client_config.name) if not client: return False return client.stop_typing(chat_id=chat_id, userid=userid) def send_direct_message(self, message: Notification) -> Optional[MessageResponse]: """ 直接发送消息并返回消息ID等信息 :param message: 消息体 :return: 消息响应(包含message_id, chat_id等) """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get("telegram_userid") if not userid: logger.warn("用户没有指定 Telegram用户ID,消息无法发送") return None client: Telegram = self.get_instance(conf.name) if client: if message.voice_path: result = client.send_voice( voice_path=message.voice_path, userid=userid, caption=message.voice_caption, original_chat_id=message.original_chat_id, parse_mode=message.parse_mode, ) else: # direct message 只禁用编辑旧消息;仅 ForceReply 使用 original_chat_id # 发回原会话,并保留 original_message_id 让 client reply_to 原消息。 original_chat_id = message.original_chat_id if message.force_reply else None original_message_id = message.original_message_id if message.force_reply else None result = client.send_msg( title=message.title, text=message.text, image=message.image, userid=userid, link=message.link, force_reply=message.force_reply, original_message_id=original_message_id, original_chat_id=original_chat_id, disable_web_page_preview=message.disable_web_page_preview, parse_mode=message.parse_mode, private_delivery=message.private_delivery, ) if result and result.get("success"): return MessageResponse( message_id=result.get("message_id"), chat_id=result.get("chat_id"), channel=MessageChannel.Telegram, source=conf.name, success=True, ) return None def register_commands(self, commands: Dict[str, dict]): """ 注册命令,实现这个函数接收系统可用的命令菜单 :param commands: 命令字典 """ for client_config in self.get_configs().values(): client = self.get_instance(client_config.name) if not client: continue # 触发事件,允许调整命令数据,这里需要进行深复制,避免实例共享 scoped_commands = copy.deepcopy(commands) event = eventmanager.send_event( ChainEventType.CommandRegister, CommandRegisterEventData( commands=scoped_commands, origin="Telegram", service=client_config.name, ), ) # 如果事件返回有效的 event_data,使用事件中调整后的命令 if event and event.event_data: event_data: CommandRegisterEventData = event.event_data # 如果事件被取消,跳过命令注册,并清理菜单 if event_data.cancel: client.delete_commands() logger.debug( f"Command registration for {client_config.name} canceled by event: {event_data.source}" ) continue scoped_commands = event_data.commands or {} if not scoped_commands: logger.debug("Filtered commands are empty, skipping registration.") client.delete_commands() # scoped_commands 必须是 commands 的子集 filtered_scoped_commands = DictUtils.filter_keys_to_subset( scoped_commands, commands ) # 如果 filtered_scoped_commands 为空,则跳过注册 if not filtered_scoped_commands: logger.debug("Filtered commands are empty, skipping registration.") client.delete_commands() continue # 对比调整后的命令与当前命令 if filtered_scoped_commands != commands: logger.debug( f"Command set has changed, Updating new commands: {filtered_scoped_commands}" ) client.register_commands(filtered_scoped_commands) def download_telegram_file_to_base64(self, file_id: str, source: str) -> Optional[str]: """ 下载Telegram文件并转为base64 :param file_id: Telegram文件ID :param source: 来源名称 :return: base64编码的图片数据 """ config = self.get_config(source) if not config: return None client = self.get_instance(config.name) if not client: return None file_content = client.download_file(file_id) if file_content: import base64 return base64.b64encode(file_content).decode() return None def download_telegram_file_bytes(self, file_id: str, source: str) -> Optional[bytes]: """ 下载Telegram文件并返回原始字节。 """ config = self.get_config(source) if not config: return None client = self.get_instance(config.name) if not client: return None return client.download_file(file_id)