Files
MoviePilot/app/modules/telegram/botapi.py

508 lines
21 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.
"""
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):
"""保留 API 错误码和描述,供调用方区分业务拒绝与网络失败。"""
self.method = method
self.description = description
self.error_code = error_code
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):
"""封装请求层已经脱敏的网络错误描述。"""
self.method = method
self.description = description
super().__init__(f"Telegram API {method} 请求失败:{description}")
class TelegramBotApi:
"""
单个 Bot token 的 Bot API 客户端,持有一个复用连接的 HTTP 会话。
发送线程和 typing 线程共享一个会话,轮询线程使用独立会话。requests 连接池本身
线程安全;轮询请求虽然也是 POST,但只读取更新且不会自动重放,避免网络抖动时
重复发送消息。轮询连接断开后可以单独重建,不影响正在发送的消息。
"""
def __init__(
self,
token: str,
api_url: Optional[str] = None,
proxy: Optional[JsonDict] = None,
parse_mode: Optional[str] = None,
):
"""
:param token: Bot token
:param api_url: 自建 Bot API 服务地址,为空时使用官方地址
:param proxy: requests 代理配置,仅在使用官方地址时生效
:param parse_mode: parse_mode 参数为 None 时使用的默认格式
"""
self._token = token
base_url = api_url or DEFAULT_API_URL
# 与历史实现一致:自定义地址只取协议和主机,路径固定为 /bot<token>/<method>。
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 self._proxy
def file_url(self, file_path: str) -> str:
"""拼接 getFile 返回路径对应的下载地址(包含 token,不可写入日志)。"""
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``。
:raises TelegramApiError: Telegram 返回 ok=false 或非 JSON 响应
:raises TelegramNetworkError: 网络失败,描述已去除 token
"""
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: # RequestUtils 在 raise_exception=True 时抛出 requests 异常
description = f"{type(err).__name__}: {err}".replace(self._token, "<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")
)
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
# ------------------------------------------------------------- Bot API 方法
def get_me(self) -> JsonDict:
"""获取 Bot 自身信息。"""
return self._call_dict("getMe")
def get_file(self, file_id: str) -> JsonDict:
"""获取文件信息,返回值含 file_path。"""
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:
"""发送文本消息。"""
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:
"""发送单个图片、文件或语音,按类型选择 URL/file_id 或 multipart 上传。"""
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:
"""发送图片,参数见 ``_send_media``。"""
return self._send_media("sendPhoto", "photo", chat_id, photo, **kwargs)
def send_document(self, chat_id: ChatId, document: Any, **kwargs: Any) -> JsonDict:
"""发送文件,参数见 ``_send_media``。"""
return self._send_media("sendDocument", "document", chat_id, document, **kwargs)
def send_voice(self, chat_id: ChatId, voice: Any, **kwargs: Any) -> JsonDict:
"""发送语音,参数见 ``_send_media``。"""
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:
"""发送 Rich Message,rich_message 为 InputRichMessage 的 JSON 结构。"""
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:
"""发送 typing 等聊天状态。"""
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:
"""编辑文本消息;传 rich_message 时替换为 Rich Message 且不带 parse_mode。"""
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:
"""编辑媒体消息的说明文字。"""
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:
"""替换消息媒体,media 为 InputMedia 的 JSON 结构(媒体须为 URL 或 file_id)。"""
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:
"""删除消息。"""
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:
"""回应按钮回调,结束客户端的加载状态。"""
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:
"""设置菜单命令,元素为 ``{"command": ..., "description": ...}``。"""
return self._call_bool("setMyCommands", {"commands": commands})
def delete_my_commands(self) -> bool:
"""清除菜单命令。"""
return self._call_bool("deleteMyCommands")
# ------------------------------------------------------------------ 轮询
def get_updates(self, offset: Optional[int], timeout: int) -> List[JsonDict]:
"""长轮询获取更新,读取超时比服务端等待时间多留出网络余量。"""
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:
"""
在当前线程循环拉取更新并逐条交给 handler,直到 ``stop_polling`` 被调用。
更新按 update_id 顺序串行处理;handler 抛错只记录日志,仍确认该更新,
避免同一条坏消息被反复投递。网络或 API 失败按指数退避重试,首次失败立即
警告,持续失败每分钟再警告一次;恢复时记录失败次数和持续时间。
停止后仍在进行的 getUpdates 返回时,结果直接丢弃且不确认 offset,Telegram
会把这些更新重新投递给下一个轮询者;新轮询者的 getUpdates 也会让 Telegram
立即结束旧请求。
"""
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