""" Telegram Bot API 精简客户端。 只实现 MoviePilot 用到的 Bot API 方法和 getUpdates 长轮询,请求统一经 RequestUtils 发出。 方法名与参数沿用 pyTelegramBotAPI 的命名,入参中的键盘、回复参数等结构直接使用 Bot API 的 JSON 字典;返回值是 Bot API 响应里的 ``result`` 原始结构(Message 为 dict)。 """ import json import threading import time from typing import Any, Callable, Dict, List, Optional, Union, cast from urllib.parse import urljoin from app.adapters.network.http import RequestUtils from app.runtime.log import logger DEFAULT_API_URL = "https://api.telegram.org" # 普通请求的连接/读取超时,与原 SDK 默认值保持一致。 _CONNECT_TIMEOUT_SECONDS = 15 _READ_TIMEOUT_SECONDS = 30 # 持续失败仍须在默认日志级别可见,同时避免网络抖动时每次重试都刷屏。 _POLLING_WARNING_INTERVAL_SECONDS = 60 # 轮询只接收宿主处理的两类更新,其余更新类型不进入 getUpdates 结果。 _ALLOWED_UPDATES = ["message", "callback_query"] # 群聊/私聊中宿主会转发的消息内容类型,与原 SDK message_handler 注册的类型一致。 FORWARDED_CONTENT_TYPES = ( "text", "photo", "video", "document", "animation", "audio", "voice", "sticker", "video_note", ) ChatId = Union[str, int] # Bot API 请求参数与响应对象(Message、User 等)的 JSON 结构。 JsonDict = Dict[str, Any] class TelegramApiError(Exception): """ Telegram Bot API 调用失败。 ``str(err)`` 包含 Telegram 返回的 description,调用方据此识别 "message is not modified" 等可忽略错误。 """ def __init__( self, method: str, description: str, error_code: Optional[int] = None, parameters: Optional[JsonDict] = None, ): """ 保留业务错误码及附加参数,构造可诊断的异常说明。 :param method: 失败的 Bot API 方法名 :param description: Telegram 返回的业务错误描述 :param error_code: 业务错误码;未知时为 None :param parameters: 可选重试秒数或迁移目标等响应参数;缺失时用空字典 """ self.method = method self.description = description self.error_code = error_code self.parameters = parameters or {} super().__init__( f"Telegram API {method} 调用失败,错误码:{error_code},描述:{description}" ) class TelegramNetworkError(Exception): """ Telegram Bot API 请求未得到响应(连接失败、超时等)。 requests 的异常文本包含完整请求地址,而地址路径里有 Bot token;这里只保留脱敏后的 描述,并切断异常链,避免 token 经日志或 traceback 泄露。 """ def __init__(self, method: str, description: str): """ 封装已经脱敏的网络错误说明。 :param method: 失败的 Bot API 方法名 :param description: 调用方已移除机器人令牌的错误描述 """ self.method = method self.description = description super().__init__(f"Telegram API {method} 请求失败:{description}") class TelegramBotApi: """ 管理单个机器人令牌的 Bot API 请求与长轮询会话。 发送和输入状态使用共用会话,轮询使用独立会话以便连接故障后单独重建。 调用方负责停止更新分发后关闭会话;普通请求失败不会在本客户端内自动重放。 """ def __init__( self, token: str, api_url: Optional[str] = None, proxy: Optional[JsonDict] = None, parse_mode: Optional[str] = None, ): """ 创建启用证书校验的发送会话和轮询停止状态。 :param token: 机器人访问令牌,用于构造 API 和文件地址 :param api_url: 可选自建服务地址,只取协议与主机;为空时使用官方地址 :param proxy: requests 代理配置,仅对官方服务地址生效 :param parse_mode: 调用时 parse_mode 为 None 所使用的默认解析格式 """ self._token = token base_url = api_url or DEFAULT_API_URL # 与历史实现一致:自定义地址只取协议和主机,路径固定为 /bot/。 self._api_base = urljoin(base_url, f"/bot{token}/") self._file_base = urljoin(base_url, f"/file/bot{token}/") # 自定义地址通常是内网自建服务,不走全局代理。 self._proxy = None if api_url else proxy self._default_parse_mode = parse_mode self._http = self._create_http() self._polling_http: Optional[RequestUtils] = None self._polling_stop = threading.Event() # 分发临界区:持锁期间检查停止标记并执行 handler,stop 取得该锁即证明不会再分发。 self._dispatch_lock = threading.Lock() @property def proxy(self) -> Optional[JsonDict]: """ 提供当前客户端实际使用的代理配置。 :return: 官方接口使用的代理字典;自建服务或未配置代理时为 None """ return self._proxy def file_url(self, file_path: str) -> str: """ 构造文件下载地址,地址包含令牌,不得直接记录日志。 :param file_path: getFile 返回的相对文件路径 :return: 包含机器人令牌的完整下载地址 """ return f"{self._file_base}{file_path}" def _create_http(self) -> RequestUtils: """ 创建一个 Bot API 专用 HTTP 会话。 """ return RequestUtils( headers={"Accept": "application/json"}, proxies=self._proxy, use_session=True, # RequestUtils 默认沿用旧宿主的不校验证书行为;Bot token 随请求发送,必须校验。 verify=True, ) def _get_polling_http(self) -> RequestUtils: """ 按需取得轮询专用会话,避免长轮询故障影响出站消息。 """ if self._polling_http is None: self._polling_http = self._create_http() return self._polling_http def _reset_polling_http(self) -> None: """ 重建轮询连接池;只由轮询线程调用。 """ old_http = self._polling_http new_http = self._create_http() self._polling_http = new_http if old_http is not None: old_http.close() def close(self) -> None: """ 关闭发送与轮询 HTTP 会话;调用方应先停止更新分发。 """ self._http.close() if self._polling_http is not None: self._polling_http.close() self._polling_http = None @staticmethod def _encode_params(params: Dict[str, Any]) -> Dict[str, Any]: """ 丢弃 None 值,并按 Bot API 约定把嵌套结构编码为 JSON 字符串。 """ encoded: Dict[str, Any] = {} for key, value in params.items(): if value is None: continue if isinstance(value, (dict, list)): encoded[key] = json.dumps(value, ensure_ascii=False) elif isinstance(value, bool): encoded[key] = "true" if value else "false" else: encoded[key] = value return encoded def call( self, method: str, params: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None, read_timeout: float = _READ_TIMEOUT_SECONDS, _http: Optional[RequestUtils] = None, ) -> Any: """ 提交 Bot API 请求并提取原始 result 字段。 :param method: Bot API 方法名,如 sendMessage :param params: 普通请求参数;None 值会移除,嵌套结构会编码为 JSON :param files: 可选 multipart 文件字段 :param read_timeout: 响应读取超时,单位为秒,默认 30 :param _http: 可选独立请求会话,轮询时使用;为空时使用发送会话 :return: 原始 result 值,具体结构由 Bot API 方法决定 :raises TelegramApiError: API 返回失败标记或无法解析为 JSON :raises TelegramNetworkError: 请求失败或无响应,异常描述已移除机器人令牌 """ request_http = _http or self._http try: response = request_http.post_res( f"{self._api_base}{method}", data=self._encode_params(params or {}), files=files, timeout=(_CONNECT_TIMEOUT_SECONDS, read_timeout), raise_exception=True, ) except Exception as err: # 请求异常可能带有包含令牌的 URL,先脱敏再向上报告 description = f"{type(err).__name__}: {err}".replace(self._token, "") raise TelegramNetworkError(method, description) from None if response is None: raise TelegramNetworkError(method, "未收到响应") try: payload = response.json() except ValueError: raise TelegramApiError( method, f"非 JSON 响应(HTTP {response.status_code})", response.status_code ) from None if not payload.get("ok"): raise TelegramApiError( method, str(payload.get("description")), payload.get("error_code"), payload.get("parameters"), ) return payload.get("result") def _call_dict(self, method: str, *args: Any, **kwargs: Any) -> JsonDict: """ 调用返回对象(Message、User、File 等)的方法。 """ return cast(JsonDict, self.call(method, *args, **kwargs)) def _call_bool(self, method: str, *args: Any, **kwargs: Any) -> bool: """ 调用返回 True 的方法。 """ return bool(self.call(method, *args, **kwargs)) def _call_list(self, method: str, *args: Any, **kwargs: Any) -> List[JsonDict]: """ 调用返回对象数组的方法。 """ return cast(List[JsonDict], self.call(method, *args, **kwargs) or []) def _parse_mode(self, parse_mode: Optional[str]) -> Optional[str]: """ None 使用默认格式,空字符串表示纯文本,不传 parse_mode。 """ resolved = self._default_parse_mode if parse_mode is None else parse_mode return resolved or None @staticmethod def _reply_parameters(reply_to_message_id: Optional[int]) -> Optional[JsonDict]: """ 旧式 reply_to_message_id 转为 Bot API 7.0 起的 reply_parameters。 """ if not reply_to_message_id: return None return {"message_id": int(reply_to_message_id)} @staticmethod def _link_preview_options(disable_web_page_preview: Optional[bool]) -> Optional[JsonDict]: """ 旧式 disable_web_page_preview 转为 link_preview_options。 """ if disable_web_page_preview is None: return None return {"is_disabled": disable_web_page_preview} @staticmethod def _input_file(field: str, value: Any, params: Dict[str, Any], files: Dict[str, Any]) -> None: """ 把 photo/document/voice 参数放到正确位置。 字符串是 file_id 或 URL,走普通参数;bytes、文件对象或 ``(文件名, 内容)`` 走 multipart。 """ if isinstance(value, str): params[field] = value elif isinstance(value, tuple): name, content = value files[field] = (name or field, content) else: files[field] = value def get_me(self) -> JsonDict: """ 查询机器人自身身份。 :return: Bot API User 字典 """ return self._call_dict("getMe") def get_file(self, file_id: str) -> JsonDict: """ 查询资源下载所需的文件信息。 :param file_id: Telegram 分配的文件标识 :return: 包含 file_path 等元数据的原始 File 字典 """ return self._call_dict("getFile", {"file_id": file_id}) def send_message( self, chat_id: ChatId, text: Optional[str], parse_mode: Optional[str] = None, reply_markup: Optional[JsonDict] = None, disable_web_page_preview: Optional[bool] = None, reply_to_message_id: Optional[int] = None, message_thread_id: Optional[int] = None, ) -> JsonDict: """ 发送文本及可选键盘、回复和话题信息。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param text: 待发送正文 :param parse_mode: 正文解析格式;None 使用客户端默认值,空字符串表示纯文本 :param reply_markup: 可选内联键盘或其他 Bot API 键盘结构 :param disable_web_page_preview: 是否关闭网页预览;None 时不发送该配置 :param reply_to_message_id: 可选被回复消息标识,会转换为 reply_parameters :param message_thread_id: 论坛群组中的可选话题标识 :return: Telegram 返回的原始消息字典;API 拒绝或网络失败时抛出统一异常 """ return self._call_dict("sendMessage", { "chat_id": chat_id, "text": text, "parse_mode": self._parse_mode(parse_mode), "reply_markup": reply_markup, "link_preview_options": self._link_preview_options(disable_web_page_preview), "reply_parameters": self._reply_parameters(reply_to_message_id), "message_thread_id": message_thread_id, }) def _send_media( self, method: str, field: str, chat_id: ChatId, media: Any, caption: Optional[str] = None, parse_mode: Optional[str] = None, reply_markup: Optional[JsonDict] = None, reply_to_message_id: Optional[int] = None, message_thread_id: Optional[int] = None, ) -> JsonDict: """ 根据资源类型发送媒体引用或上传本地内容。 :param method: 具体媒体发送 API 方法 :param field: photo、document 或 voice 等请求字段名 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param media: file_id、URL、字节、文件对象或文件名与内容的二元组 :param caption: 可选媒体说明 :param parse_mode: 正文解析格式;None 使用客户端默认值,空字符串表示纯文本 :param reply_markup: 可选内联键盘或其他 Bot API 键盘结构 :param reply_to_message_id: 可选被回复消息标识,会转换为 reply_parameters :param message_thread_id: 论坛群组中的可选话题标识 :return: Telegram 返回的原始消息字典;API 拒绝或网络失败时抛出统一异常 """ params: Dict[str, Any] = { "chat_id": chat_id, "caption": caption, "parse_mode": self._parse_mode(parse_mode), "reply_markup": reply_markup, "reply_parameters": self._reply_parameters(reply_to_message_id), "message_thread_id": message_thread_id, } files: Dict[str, Any] = {} self._input_file(field, media, params, files) return self._call_dict(method, params, files=files or None) def send_photo(self, chat_id: ChatId, photo: Any, **kwargs: Any) -> JsonDict: """ 发送图片资源。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param photo: 资源 file_id、URL、字节、文件对象或文件名与内容的二元组 :param kwargs: 传给 _send_media 的说明、格式、键盘、回复和话题参数 :return: Telegram 返回的原始消息字典;API 拒绝或网络失败时抛出统一异常 """ return self._send_media("sendPhoto", "photo", chat_id, photo, **kwargs) def send_document(self, chat_id: ChatId, document: Any, **kwargs: Any) -> JsonDict: """ 发送文件资源。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param document: 资源 file_id、URL、字节、文件对象或文件名与内容的二元组 :param kwargs: 传给 _send_media 的说明、格式、键盘、回复和话题参数 :return: Telegram 返回的原始消息字典;API 拒绝或网络失败时抛出统一异常 """ return self._send_media("sendDocument", "document", chat_id, document, **kwargs) def send_voice(self, chat_id: ChatId, voice: Any, **kwargs: Any) -> JsonDict: """ 发送语音资源。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param voice: 资源 file_id、URL、字节、文件对象或文件名与内容的二元组 :param kwargs: 传给 _send_media 的说明、格式、键盘、回复和话题参数 :return: Telegram 返回的原始消息字典;API 拒绝或网络失败时抛出统一异常 """ return self._send_media("sendVoice", "voice", chat_id, voice, **kwargs) def send_rich_message( self, chat_id: ChatId, rich_message: JsonDict, reply_markup: Optional[JsonDict] = None, reply_parameters: Optional[JsonDict] = None, message_thread_id: Optional[int] = None, ) -> JsonDict: """ 发送结构化富文本消息。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param rich_message: InputRichMessage 字典,包含 HTML 或 Markdown 正文 :param reply_markup: 可选内联键盘或其他 Bot API 键盘结构 :param reply_parameters: 可选原消息回复参数,直接透传 Bot API :param message_thread_id: 论坛群组中的可选话题标识 :return: Telegram 返回的原始消息字典;API 拒绝或网络失败时抛出统一异常 """ return self._call_dict("sendRichMessage", { "chat_id": chat_id, "rich_message": rich_message, "reply_markup": reply_markup, "reply_parameters": reply_parameters, "message_thread_id": message_thread_id, }) def send_chat_action(self, chat_id: ChatId, action: str) -> bool: """ 发送输入、上传等临时会话状态。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param action: Bot API 状态名称,如 typing :return: API 返回结果转换后的布尔值;调用失败时抛出统一异常 """ return self._call_bool("sendChatAction", {"chat_id": chat_id, "action": action}) def edit_message_text( self, chat_id: ChatId, message_id: int, text: Optional[str] = None, parse_mode: Optional[str] = None, reply_markup: Optional[JsonDict] = None, disable_web_page_preview: Optional[bool] = None, rich_message: Optional[JsonDict] = None, ) -> Any: """ 修改文本消息,可替换为结构化富文本。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param message_id: 目标会话中的原消息标识 :param text: 新的普通正文 :param parse_mode: 正文解析格式;None 使用客户端默认值,空字符串表示纯文本 :param reply_markup: 可选内联键盘或其他 Bot API 键盘结构 :param disable_web_page_preview: 是否关闭网页预览;None 时不发送该配置 :param rich_message: 可选 InputRichMessage;非空时不发送 parse_mode :return: Telegram 返回的更新后消息或成功标记;API 拒绝或网络失败时抛出统一异常 """ return self.call("editMessageText", { "chat_id": chat_id, "message_id": message_id, "text": text, "rich_message": rich_message, "parse_mode": None if rich_message else self._parse_mode(parse_mode), "reply_markup": reply_markup, "link_preview_options": self._link_preview_options(disable_web_page_preview), }) def edit_message_caption( self, chat_id: ChatId, message_id: int, caption: Optional[str] = None, parse_mode: Optional[str] = None, reply_markup: Optional[JsonDict] = None, ) -> Any: """ 修改媒体消息的说明文字。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param message_id: 目标会话中的原消息标识 :param caption: 新的媒体说明 :param parse_mode: 正文解析格式;None 使用客户端默认值,空字符串表示纯文本 :param reply_markup: 可选内联键盘或其他 Bot API 键盘结构 :return: Telegram 返回的更新后消息或成功标记;API 拒绝或网络失败时抛出统一异常 """ return self.call("editMessageCaption", { "chat_id": chat_id, "message_id": message_id, "caption": caption, "parse_mode": self._parse_mode(parse_mode), "reply_markup": reply_markup, }) def edit_message_media( self, chat_id: ChatId, message_id: int, media: JsonDict, reply_markup: Optional[JsonDict] = None, ) -> Any: """ 替换消息中的媒体引用。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param message_id: 目标会话中的原消息标识 :param media: InputMedia 字典;资源必须是 URL 或 file_id,不在此方法上传文件 :param reply_markup: 可选内联键盘或其他 Bot API 键盘结构 :return: Telegram 返回的更新后消息或成功标记;API 拒绝或网络失败时抛出统一异常 """ return self.call("editMessageMedia", { "chat_id": chat_id, "message_id": message_id, "media": media, "reply_markup": reply_markup, }) def delete_message(self, chat_id: ChatId, message_id: int) -> bool: """ 删除目标会话中的消息。 :param chat_id: 目标用户或会话标识,可为数字或 Telegram 支持的公开会话名称 :param message_id: 目标会话中的原消息标识 :return: API 返回结果转换后的布尔值;调用失败时抛出统一异常 """ return self._call_bool("deleteMessage", {"chat_id": chat_id, "message_id": message_id}) def answer_callback_query( self, callback_query_id: Union[str, int], text: Optional[str] = None, show_alert: Optional[bool] = None, ) -> bool: """ 确认按钮回调并结束客户端加载状态。 :param callback_query_id: 按钮回调标识 :param text: 可选提示文字 :param show_alert: 是否使用弹窗提示;None 时不发送该参数 :return: API 返回结果转换后的布尔值;调用失败时抛出统一异常 """ return self._call_bool("answerCallbackQuery", { "callback_query_id": callback_query_id, "text": text, "show_alert": show_alert, }) def set_my_commands(self, commands: List[JsonDict]) -> bool: """ 替换机器人菜单命令。 :param commands: 含 command 和 description 的命令字典列表,命令名不带斜杠 :return: API 返回结果转换后的布尔值;调用失败时抛出统一异常 """ return self._call_bool("setMyCommands", {"commands": commands}) def delete_my_commands(self) -> bool: """ 清除机器人菜单命令。 :return: API 返回结果转换后的布尔值;调用失败时抛出统一异常 """ return self._call_bool("deleteMyCommands") def get_updates(self, offset: Optional[int], timeout: int) -> List[JsonDict]: """ 长轮询读取消息和按钮回调,其他更新类型不请求。 :param offset: 下一条需要的更新标识;服务端会确认比它更早的更新,None 表示尚无本地进度 :param timeout: 服务端等待时长,单位为秒;读取超时额外增加 15 秒 :return: 原始 Update 字典列表;没有更新时返回空列表 """ return self._call_list( "getUpdates", {"offset": offset, "timeout": timeout, "allowed_updates": _ALLOWED_UPDATES}, read_timeout=timeout + _CONNECT_TIMEOUT_SECONDS, _http=self._get_polling_http(), ) def polling( self, handler: Callable[[JsonDict], None], long_polling_timeout: int, retry_max_seconds: int = 60, ) -> None: """ 在当前线程拉取更新并按返回顺序串行分发,直到请求停止。 请求失败从 3 秒开始指数退避,网络异常会重建轮询会话;持续失败每分钟警告一次。 停止后返回的长轮询结果会丢弃,不推进更新进度,供下一个轮询者重新接收。 :param handler: 每条更新的同步处理函数;抛错只记录日志,该更新仍推进本地进度 :param long_polling_timeout: 每次服务端长轮询等待秒数 :param retry_max_seconds: 连续请求失败的最大退避秒数,默认 60 """ offset: Optional[int] = None failures = 0 failure_started_at = 0.0 next_warning_at = 0.0 while not self._polling_stop.is_set(): try: updates = self.get_updates(offset, long_polling_timeout) except Exception as err: if self._polling_stop.is_set(): return if isinstance(err, TelegramNetworkError): try: self._reset_polling_http() except Exception as reset_err: logger.warning(f"Telegram轮询连接重建失败,将继续重试:{reset_err}") failures += 1 now = time.monotonic() retry_seconds = min(3 * 2 ** (failures - 1), retry_max_seconds) if failures == 1: failure_started_at = now next_warning_at = now if now >= next_warning_at: logger.warning( f"Telegram消息拉取失败,将自动重试(连续{failures}次," f"已持续{int(now - failure_started_at)}秒," f"{retry_seconds}秒后重试):{err}" ) next_warning_at = now + _POLLING_WARNING_INTERVAL_SECONDS else: logger.debug(f"Telegram消息拉取第{failures}次失败:{err}") self._polling_stop.wait(retry_seconds) continue if failures: logger.info( f"Telegram消息拉取已恢复(此前连续{failures}次失败," f"持续{int(time.monotonic() - failure_started_at)}秒)" ) failures = 0 for update in updates or []: with self._dispatch_lock: # 停止后不再分发,未确认的更新会在下次启动时重新投递。 if self._polling_stop.is_set(): return offset = update["update_id"] + 1 try: handler(update) except Exception as err: logger.error(f"处理Telegram更新失败:{err}") def stop_polling(self, timeout: float) -> bool: """ 请求轮询循环退出,并等待正在执行的 handler 结束。 不等待进行中的长轮询请求:它返回后只会丢弃结果并退出,不再触发任何回调。 :param timeout: 等待 handler 的最长秒数 :return: 是否已确认之后不会再分发更新 """ self._polling_stop.set() if not self._dispatch_lock.acquire(timeout=max(timeout, 0)): return False self._dispatch_lock.release() return True