mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 20:05:42 +08:00
* fix: retain plugin lifecycle ownership and recover installation state * fix: preserve directed messaging and channel interaction contracts * test: cover plugin rollback and uninstall lifecycle boundaries --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
2398 lines
97 KiB
Python
2398 lines
97 KiB
Python
"""
|
||
Telegram 消息投递、富文本回退、更新转发及输入状态生命周期。
|
||
"""
|
||
|
||
import asyncio
|
||
import html as html_utils
|
||
import re
|
||
import threading
|
||
import time
|
||
from html.parser import HTMLParser
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, List, Optional, Union, cast
|
||
|
||
from telegramify_markdown import richify, split_rich, standardize, telegramify
|
||
|
||
try:
|
||
from telegramify_markdown import entities_to_markdownv2
|
||
except ImportError:
|
||
entities_to_markdownv2 = None
|
||
try:
|
||
from telegramify_markdown.content import ContentTypes, File, Photo, Text
|
||
except ImportError:
|
||
from telegramify_markdown.type import ContentTypes, File, Photo, Text
|
||
|
||
from app.adapters.network.http import RequestUtils
|
||
from app.application.image import ImageHelper
|
||
from app.application.messaging.ingress import forward_message_to_host
|
||
from app.domain.context import Context, MediaInfo
|
||
from app.domain.metainfo import MetaInfo
|
||
from app.foundation import size as size_tools
|
||
from app.modules.telegram.botapi import (
|
||
FORWARDED_CONTENT_TYPES,
|
||
JsonDict,
|
||
TelegramApiError,
|
||
TelegramBotApi,
|
||
)
|
||
from app.runtime.execution import retry
|
||
from app.runtime.log import logger
|
||
from app.runtime.settings import get_runtime_setting
|
||
from app.runtime.thread import ThreadHelper
|
||
from app.schemas.exception import ImmediateException
|
||
|
||
TELEGRAM_PARSE_MODE_MARKDOWN = "MarkdownV2"
|
||
TELEGRAM_PARSE_MODE_HTML = "HTML"
|
||
TELEGRAM_PARSE_MODE_PLAIN = ""
|
||
# Telegram 对同一消息的编辑频率有独立限制;流式缓冲会在闸门后合并为最新内容。
|
||
TELEGRAM_EDIT_MIN_INTERVAL_SECONDS = 2.0
|
||
# 服务端未给出重试时间时使用的默认冷却秒数
|
||
TELEGRAM_RATE_LIMIT_DEFAULT_SECONDS = 60.0
|
||
# 持续限流期间日志输出间隔,单位为秒
|
||
TELEGRAM_RATE_LIMIT_LOG_INTERVAL_SECONDS = 60.0
|
||
_TELEGRAM_RETRY_AFTER_PATTERN = re.compile(r"retry\s+after\s+(\d+)", re.IGNORECASE)
|
||
# InputRichMessage 在 Bot API 中定义的字段,转换结果中的其他键不发送。
|
||
_RICH_MESSAGE_FIELDS = ("html", "markdown", "is_rtl", "skip_entity_detection")
|
||
TELEGRAM_PARSE_MODE_ALIASES = {
|
||
"markdownv2": TELEGRAM_PARSE_MODE_MARKDOWN,
|
||
"mdv2": TELEGRAM_PARSE_MODE_MARKDOWN,
|
||
"html": TELEGRAM_PARSE_MODE_HTML,
|
||
"plain": TELEGRAM_PARSE_MODE_PLAIN,
|
||
"text": TELEGRAM_PARSE_MODE_PLAIN,
|
||
}
|
||
|
||
|
||
class _HtmlMessageSplitter(HTMLParser):
|
||
"""
|
||
按可见文本UTF-16长度拆分Telegram HTML,分块处关闭并重开仍生效的标签。
|
||
|
||
:ivar chunks: 各块可独立解析的HTML正文
|
||
:ivar _parts: 当前块的原始标记与文本片段
|
||
:ivar _stack: 仍生效的标签名与原始起始标记,保留链接等属性
|
||
:ivar _size: 当前块解析实体后的UTF-16长度
|
||
:ivar _limit: 每块允许的最大可见长度
|
||
"""
|
||
|
||
def __init__(self, limit: int) -> None:
|
||
"""
|
||
保留实体原文,使分块不会切开实体或丢失换行。
|
||
|
||
:param limit: 每块可见文本UTF-16长度上限,必须为正数
|
||
"""
|
||
super().__init__(convert_charrefs=False)
|
||
self.chunks: list[str] = []
|
||
self._parts: list[str] = []
|
||
self._stack: list[tuple[str, str]] = []
|
||
self._size = 0
|
||
self._limit = max(1, limit)
|
||
|
||
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
|
||
"""
|
||
保存完整起始标签及属性,供后续分块重新打开。
|
||
"""
|
||
raw = self.get_starttag_text() or f"<{tag}>"
|
||
self._parts.append(raw)
|
||
self._stack.append((tag, raw))
|
||
|
||
def handle_endtag(self, tag: str) -> None:
|
||
"""
|
||
结束当前标签,输入须遵守Telegram原有合法HTML合同。
|
||
"""
|
||
self._parts.append(f"</{tag}>")
|
||
if self._stack and self._stack[-1][0] == tag:
|
||
self._stack.pop()
|
||
|
||
def handle_data(self, data: str) -> None:
|
||
"""
|
||
仅在Unicode字符之间拆分正文,非BMP字符占两个UTF-16单元。
|
||
"""
|
||
for character in data:
|
||
self._append_content(character, 2 if ord(character) > 0xFFFF else 1)
|
||
|
||
def handle_entityref(self, name: str) -> None:
|
||
"""
|
||
作为不可拆分的整体追加命名实体。
|
||
"""
|
||
self._append_entity(f"&{name};")
|
||
|
||
def handle_charref(self, name: str) -> None:
|
||
"""
|
||
作为不可拆分的整体追加数字实体。
|
||
"""
|
||
self._append_entity(f"&#{name};")
|
||
|
||
def _append_entity(self, raw: str) -> None:
|
||
"""
|
||
按解码后的可见长度核对实体边界,发送时保留其原始编码。
|
||
"""
|
||
self._append_content(raw, len(html_utils.unescape(raw).encode("utf-16-le")) // 2)
|
||
|
||
def _append_content(self, raw: str, size: int) -> None:
|
||
"""
|
||
在当前内容超限前结算并重新打开标签,不重复可见正文。
|
||
"""
|
||
if self._size and self._size + size > self._limit:
|
||
self._flush()
|
||
self._parts.append(raw)
|
||
self._size += size
|
||
|
||
def _flush(self) -> None:
|
||
"""
|
||
结算可独立解析的HTML块,并恢复下一块所需的标签上下文。
|
||
"""
|
||
closing = "".join(f"</{tag}>" for tag, _raw in reversed(self._stack))
|
||
self.chunks.append("".join(self._parts) + closing)
|
||
self._parts = [raw for _tag, raw in self._stack]
|
||
self._size = 0
|
||
|
||
def finish(self) -> List[str]:
|
||
"""
|
||
返回包含最后一块的完整结果,不制造只有重复标签的空块。
|
||
"""
|
||
self.close()
|
||
if self._size:
|
||
self._flush()
|
||
return self.chunks
|
||
|
||
|
||
class RetryException(Exception):
|
||
"""
|
||
Telegram 消息发送重试异常。
|
||
"""
|
||
|
||
pass
|
||
|
||
|
||
class NonRetryableTelegramError(ImmediateException):
|
||
"""
|
||
Telegram 明确拒绝的请求,重试不会改变结果。
|
||
|
||
内容非法、格式无法解析、方法不存在等 4xx 错误属于这一类:立即抛出以跳过
|
||
``retry`` 的退避等待,避免同一条坏消息连续阻塞 3 秒、6 秒后才报错。
|
||
"""
|
||
|
||
pass
|
||
|
||
|
||
def _is_non_retryable_api_error(err: Exception) -> bool:
|
||
"""
|
||
判断 Bot API 错误是否为重试无意义的 4xx。
|
||
|
||
5xx 与网络异常仍按原逻辑重试;400 是确定性拒绝,429 需要由调用方建立冷却,
|
||
两者都不适合立即重复请求。
|
||
"""
|
||
return isinstance(err, TelegramApiError) and err.error_code in (400, 429)
|
||
|
||
|
||
class Telegram:
|
||
"""
|
||
Telegram 消息客户端,负责发送、编辑、接收和转发 Telegram 消息。
|
||
"""
|
||
|
||
_bot: Optional[TelegramBotApi] = None
|
||
# 按回调命令保存自定义处理器
|
||
_callback_handlers: Dict[str, Callable[..., Any]] = {}
|
||
# 用于群聊提及识别的机器人用户名
|
||
_bot_username: Optional[str] = None
|
||
# 输入状态续发、首发延迟及各场景最长展示时间,单位均为秒
|
||
_typing_interval_seconds = 5
|
||
_typing_initial_delay_seconds = 1
|
||
_typing_max_duration_seconds = 10 * 60
|
||
_typing_command_max_duration_seconds = 30
|
||
_typing_callback_max_duration_seconds = 60
|
||
# 单个输入状态任务与更新分发的关闭等待秒数
|
||
_typing_join_timeout_seconds = 1
|
||
_shutdown_timeout_seconds = 10
|
||
# getUpdates 长轮询等待秒数;停止时不等待进行中的请求,因此不受关闭预算约束。
|
||
_long_polling_timeout_seconds = 30
|
||
|
||
def __init__(
|
||
self,
|
||
TELEGRAM_TOKEN: Optional[str] = None,
|
||
TELEGRAM_CHAT_ID: Optional[str] = None,
|
||
TELEGRAM_TOPIC_ID: Optional[str] = None,
|
||
**kwargs: Any,
|
||
) -> None:
|
||
"""
|
||
创建独立机器人客户端并启动长轮询接收线程。
|
||
|
||
:param TELEGRAM_TOKEN: 机器人令牌;令牌或默认会话缺失时不创建 API 客户端
|
||
:param TELEGRAM_CHAT_ID: 默认消息接收会话标识
|
||
:param TELEGRAM_TOPIC_ID: 默认会话的话题标识,必须为正十进制整数;无效时忽略
|
||
:param kwargs: 可选 API_URL 自建服务地址和 name 实例名称,其他配置当前不使用
|
||
"""
|
||
# 即使配置不完整也保留基础属性,便于测试和未启用实例安全调用发送方法。
|
||
self._telegram_token = TELEGRAM_TOKEN
|
||
self._telegram_chat_id = TELEGRAM_CHAT_ID
|
||
self._telegram_topic_id = None
|
||
if TELEGRAM_TOPIC_ID and str(TELEGRAM_TOPIC_ID).strip():
|
||
topic_id = str(TELEGRAM_TOPIC_ID).strip()
|
||
if topic_id.isdecimal() and int(topic_id) > 0:
|
||
self._telegram_topic_id = int(topic_id)
|
||
else:
|
||
logger.warning("Telegram话题ID无效,通知将发送到默认话题")
|
||
self._polling_thread = None
|
||
# 一个 Telegram 配置对应一个 SDK client,运行状态不能被其他配置共享。
|
||
self._user_chat_mapping: Dict[str, str] = {}
|
||
# 出站编辑使用独立状态,避免高频流式回调把限流放大为重复请求。
|
||
self._edit_state_lock = threading.RLock()
|
||
self._edit_next_allowed: Dict[tuple[str, int], float] = {}
|
||
self._chat_rate_limit_until: Dict[str, float] = {}
|
||
self._chat_rate_limit_skipped: Dict[str, int] = {}
|
||
self._chat_rate_limit_last_log: Dict[str, float] = {}
|
||
self._typing_tasks: Dict[str, threading.Thread] = {}
|
||
self._typing_stop_flags: Dict[str, threading.Event] = {}
|
||
self._typing_lock = threading.RLock()
|
||
self._typing_lifecycle_lock = threading.RLock()
|
||
self._typing_accepting = True
|
||
if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
|
||
logger.error("Telegram配置不完整!")
|
||
return
|
||
# Bot API 服务基址;自建地址不使用全局代理。
|
||
self._bot = TelegramBotApi(
|
||
TELEGRAM_TOKEN,
|
||
api_url=kwargs.get("API_URL"),
|
||
proxy=get_runtime_setting('PROXY'),
|
||
parse_mode=TELEGRAM_PARSE_MODE_MARKDOWN,
|
||
)
|
||
self._config_name = kwargs.get("name")
|
||
# 获取并存储bot用户名用于@检测
|
||
try:
|
||
self._bot_username = self._api.get_me().get("username")
|
||
logger.info(f"Telegram bot用户名: @{self._bot_username}")
|
||
except Exception as e:
|
||
logger.error(f"获取bot信息失败: {e}")
|
||
self._bot_username = None
|
||
|
||
self._polling_thread = threading.Thread(
|
||
target=self._run_polling,
|
||
name="MoviePilot-TelegramPolling",
|
||
daemon=True,
|
||
)
|
||
self._polling_thread.start()
|
||
logger.info("Telegram消息接收服务启动")
|
||
|
||
@property
|
||
def _api(self) -> TelegramBotApi:
|
||
"""
|
||
已初始化的 Bot API 客户端。
|
||
|
||
未配置或已停止时抛错,由各发送方法的异常处理记录失败,与历史实现访问空句柄的结果一致。
|
||
"""
|
||
if self._bot is None:
|
||
raise RuntimeError("Telegram客户端未初始化")
|
||
return self._bot
|
||
|
||
@staticmethod
|
||
def _find_api_error(err: BaseException) -> Optional[TelegramApiError]:
|
||
"""
|
||
沿异常链查找 Bot API 原始错误,保留装饰器包装后的 429 语义。
|
||
"""
|
||
current: Optional[BaseException] = err
|
||
seen: set[int] = set()
|
||
while current is not None and id(current) not in seen:
|
||
seen.add(id(current))
|
||
if isinstance(current, TelegramApiError):
|
||
return current
|
||
current = current.__cause__ or current.__context__
|
||
return None
|
||
|
||
@classmethod
|
||
def _retry_after_seconds(cls, err: BaseException) -> Optional[int]:
|
||
"""
|
||
读取 Telegram 429 的结构化或文本冷却秒数。
|
||
"""
|
||
api_error = cls._find_api_error(err)
|
||
if api_error is None or api_error.error_code != 429:
|
||
return None
|
||
retry_after = api_error.parameters.get("retry_after")
|
||
try:
|
||
if retry_after is not None and float(retry_after) > 0:
|
||
return max(1, int(float(retry_after)))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
matched = _TELEGRAM_RETRY_AFTER_PATTERN.search(api_error.description)
|
||
if matched:
|
||
return max(1, int(matched.group(1)))
|
||
return None
|
||
|
||
@classmethod
|
||
def _is_rate_limit_error(cls, err: BaseException) -> bool:
|
||
"""
|
||
判断异常链是否包含 Telegram 429。
|
||
"""
|
||
return cls._retry_after_seconds(err) is not None or (
|
||
(api_error := cls._find_api_error(err)) is not None
|
||
and api_error.error_code == 429
|
||
)
|
||
|
||
def _ensure_edit_state(self) -> None:
|
||
"""
|
||
为旧测试桩和未完整初始化实例补齐出站编辑状态。
|
||
"""
|
||
if hasattr(self, "_edit_state_lock"):
|
||
return
|
||
self._edit_state_lock = threading.RLock()
|
||
self._edit_next_allowed = {}
|
||
self._chat_rate_limit_until = {}
|
||
self._chat_rate_limit_skipped = {}
|
||
self._chat_rate_limit_last_log = {}
|
||
|
||
def _mark_rate_limit(
|
||
self,
|
||
chat_id: Union[str, int],
|
||
err: BaseException,
|
||
operation: str = "编辑",
|
||
) -> None:
|
||
"""
|
||
为聊天记录 Telegram 429 冷却,并输出一次可诊断的限流日志。
|
||
"""
|
||
self._ensure_edit_state()
|
||
retry_after = self._retry_after_seconds(err) or int(TELEGRAM_RATE_LIMIT_DEFAULT_SECONDS)
|
||
now = time.monotonic()
|
||
chat_key = str(chat_id)
|
||
with self._edit_state_lock:
|
||
previous_until = self._chat_rate_limit_until.get(chat_key, 0.0)
|
||
cooldown_until = max(
|
||
previous_until,
|
||
now + max(retry_after, 1),
|
||
)
|
||
self._chat_rate_limit_until[chat_key] = cooldown_until
|
||
if previous_until <= now:
|
||
self._chat_rate_limit_skipped[chat_key] = 0
|
||
self._chat_rate_limit_last_log[chat_key] = now
|
||
if cooldown_until > previous_until:
|
||
logger.warning(
|
||
"Telegram%s触发429,chat_id=%s,冷却%s秒%s",
|
||
operation,
|
||
chat_id,
|
||
retry_after,
|
||
"(按 retry_after)" if self._retry_after_seconds(err) else "(未提供 retry_after,使用默认值)",
|
||
)
|
||
|
||
def _is_chat_rate_limited(self, chat_id: Union[str, int], operation: str = "编辑") -> bool:
|
||
"""
|
||
检查聊天是否仍在 Telegram 429 冷却期,并限频记录跳过次数。
|
||
"""
|
||
self._ensure_edit_state()
|
||
now = time.monotonic()
|
||
chat_key = str(chat_id)
|
||
with self._edit_state_lock:
|
||
cooldown_until = self._chat_rate_limit_until.get(chat_key, 0.0)
|
||
if cooldown_until <= now:
|
||
if chat_key in self._chat_rate_limit_until:
|
||
skipped = self._chat_rate_limit_skipped.pop(chat_key, 0)
|
||
self._chat_rate_limit_until.pop(chat_key, None)
|
||
self._chat_rate_limit_last_log.pop(chat_key, None)
|
||
if skipped:
|
||
logger.info(
|
||
"Telegram%s限流冷却结束,chat_id=%s,期间跳过%s次请求",
|
||
operation,
|
||
chat_id,
|
||
skipped,
|
||
)
|
||
return False
|
||
skipped = self._chat_rate_limit_skipped.get(chat_key, 0) + 1
|
||
self._chat_rate_limit_skipped[chat_key] = skipped
|
||
last_log = self._chat_rate_limit_last_log.get(chat_key, 0.0)
|
||
if skipped == 1 or now - last_log >= TELEGRAM_RATE_LIMIT_LOG_INTERVAL_SECONDS:
|
||
self._chat_rate_limit_last_log[chat_key] = now
|
||
remaining = max(0, int(cooldown_until - now))
|
||
logger.warning(
|
||
"Telegram%s处于429冷却,跳过后续请求:chat_id=%s,剩余约%s秒,累计跳过%s次",
|
||
operation,
|
||
chat_id,
|
||
remaining,
|
||
skipped,
|
||
)
|
||
return True
|
||
|
||
def _reserve_edit(
|
||
self,
|
||
chat_id: Union[str, int],
|
||
message_id: Union[str, int],
|
||
wait_for_interval: bool = False,
|
||
) -> bool:
|
||
"""
|
||
原子预留一次消息编辑,执行聊天冷却和消息级最小间隔检查。
|
||
|
||
:param chat_id: 编辑目标会话标识
|
||
:param message_id: 编辑目标消息标识
|
||
:param wait_for_interval: 是否等待一次消息级间隔,最长两秒;聊天 429 冷却不会等待
|
||
:return: 成功预留编辑时返回 True,否则返回 False
|
||
"""
|
||
self._ensure_edit_state()
|
||
if self._is_chat_rate_limited(chat_id):
|
||
return False
|
||
key = (str(chat_id), int(message_id))
|
||
if wait_for_interval:
|
||
with self._edit_state_lock:
|
||
remaining = self._edit_next_allowed.get(key, 0.0) - time.monotonic()
|
||
if remaining > 0:
|
||
# 调用方在线程池内收尾;不占用事件循环,也不持锁等待或无限追赶并发编辑。
|
||
time.sleep(min(remaining, TELEGRAM_EDIT_MIN_INTERVAL_SECONDS))
|
||
if self._is_chat_rate_limited(chat_id):
|
||
return False
|
||
now = time.monotonic()
|
||
with self._edit_state_lock:
|
||
if self._edit_next_allowed.get(key, 0.0) > now:
|
||
return False
|
||
self._edit_next_allowed[key] = now + TELEGRAM_EDIT_MIN_INTERVAL_SECONDS
|
||
return True
|
||
|
||
def _mark_edit_succeeded(
|
||
self, chat_id: Union[str, int], message_id: Union[str, int]
|
||
) -> None:
|
||
"""
|
||
以实际完成时间重新计算消息编辑间隔,避免并发请求贴着结束时间发送。
|
||
"""
|
||
self._ensure_edit_state()
|
||
key = (str(chat_id), int(message_id))
|
||
with self._edit_state_lock:
|
||
self._edit_next_allowed[key] = (
|
||
time.monotonic() + TELEGRAM_EDIT_MIN_INTERVAL_SECONDS
|
||
)
|
||
|
||
def _remember_sent_message(self, sent: Any, chat_id: Union[str, int]) -> None:
|
||
"""
|
||
记录新消息的发送时间,使首个流式编辑也遵守最小间隔。
|
||
"""
|
||
self._ensure_edit_state()
|
||
if not isinstance(sent, dict) or not sent.get("message_id"):
|
||
return
|
||
target_chat_id = sent.get("chat", {}).get("id", chat_id)
|
||
key = (str(target_chat_id), int(sent["message_id"]))
|
||
with self._edit_state_lock:
|
||
self._edit_next_allowed[key] = (
|
||
time.monotonic() + TELEGRAM_EDIT_MIN_INTERVAL_SECONDS
|
||
)
|
||
|
||
def _run_polling(self) -> None:
|
||
"""
|
||
轮询线程入口,逐条处理 getUpdates 返回的更新。
|
||
"""
|
||
try:
|
||
self._api.polling(
|
||
self._handle_update,
|
||
long_polling_timeout=self._long_polling_timeout_seconds,
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"Telegram消息接收服务异常:{str(err)}")
|
||
|
||
def _handle_update(self, update: JsonDict) -> None:
|
||
"""
|
||
按更新类型分发:普通消息转发主程序,按钮回调先应答再转发。
|
||
"""
|
||
if "callback_query" in update:
|
||
self._handle_callback_query(update["callback_query"])
|
||
return
|
||
message = update.get("message")
|
||
if not message or not any(key in message for key in FORWARDED_CONTENT_TYPES):
|
||
return
|
||
if self._reply_welcome_if_needed(message):
|
||
return
|
||
sender = message.get("from") or {}
|
||
self._update_user_chat_mapping(sender.get("id"), message["chat"]["id"])
|
||
if self._should_process_message(message):
|
||
if not self._forward_to_message_chain(message):
|
||
logger.warn("Telegram消息转发失败")
|
||
|
||
def _reply_welcome_if_needed(self, message: JsonDict) -> bool:
|
||
"""
|
||
/start、/help 命令只回复使用提示,不转发主程序。
|
||
|
||
与原 SDK 命令过滤一致:忽略 @bot 后缀和参数,例如 ``/start@mp_bot payload``。
|
||
"""
|
||
text = message.get("text") or ""
|
||
if not text.startswith("/"):
|
||
return False
|
||
command = text.split(maxsplit=1)[0][1:].split("@", 1)[0]
|
||
if command not in ("start", "help"):
|
||
return False
|
||
self._api.send_message(
|
||
chat_id=message["chat"]["id"],
|
||
text="温馨提示:直接发送名称或`订阅`+名称,搜索或订阅电影、电视剧",
|
||
reply_to_message_id=message["message_id"],
|
||
)
|
||
return True
|
||
|
||
def _handle_callback_query(self, call: JsonDict) -> None:
|
||
"""
|
||
处理按钮点击回调。
|
||
"""
|
||
try:
|
||
# 按钮回调也更新用户到最近会话的映射
|
||
chat_id = call["message"]["chat"]["id"]
|
||
self._update_user_chat_mapping(call["from"]["id"], chat_id)
|
||
|
||
callback_data = call.get("data")
|
||
user_id = str(call["from"]["id"])
|
||
|
||
logger.info(f"收到按钮回调:{callback_data},用户:{user_id}")
|
||
|
||
callback_json = {
|
||
"callback_query": {
|
||
"id": call["id"],
|
||
"from": call["from"],
|
||
"message": {
|
||
"message_id": call["message"]["message_id"],
|
||
"chat": {
|
||
"id": chat_id,
|
||
},
|
||
},
|
||
"data": callback_data,
|
||
}
|
||
}
|
||
|
||
# 先确认回调,避免用户看到loading状态
|
||
self._api.answer_callback_query(call["id"])
|
||
|
||
if not self._forward_to_message_chain(callback_json):
|
||
logger.warn("Telegram按钮回调转发失败")
|
||
|
||
except Exception as err:
|
||
logger.error(f"处理按钮回调失败:{str(err)}")
|
||
self._api.answer_callback_query(call["id"], "处理失败,请重试")
|
||
|
||
def _forward_to_message_chain(self, payload: dict[str, Any]) -> bool:
|
||
"""
|
||
把 Telegram SDK 回调同步转交统一消息入口。
|
||
"""
|
||
return forward_message_to_host(payload, self._config_name)
|
||
|
||
@property
|
||
def bot(self) -> Optional[TelegramBotApi]:
|
||
"""
|
||
获取Telegram Bot实例。
|
||
|
||
:return: Bot API 客户端或None
|
||
"""
|
||
return self._bot
|
||
|
||
@property
|
||
def bot_username(self) -> Optional[str]:
|
||
"""
|
||
获取Bot用户名。
|
||
|
||
:return: Bot用户名或None
|
||
"""
|
||
return self._bot_username
|
||
|
||
def download_file(self, file_id: str) -> Optional[bytes]:
|
||
"""
|
||
查询文件路径并下载 Telegram 媒体内容。
|
||
|
||
:param file_id: Telegram 分配的文件标识
|
||
:return: 下载内容字节;未初始化、参数缺失或下载失败返回 None
|
||
"""
|
||
if not self._bot:
|
||
return None
|
||
try:
|
||
file_path = self._api.get_file(file_id).get("file_path")
|
||
if not file_path:
|
||
logger.warn(f"Telegram文件信息缺少下载路径: file_id={file_id}")
|
||
return None
|
||
file_url = self._api.file_url(file_path)
|
||
resp = RequestUtils(
|
||
proxies=self._api.proxy, timeout=30
|
||
).get_res(file_url)
|
||
if resp and resp.content:
|
||
logger.info(
|
||
"Telegram文件下载成功: file_id=%s, file_path=%s, content_bytes=%s",
|
||
file_id,
|
||
file_path,
|
||
len(resp.content),
|
||
)
|
||
return cast(bytes, resp.content)
|
||
logger.warn(
|
||
"Telegram文件下载失败: file_id=%s, file_path=%s, proxy_enabled=%s",
|
||
file_id,
|
||
file_path,
|
||
bool(self._api.proxy),
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"下载Telegram文件失败: {e}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def _telegramify_item_text(item: Text) -> str:
|
||
"""
|
||
将 telegramify 文本片段转换为 Telegram MarkdownV2 字符串。
|
||
"""
|
||
if entities_to_markdownv2:
|
||
return cast(str, entities_to_markdownv2(item.text, item.entities))
|
||
return cast(str, standardize(item.text))
|
||
|
||
@staticmethod
|
||
def _telegramify_item_caption(item: Text | File | Photo) -> str:
|
||
"""
|
||
将 telegramify 文本或媒体片段转换为 Telegram MarkdownV2 caption。
|
||
"""
|
||
if isinstance(item, Text):
|
||
return Telegram._telegramify_item_text(item)
|
||
if entities_to_markdownv2:
|
||
return cast(str, entities_to_markdownv2(item.caption_text, item.caption_entities))
|
||
return cast(str, standardize(item.caption_text))
|
||
|
||
@staticmethod
|
||
def _normalize_parse_mode(parse_mode: Optional[str] = None) -> str:
|
||
"""
|
||
规范化 Telegram 消息格式类型。
|
||
"""
|
||
if parse_mode is None:
|
||
return TELEGRAM_PARSE_MODE_MARKDOWN
|
||
normalized = str(parse_mode).strip()
|
||
if not normalized:
|
||
return TELEGRAM_PARSE_MODE_PLAIN
|
||
return TELEGRAM_PARSE_MODE_ALIASES.get(
|
||
normalized.lower(), TELEGRAM_PARSE_MODE_MARKDOWN
|
||
)
|
||
|
||
@staticmethod
|
||
def _is_html_parse_mode(parse_mode: Optional[str] = None) -> bool:
|
||
"""
|
||
判断本次发送是否使用 Telegram HTML 格式。
|
||
"""
|
||
return Telegram._normalize_parse_mode(parse_mode) == TELEGRAM_PARSE_MODE_HTML
|
||
|
||
@staticmethod
|
||
def _is_plain_parse_mode(parse_mode: Optional[str] = None) -> bool:
|
||
"""
|
||
判断本次发送是否禁用 Telegram 文本格式解析。
|
||
"""
|
||
return Telegram._normalize_parse_mode(parse_mode) == TELEGRAM_PARSE_MODE_PLAIN
|
||
|
||
@staticmethod
|
||
def _format_title(title: Optional[str], parse_mode: Optional[str] = None) -> Optional[str]:
|
||
"""
|
||
按 parse_mode 生成 Telegram 标题文本。
|
||
"""
|
||
if not title:
|
||
return None
|
||
if Telegram._is_plain_parse_mode(parse_mode):
|
||
return title.removesuffix("\n")
|
||
if Telegram._is_html_parse_mode(parse_mode):
|
||
return f"<b>{html_utils.escape(title).removesuffix(chr(10))}</b>"
|
||
return f"**{standardize(title).removesuffix(chr(10))}**"
|
||
|
||
@staticmethod
|
||
def _format_link(label: str, link: str, parse_mode: Optional[str] = None) -> str:
|
||
"""
|
||
按 parse_mode 生成 Telegram 链接文本。
|
||
"""
|
||
if Telegram._is_plain_parse_mode(parse_mode):
|
||
return f"{label}: {link}"
|
||
if Telegram._is_html_parse_mode(parse_mode):
|
||
return (
|
||
f'<a href="{html_utils.escape(link, quote=True)}">'
|
||
f"{html_utils.escape(label)}</a>"
|
||
)
|
||
return f"[{label}]({link})"
|
||
|
||
@staticmethod
|
||
def _format_italic(text: str, parse_mode: Optional[str] = None) -> str:
|
||
"""
|
||
按 parse_mode 生成 Telegram 斜体文本。
|
||
"""
|
||
if Telegram._is_plain_parse_mode(parse_mode):
|
||
return text
|
||
if Telegram._is_html_parse_mode(parse_mode):
|
||
return f"<i>{html_utils.escape(text)}</i>"
|
||
return f"_{text}_"
|
||
|
||
@staticmethod
|
||
def _format_detail_link(link: str, parse_mode: Optional[str] = None) -> str:
|
||
"""
|
||
按 parse_mode 生成查看详情链接。
|
||
"""
|
||
return Telegram._format_link("查看详情", link, parse_mode)
|
||
|
||
@staticmethod
|
||
def _prepare_text(text: Optional[str], parse_mode: Optional[str] = None) -> Optional[str]:
|
||
"""
|
||
按 parse_mode 生成 Telegram 可发送文本。
|
||
"""
|
||
if not text:
|
||
return None
|
||
if (
|
||
Telegram._is_plain_parse_mode(parse_mode)
|
||
or Telegram._is_html_parse_mode(parse_mode)
|
||
):
|
||
return text
|
||
return cast(str, standardize(text))
|
||
|
||
@staticmethod
|
||
def _split_plain_text(text: str, limit: int) -> List[str]:
|
||
"""
|
||
按 Telegram 长度限制拆分普通文本。
|
||
"""
|
||
if not text:
|
||
return []
|
||
if limit <= 0:
|
||
return [text]
|
||
chunks = []
|
||
remaining = text
|
||
while remaining:
|
||
if len(remaining) <= limit:
|
||
chunks.append(remaining)
|
||
break
|
||
split_at = remaining.rfind("\n", 0, limit)
|
||
if split_at <= 0:
|
||
split_at = limit
|
||
chunks.append(remaining[:split_at])
|
||
remaining = remaining[split_at:].lstrip("\n")
|
||
return chunks
|
||
|
||
def _update_user_chat_mapping(
|
||
self, userid: Optional[Union[str, int]], chat_id: Optional[Union[str, int]]
|
||
) -> None:
|
||
"""
|
||
更新用户与聊天的映射关系。
|
||
|
||
:param userid: 用户ID
|
||
:param chat_id: 聊天ID
|
||
"""
|
||
if userid and chat_id:
|
||
self._user_chat_mapping[str(userid)] = str(chat_id)
|
||
|
||
def _get_user_chat_id(self, userid: str) -> Optional[str]:
|
||
"""
|
||
获取用户对应的聊天ID。
|
||
|
||
:param userid: 用户ID
|
||
:return: 聊天ID或None
|
||
"""
|
||
return self._user_chat_mapping.get(str(userid)) if userid else None
|
||
|
||
def _should_process_message(self, message: JsonDict) -> bool:
|
||
"""
|
||
判断是否应该处理这条消息。
|
||
|
||
:param message: Bot API Message 结构
|
||
:return: 是否处理
|
||
"""
|
||
chat_type = message["chat"].get("type")
|
||
# 私聊消息总是处理
|
||
if chat_type == "private":
|
||
logger.debug(f"处理私聊消息:用户 {(message.get('from') or {}).get('id')}")
|
||
return True
|
||
|
||
# 消息内容:文本消息用 text,媒体消息的说明文字用 caption
|
||
message_text = message.get("text") or message.get("caption") or ""
|
||
|
||
# 群聊中的命令消息总是处理(以/开头)
|
||
if message_text.startswith("/"):
|
||
logger.debug(f"处理群聊命令消息:{message_text[:20]}...")
|
||
return True
|
||
|
||
# 群聊中检查是否@了机器人
|
||
if chat_type in ["group", "supergroup"]:
|
||
if not self._bot_username:
|
||
# 未获取机器人用户名时无法匹配提及,沿用兼容行为接收该群全部消息
|
||
logger.debug("未获取到bot用户名,处理所有群聊消息")
|
||
return True
|
||
|
||
# 检查消息文本或说明文字中是否包含@bot_username
|
||
if f"@{self._bot_username}" in message_text:
|
||
logger.debug(f"检测到@{self._bot_username},处理群聊消息")
|
||
return True
|
||
|
||
# 检查消息实体(文本消息用 entities,媒体消息用 caption_entities)
|
||
entities = (
|
||
message.get("entities")
|
||
if message.get("text") is not None
|
||
else message.get("caption_entities")
|
||
) or []
|
||
for entity in entities:
|
||
if entity.get("type") == "mention":
|
||
# Telegram offset/length 基于 UTF-16 代码单元,需用字节编码处理含 emoji 的消息
|
||
offset, length = entity["offset"], entity["length"]
|
||
mention_text = message_text.encode('utf-16-le')[
|
||
offset * 2: (offset + length) * 2
|
||
].decode('utf-16-le')
|
||
if mention_text == f"@{self._bot_username}":
|
||
logger.debug(
|
||
f"通过实体检测到@{self._bot_username},处理群聊消息"
|
||
)
|
||
return True
|
||
|
||
# 群聊中没有@机器人,不处理
|
||
logger.debug(
|
||
f"群聊消息未@机器人,跳过处理:{message_text[:30] if message_text else 'No text'}..."
|
||
)
|
||
return False
|
||
|
||
# 其他类型的聊天默认处理
|
||
logger.debug(f"处理其他类型聊天消息:{chat_type}")
|
||
return True
|
||
|
||
def get_state(self) -> bool:
|
||
"""
|
||
检查当前实例是否持有 Bot API 客户端。
|
||
|
||
:return: 客户端存在时返回 True;不额外检查网络或轮询健康状态
|
||
"""
|
||
return self._bot is not None
|
||
|
||
def _start_typing_task(
|
||
self,
|
||
chat_id: Union[str, int],
|
||
max_duration_seconds: Optional[float] = None,
|
||
initial_delay_seconds: Optional[float] = None,
|
||
) -> bool:
|
||
"""
|
||
启动一个会话独占的输入状态续发任务。
|
||
|
||
:param chat_id: 需要展示输入状态的会话标识
|
||
:param max_duration_seconds: 最长续发秒数;未提供或为 0 时使用 600 秒
|
||
:param initial_delay_seconds: 首发延迟秒数;None 使用 1 秒,负数按 0 处理
|
||
:return: 成功启动返回 True;客户端停止接收任务或旧任务未退出时返回 False
|
||
"""
|
||
chat_id_str = str(chat_id)
|
||
with self._typing_lifecycle_lock:
|
||
if not self._typing_accepting:
|
||
logger.debug("Telegram client已停止,拒绝启动typing任务")
|
||
return False
|
||
# 如果已有任务在运行,先停止;阻塞的旧 SDK 请求不能被新 owner 覆盖。
|
||
if not self._stop_typing_task(chat_id_str):
|
||
logger.warning(
|
||
"Telegram typing旧任务尚未结束,拒绝并行启动: chat_id=%s",
|
||
chat_id_str,
|
||
)
|
||
return False
|
||
|
||
# 使用独立 Event 避免同一 chat 新旧 typing 线程互相误改停止标记。
|
||
stop_event = threading.Event()
|
||
max_duration = max_duration_seconds or self._typing_max_duration_seconds
|
||
initial_delay = (
|
||
self._typing_initial_delay_seconds
|
||
if initial_delay_seconds is None
|
||
else max(initial_delay_seconds, 0)
|
||
)
|
||
|
||
def typing_worker() -> None:
|
||
"""
|
||
延迟首发并定期发送 typing 状态的后台线程。
|
||
"""
|
||
started_at = time.monotonic()
|
||
try:
|
||
# Telegram 没有撤销 typing 的接口,短响应先等待一小段时间,
|
||
# 避免回复已经发出后客户端仍残留几秒“正在输入”。
|
||
if initial_delay and stop_event.wait(initial_delay):
|
||
return
|
||
while not stop_event.is_set():
|
||
if time.monotonic() - started_at >= max_duration:
|
||
logger.warning(
|
||
"Telegram typing状态超过最大续期,自动停止: chat_id=%s",
|
||
chat_id_str,
|
||
)
|
||
break
|
||
try:
|
||
if self._bot:
|
||
self._api.send_chat_action(chat_id, "typing")
|
||
except Exception as e:
|
||
logger.debug(f"发送typing状态失败: {e}")
|
||
# Telegram 客户端约 5-6 秒后会隐藏 typing,需要周期性续发。
|
||
stop_event.wait(self._typing_interval_seconds)
|
||
finally:
|
||
with self._typing_lock:
|
||
current = self._typing_tasks.get(chat_id_str)
|
||
if current is threading.current_thread():
|
||
self._typing_tasks.pop(chat_id_str, None)
|
||
self._typing_stop_flags.pop(chat_id_str, None)
|
||
|
||
thread = threading.Thread(
|
||
target=typing_worker,
|
||
name=f"MoviePilot-TelegramTyping-{chat_id_str}"[:120],
|
||
daemon=True,
|
||
)
|
||
with self._typing_lock:
|
||
self._typing_stop_flags[chat_id_str] = stop_event
|
||
self._typing_tasks[chat_id_str] = thread
|
||
try:
|
||
thread.start()
|
||
except BaseException:
|
||
self._typing_stop_flags.pop(chat_id_str, None)
|
||
self._typing_tasks.pop(chat_id_str, None)
|
||
raise
|
||
return True
|
||
|
||
def _stop_typing_task(self, chat_id: Union[str, int]) -> bool:
|
||
"""
|
||
请求结束指定会话的输入状态任务,并最多等待 1 秒。
|
||
|
||
:param chat_id: 目标会话标识
|
||
:return: 任务已退出或不存在时返回 True;仍运行时返回 False 并保留任务引用
|
||
"""
|
||
chat_id_str = str(chat_id)
|
||
with self._typing_lifecycle_lock:
|
||
with self._typing_lock:
|
||
stop_event = self._typing_stop_flags.get(chat_id_str)
|
||
task = self._typing_tasks.get(chat_id_str)
|
||
if stop_event:
|
||
stop_event.set()
|
||
if task and task.is_alive() and task is not threading.current_thread():
|
||
task.join(timeout=self._typing_join_timeout_seconds)
|
||
task_finished = task is None or not task.is_alive()
|
||
if task_finished:
|
||
with self._typing_lock:
|
||
if self._typing_tasks.get(chat_id_str) is task:
|
||
self._typing_tasks.pop(chat_id_str, None)
|
||
self._typing_stop_flags.pop(chat_id_str, None)
|
||
return task_finished
|
||
|
||
def _stop_typing_if_needed(
|
||
self, chat_id: Union[str, int], stop_typing: bool
|
||
) -> None:
|
||
"""
|
||
按调用方要求停止 typing。
|
||
typing 由消息处理状态统一收口,兼容显式要求立即停止的调用。
|
||
"""
|
||
if stop_typing:
|
||
self._stop_typing_task(chat_id)
|
||
|
||
def start_typing(
|
||
self,
|
||
chat_id: Optional[Union[str, int]] = None,
|
||
userid: Optional[Union[str, int]] = None,
|
||
) -> bool:
|
||
"""
|
||
根据会话或用户启动输入状态续发。
|
||
|
||
:param chat_id: 显式会话标识,优先于用户映射
|
||
:param userid: 无显式会话时先查最近会话,再使用用户标识作为私聊目标
|
||
:return: 任务启动成功返回 True;无目标或启动被拒绝返回 False
|
||
"""
|
||
if chat_id:
|
||
target_chat_id = chat_id
|
||
elif userid:
|
||
target_chat_id = self._get_user_chat_id(str(userid)) or str(userid)
|
||
else:
|
||
target_chat_id = None
|
||
target_chat_id = target_chat_id or (str(userid) if userid else None)
|
||
if not target_chat_id:
|
||
return False
|
||
return self._start_typing_task(target_chat_id)
|
||
|
||
def stop_typing(
|
||
self,
|
||
chat_id: Optional[Union[str, int]] = None,
|
||
userid: Optional[Union[str, int]] = None,
|
||
) -> bool:
|
||
"""
|
||
根据会话或用户请求停止输入状态续发。
|
||
|
||
:param chat_id: 显式会话标识,优先于用户映射
|
||
:param userid: 无显式会话时先查最近会话,再使用用户标识作为私聊目标
|
||
:return: 找到目标并请求停止返回 True;无目标返回 False,True 不保证任务已经退出
|
||
"""
|
||
if chat_id:
|
||
target_chat_id = chat_id
|
||
elif userid:
|
||
target_chat_id = self._get_user_chat_id(str(userid)) or str(userid)
|
||
else:
|
||
target_chat_id = None
|
||
target_chat_id = target_chat_id or (str(userid) if userid else None)
|
||
if not target_chat_id:
|
||
return False
|
||
self._stop_typing_task(target_chat_id)
|
||
return True
|
||
|
||
def send_msg(
|
||
self,
|
||
title: Optional[str],
|
||
text: Optional[str] = None,
|
||
image: Optional[str] = None,
|
||
userid: Optional[str] = None,
|
||
link: Optional[str] = None,
|
||
buttons: Optional[List[List[dict[str, Any]]]] = None,
|
||
force_reply: bool = False,
|
||
original_message_id: Optional[int] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
disable_web_page_preview: Optional[bool] = None,
|
||
stop_typing: bool = False,
|
||
parse_mode: Optional[str] = None,
|
||
rich_message: Optional[str] = None,
|
||
private_delivery: bool = False,
|
||
) -> Optional[JsonDict]:
|
||
"""
|
||
发送文本、图文或富文本,并兼容原消息编辑和强制回复。
|
||
|
||
:param title: 可选消息标题,与正文和富文本不能同时为空
|
||
:param text: 普通消息正文
|
||
:param image: 图文封面地址,富文本模式下不使用
|
||
:param userid: 接收用户标识;默认使用该用户最近会话,无记录时用用户标识,未指定时用默认会话
|
||
:param link: 普通正文末尾的详情地址
|
||
:param buttons: 按行排列的链接或回调按钮;优先于强制回复键盘
|
||
:param force_reply: 是否显示强制回复键盘;与原消息上下文结合时发送新回复
|
||
:param original_message_id: 与原会话同时提供时编辑原消息;无按钮的强制回复模式改为回复原消息
|
||
:param original_chat_id: 显式原会话标识,优先于普通用户映射
|
||
:param disable_web_page_preview: 是否禁用普通文本链接预览
|
||
:param stop_typing: 是否在操作收尾时请求停止输入状态,默认 False
|
||
:param parse_mode: 消息格式,默认 MarkdownV2;支持 HTML 和 plain 纯文本
|
||
:param rich_message: 完整富文本正文;非空时替代普通图文内容
|
||
:param private_delivery: 有用户标识时是否绕过最近会话映射直接投递私聊
|
||
:return: 含 success 和消息引用的结果;配置缺失返回 None,限流、空内容或发送失败返回 success 为 False 的字典
|
||
"""
|
||
if not self._telegram_token or not self._telegram_chat_id:
|
||
return None
|
||
|
||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||
# 兼容原会话回复、最近互动会话及显式私聊投递
|
||
chat_id = self._determine_target_chat_id(
|
||
userid,
|
||
original_chat_id,
|
||
private_delivery=private_delivery,
|
||
)
|
||
if self._is_chat_rate_limited(chat_id, operation="发送"):
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return {"success": False}
|
||
if not title and not text and not rich_message:
|
||
logger.warn("标题、内容和富文本内容不能同时为空")
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return {"success": False}
|
||
|
||
try:
|
||
bold_title = self._format_title(title, parse_mode)
|
||
if bold_title and text:
|
||
caption = f"{bold_title}\n{text}"
|
||
elif bold_title:
|
||
caption = bold_title
|
||
elif text:
|
||
caption = text
|
||
else:
|
||
caption = ""
|
||
|
||
if link:
|
||
caption = f"{caption}\n{self._format_detail_link(link, parse_mode)}"
|
||
|
||
reply_markup = None
|
||
if buttons:
|
||
reply_markup = self._create_inline_keyboard(buttons)
|
||
elif force_reply:
|
||
reply_markup = self._create_force_reply_markup()
|
||
|
||
if rich_message:
|
||
return self._send_rich_message(
|
||
chat_id=chat_id,
|
||
text=text,
|
||
buttons=buttons,
|
||
force_reply=force_reply,
|
||
original_message_id=original_message_id,
|
||
original_chat_id=original_chat_id,
|
||
stop_typing=stop_typing,
|
||
rich_message=rich_message,
|
||
reply_markup=reply_markup,
|
||
)
|
||
|
||
if original_message_id and original_chat_id:
|
||
if force_reply and reply_markup and not buttons:
|
||
sent = self.__send_request(
|
||
userid=original_chat_id,
|
||
image=image,
|
||
caption=caption,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
parse_mode=parse_mode,
|
||
reply_to_message_id=original_message_id,
|
||
)
|
||
self._remember_sent_message(sent, original_chat_id)
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return self._sent_result(sent, chat_id)
|
||
result = self.__edit_message(
|
||
original_chat_id,
|
||
original_message_id,
|
||
caption,
|
||
buttons,
|
||
image,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
parse_mode=parse_mode,
|
||
)
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return {
|
||
"success": bool(result),
|
||
"message_id": original_message_id,
|
||
"chat_id": original_chat_id,
|
||
}
|
||
else:
|
||
sent = self.__send_request(
|
||
userid=chat_id,
|
||
image=image,
|
||
caption=caption,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
parse_mode=parse_mode,
|
||
)
|
||
self._remember_sent_message(sent, chat_id)
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return self._sent_result(sent, chat_id)
|
||
|
||
except Exception as msg_e:
|
||
logger.error(f"发送消息失败:{msg_e}")
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return {"success": False}
|
||
|
||
def _send_rich_message(
|
||
self,
|
||
*,
|
||
chat_id: Union[str, int],
|
||
text: Optional[str],
|
||
buttons: Optional[List[List[Dict[str, Any]]]],
|
||
force_reply: bool,
|
||
original_message_id: Optional[int],
|
||
original_chat_id: Optional[str],
|
||
stop_typing: bool,
|
||
rich_message: str,
|
||
reply_markup: Optional[JsonDict],
|
||
) -> JsonDict:
|
||
"""
|
||
发送或编辑 Rich Message,并在限流时隔离后续输出请求。
|
||
"""
|
||
if original_message_id and original_chat_id and not force_reply:
|
||
result = self.edit_msg(
|
||
chat_id=original_chat_id,
|
||
message_id=original_message_id,
|
||
text=text or "",
|
||
rich_message=rich_message,
|
||
buttons=buttons,
|
||
stop_typing=stop_typing,
|
||
)
|
||
return {
|
||
"success": bool(result),
|
||
"message_id": original_message_id,
|
||
"chat_id": original_chat_id,
|
||
}
|
||
|
||
target_chat_id = original_chat_id if force_reply and original_chat_id else chat_id
|
||
try:
|
||
sent = self.__send_rich_message(
|
||
chat_id=target_chat_id,
|
||
rich_message=rich_message,
|
||
reply_markup=reply_markup,
|
||
reply_to_message_id=original_message_id if force_reply else None,
|
||
)
|
||
except Exception as rich_err:
|
||
if self._is_rate_limit_error(rich_err):
|
||
self._mark_rate_limit(target_chat_id, rich_err, operation="发送")
|
||
sent = None
|
||
else:
|
||
# Rich Message 发送失败不再等同于通知丢失,降级为纯文本重发一次。
|
||
logger.warning(
|
||
f"Telegram Rich Message 发送失败,降级为纯文本:{rich_err}"
|
||
)
|
||
sent = self.__send_plain_fallback(
|
||
chat_id=target_chat_id,
|
||
rich_message=rich_message,
|
||
reply_markup=reply_markup,
|
||
)
|
||
self._remember_sent_message(sent, target_chat_id)
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return self._sent_result(sent, chat_id)
|
||
|
||
@staticmethod
|
||
def _sent_result(sent: Any, chat_id: Union[str, int]) -> JsonDict:
|
||
"""
|
||
把 Bot API 返回的 Message 转为发送结果;非 Message 的真值只表示成功。
|
||
"""
|
||
if isinstance(sent, dict) and "message_id" in sent:
|
||
return {
|
||
"success": True,
|
||
"message_id": sent["message_id"],
|
||
"chat_id": sent.get("chat", {}).get("id", chat_id),
|
||
}
|
||
return {"success": bool(sent)}
|
||
|
||
@staticmethod
|
||
def _create_force_reply_markup() -> JsonDict:
|
||
"""
|
||
生成只对被回复用户生效的 ForceReply 标记。
|
||
"""
|
||
return {
|
||
"force_reply": True,
|
||
"selective": True,
|
||
"input_field_placeholder": "请输入内容",
|
||
}
|
||
|
||
def send_voice(
|
||
self,
|
||
voice_path: str,
|
||
userid: Optional[str] = None,
|
||
caption: Optional[str] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
stop_typing: bool = False,
|
||
parse_mode: Optional[str] = None,
|
||
) -> Optional[JsonDict]:
|
||
"""
|
||
发送本地语音,进入发送流程后清理源文件。
|
||
|
||
:param voice_path: 语音文件路径;发送成功或失败都会尝试删除
|
||
:param userid: 接收用户标识;默认使用该用户最近会话,无记录时用用户标识,未指定时用默认会话
|
||
:param caption: 可选语音说明
|
||
:param original_chat_id: 显式原会话标识,优先于普通用户映射
|
||
:param stop_typing: 是否在操作收尾时请求停止输入状态,默认 False
|
||
:param parse_mode: 消息格式,默认 MarkdownV2;支持 HTML 和 plain 纯文本
|
||
:return: 含 success、message_id 和 chat_id 的结果;失败为 success 为 False 的字典,未配置或无输入路径返回 None
|
||
"""
|
||
if not self._bot or not voice_path:
|
||
return None
|
||
|
||
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||
voice_file = Path(voice_path)
|
||
if not voice_file.exists():
|
||
logger.error(f"语音文件不存在: {voice_file}")
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return {"success": False}
|
||
|
||
try:
|
||
with voice_file.open("rb") as fp:
|
||
sent = self._api.send_voice(
|
||
chat_id=chat_id,
|
||
voice=fp,
|
||
caption=self._prepare_text(caption, parse_mode),
|
||
parse_mode=parse_mode if caption else None,
|
||
**self._topic_kwargs(chat_id),
|
||
)
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return self._sent_result(sent, chat_id)
|
||
except Exception as err:
|
||
logger.error(f"发送语音消息失败:{err}")
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return {"success": False}
|
||
finally:
|
||
try:
|
||
voice_file.unlink(missing_ok=True)
|
||
except Exception as cleanup_err:
|
||
logger.debug(f"清理语音临时文件失败: {cleanup_err}")
|
||
|
||
def send_file(
|
||
self,
|
||
file_path: str,
|
||
userid: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
text: Optional[str] = None,
|
||
file_name: Optional[str] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
stop_typing: bool = False,
|
||
parse_mode: Optional[str] = None,
|
||
) -> Optional[JsonDict]:
|
||
"""
|
||
根据后缀发送本地图片或普通附件,保留源文件。
|
||
|
||
:param file_path: 本地普通文件路径
|
||
:param userid: 接收用户标识;默认使用该用户最近会话,无记录时用用户标识,未指定时用默认会话
|
||
:param title: 可选附件说明标题
|
||
:param text: 可选附件说明正文
|
||
:param file_name: 普通附件的显示名称;为空时使用源文件名,图片不使用此值
|
||
:param original_chat_id: 显式原会话标识,优先于普通用户映射
|
||
:param stop_typing: 是否在操作收尾时请求停止输入状态,默认 False
|
||
:param parse_mode: 消息格式,默认 MarkdownV2;支持 HTML 和 plain 纯文本
|
||
:return: 含 success、message_id 和 chat_id 的结果;失败为 success 为 False 的字典,未配置或无输入路径返回 None
|
||
"""
|
||
if not self._bot or not file_path:
|
||
return None
|
||
|
||
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||
local_file = Path(file_path)
|
||
if not local_file.exists() or not local_file.is_file():
|
||
logger.error(f"附件文件不存在: {local_file}")
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return {"success": False}
|
||
|
||
send_name = file_name or local_file.name
|
||
suffix = local_file.suffix.lower()
|
||
is_image = suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
||
|
||
try:
|
||
bold_title = self._format_title(title, parse_mode)
|
||
if bold_title and text:
|
||
caption = f"{bold_title}\n{text}"
|
||
elif bold_title:
|
||
caption = bold_title
|
||
else:
|
||
caption = text or ""
|
||
|
||
with local_file.open("rb") as fp:
|
||
if is_image:
|
||
sent = self._api.send_photo(
|
||
chat_id=chat_id,
|
||
photo=fp,
|
||
caption=self._prepare_text(caption, parse_mode),
|
||
parse_mode=parse_mode if caption else None,
|
||
**self._topic_kwargs(chat_id),
|
||
)
|
||
else:
|
||
sent = self._api.send_document(
|
||
chat_id=chat_id,
|
||
document=(send_name, fp),
|
||
caption=self._prepare_text(caption, parse_mode),
|
||
parse_mode=parse_mode if caption else None,
|
||
**self._topic_kwargs(chat_id),
|
||
)
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return self._sent_result(sent, chat_id)
|
||
except Exception as err:
|
||
logger.error(f"发送本地附件失败: {err}")
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
return {"success": False}
|
||
|
||
def _determine_target_chat_id(
|
||
self,
|
||
userid: Optional[str] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
private_delivery: bool = False,
|
||
) -> str:
|
||
"""
|
||
按私聊约束、原会话、用户映射和默认配置解析发送目标。
|
||
|
||
:param userid: 接收用户标识;默认使用该用户最近会话,无记录时用用户标识,未指定时用默认会话
|
||
:param original_chat_id: 显式原会话标识,优先于普通用户映射
|
||
:param private_delivery: 有用户标识时是否强制直接以该用户作为私聊目标
|
||
:return: 目标会话标识;无用户和原会话时使用配置中的默认值
|
||
"""
|
||
# 私聊投递以渠道用户 ID 为目标,最近会话映射可能指向群聊,不能参与解析。
|
||
if private_delivery and userid:
|
||
return str(userid)
|
||
|
||
if original_chat_id:
|
||
return original_chat_id
|
||
|
||
if userid:
|
||
mapped_chat_id = self._get_user_chat_id(userid)
|
||
if mapped_chat_id:
|
||
return mapped_chat_id
|
||
# 如果映射中没有,回退到使用userid作为聊天ID (私聊场景)
|
||
return userid
|
||
|
||
return self._telegram_chat_id or ""
|
||
|
||
def _topic_kwargs(self, chat_id: Optional[Union[str, int]]) -> dict[str, int]:
|
||
"""
|
||
仅向本渠道配置的群组投递话题参数,避免影响私聊和其它会话。
|
||
"""
|
||
topic_id = getattr(self, "_telegram_topic_id", None)
|
||
if topic_id and chat_id is not None and str(chat_id) == str(self._telegram_chat_id):
|
||
return {"message_thread_id": topic_id}
|
||
return {}
|
||
|
||
def send_medias_msg(
|
||
self,
|
||
medias: List[MediaInfo],
|
||
userid: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
link: Optional[str] = None,
|
||
buttons: Optional[List[List[dict[str, Any]]]] = None,
|
||
original_message_id: Optional[int] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
stop_typing: bool = False,
|
||
parse_mode: Optional[str] = None,
|
||
) -> Optional[Union[bool, JsonDict]]:
|
||
"""
|
||
发送或编辑媒体候选列表。
|
||
|
||
:param medias: 按原顺序展示的候选项
|
||
:param userid: 接收用户标识;默认使用该用户最近会话,无记录时用用户标识,未指定时用默认会话
|
||
:param title: 列表标题
|
||
:param link: 列表末尾链接
|
||
:param buttons: 按行排列的链接或回调按钮
|
||
:param original_message_id: 与原会话同时提供时编辑该消息
|
||
:param original_chat_id: 显式原会话标识,优先于普通用户映射
|
||
:param stop_typing: 是否在操作收尾时请求停止输入状态,默认 False
|
||
:param parse_mode: 消息格式,默认 MarkdownV2;支持 HTML 和 plain 纯文本
|
||
:return: 编辑时返回成功布尔值,发送时返回最后一条 Bot API 消息字典;异常返回 False,配置缺失或发送无结果返回 None
|
||
"""
|
||
if not self._telegram_token or not self._telegram_chat_id:
|
||
return None
|
||
|
||
# 列表发送结束后是否停止输入状态,由 stop_typing 明确控制。
|
||
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||
try:
|
||
index, image = 1, ""
|
||
caption = self._format_title(title, parse_mode) or ""
|
||
for media in medias:
|
||
if not image:
|
||
image = media.get_message_image()
|
||
media_link = self._format_link(
|
||
media.title_year, media.detail_link, parse_mode
|
||
)
|
||
type_text = f"类型:{media.type.value if media.type else ""}"
|
||
if media.vote_average:
|
||
score_text = f"评分:{media.vote_average}"
|
||
caption = (
|
||
f"{caption}\n{index}. {media_link}\n"
|
||
f"{self._format_italic(f'{type_text},{score_text}', parse_mode)}"
|
||
)
|
||
else:
|
||
caption = (
|
||
f"{caption}\n{index}. {media_link}\n"
|
||
f"{self._format_italic(type_text, parse_mode)}"
|
||
)
|
||
index += 1
|
||
|
||
if link:
|
||
caption = f"{caption}\n{self._format_detail_link(link, parse_mode)}"
|
||
|
||
reply_markup = None
|
||
if buttons:
|
||
reply_markup = self._create_inline_keyboard(buttons)
|
||
|
||
if original_message_id and original_chat_id:
|
||
return self.__edit_message(
|
||
original_chat_id,
|
||
original_message_id,
|
||
caption,
|
||
buttons,
|
||
image,
|
||
parse_mode=parse_mode,
|
||
)
|
||
else:
|
||
return self.__send_request(
|
||
userid=chat_id,
|
||
image=image,
|
||
caption=caption,
|
||
reply_markup=reply_markup,
|
||
parse_mode=parse_mode,
|
||
)
|
||
|
||
except Exception as msg_e:
|
||
logger.error(f"发送消息失败:{msg_e}")
|
||
return False
|
||
finally:
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
|
||
def send_torrents_msg(
|
||
self,
|
||
torrents: List[Context],
|
||
userid: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
link: Optional[str] = None,
|
||
buttons: Optional[List[List[dict[str, Any]]]] = None,
|
||
original_message_id: Optional[int] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
stop_typing: bool = False,
|
||
parse_mode: Optional[str] = None,
|
||
) -> Optional[Union[bool, JsonDict]]:
|
||
"""
|
||
发送或编辑种子候选列表。
|
||
|
||
:param torrents: 按原顺序展示的候选项;种子上下文需含媒体信息,空列表按失败处理
|
||
:param userid: 接收用户标识;默认使用该用户最近会话,无记录时用用户标识,未指定时用默认会话
|
||
:param title: 列表标题
|
||
:param link: 列表末尾链接;当前实现会被最后一项种子详情地址覆盖
|
||
:param buttons: 按行排列的链接或回调按钮
|
||
:param original_message_id: 与原会话同时提供时编辑该消息
|
||
:param original_chat_id: 显式原会话标识,优先于普通用户映射
|
||
:param stop_typing: 是否在操作收尾时请求停止输入状态,默认 False
|
||
:param parse_mode: 消息格式,默认 MarkdownV2;支持 HTML 和 plain 纯文本
|
||
:return: 编辑时返回成功布尔值,发送时返回最后一条 Bot API 消息字典;异常返回 False,配置缺失或发送无结果返回 None
|
||
"""
|
||
if not self._telegram_token or not self._telegram_chat_id:
|
||
return None
|
||
|
||
# 资源列表发送结束后是否停止输入状态,由 stop_typing 明确控制。
|
||
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||
try:
|
||
index = 1
|
||
caption = self._format_title(title, parse_mode) or ""
|
||
if not torrents:
|
||
return False
|
||
first_media = torrents[0].media_info
|
||
image = first_media.get_message_image() if first_media else None
|
||
for context in torrents:
|
||
torrent = context.torrent_info
|
||
if not torrent:
|
||
continue
|
||
site_name = torrent.site_name or ""
|
||
meta = MetaInfo(torrent.title or "", torrent.description)
|
||
link = torrent.page_url or ""
|
||
title = (
|
||
f"{meta.season_episode} "
|
||
f"{meta.resource_term} "
|
||
f"{meta.video_term} "
|
||
f"{meta.release_group}"
|
||
)
|
||
title = re.sub(r"\s+", " ", title).strip()
|
||
free = torrent.volume_factor
|
||
seeder = f"{torrent.seeders}↑"
|
||
site_name = (
|
||
html_utils.escape(site_name)
|
||
if self._is_html_parse_mode(parse_mode)
|
||
else site_name
|
||
)
|
||
title_link = self._format_link(title, link, parse_mode)
|
||
caption = (
|
||
f"{caption}\n{index}.【{site_name}】{title_link} "
|
||
f"{size_tools.format_compact_size(torrent.size)} {free} {seeder}"
|
||
)
|
||
index += 1
|
||
|
||
if link:
|
||
caption = f"{caption}\n{self._format_detail_link(link, parse_mode)}"
|
||
|
||
reply_markup = None
|
||
if buttons:
|
||
reply_markup = self._create_inline_keyboard(buttons)
|
||
|
||
if original_message_id and original_chat_id:
|
||
# 原消息和会话同时存在时替换该消息内容
|
||
return self.__edit_message(
|
||
original_chat_id,
|
||
original_message_id,
|
||
caption,
|
||
buttons,
|
||
image,
|
||
parse_mode=parse_mode,
|
||
)
|
||
else:
|
||
return self.__send_request(
|
||
userid=chat_id,
|
||
image=image,
|
||
caption=caption,
|
||
reply_markup=reply_markup,
|
||
parse_mode=parse_mode,
|
||
)
|
||
|
||
except Exception as msg_e:
|
||
logger.error(f"发送消息失败:{msg_e}")
|
||
return False
|
||
finally:
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
|
||
@staticmethod
|
||
def _create_inline_keyboard(buttons: List[List[dict[str, Any]]]) -> JsonDict:
|
||
"""
|
||
创建内联键盘,忽略没有有效链接或回调数据的按钮及空行。
|
||
|
||
:param buttons: 按行排列的按钮配置;非空字符串 URL 优先,否则使用非空字符串回调数据
|
||
:return: Bot API InlineKeyboardMarkup 结构;没有有效按钮时包含空键盘
|
||
"""
|
||
keyboard = []
|
||
for row in buttons:
|
||
button_row = []
|
||
for button in row:
|
||
url = button.get("url")
|
||
callback_data = button.get("callback_data")
|
||
if isinstance(url, str) and url.strip():
|
||
btn = {"text": button["text"], "url": url}
|
||
elif isinstance(callback_data, str) and callback_data:
|
||
btn = {"text": button["text"], "callback_data": callback_data}
|
||
else:
|
||
logger.warning("Telegram 按钮缺少有效 URL 或回调数据,已忽略")
|
||
continue
|
||
button_row.append(btn)
|
||
if button_row:
|
||
keyboard.append(button_row)
|
||
return {"inline_keyboard": keyboard}
|
||
|
||
def answer_callback_query(
|
||
self,
|
||
callback_query_id: int,
|
||
text: Optional[str] = None,
|
||
show_alert: bool = False,
|
||
) -> Optional[bool]:
|
||
"""
|
||
确认按钮回调并可显示提示。
|
||
|
||
:param callback_query_id: Telegram 按钮回调标识
|
||
:param text: 可选提示正文
|
||
:param show_alert: 是否以弹窗展示提示
|
||
:return: 调用未抛错时返回 True;异常返回 False;客户端未初始化返回 None
|
||
"""
|
||
if not self._bot:
|
||
return None
|
||
|
||
try:
|
||
self._api.answer_callback_query(
|
||
callback_query_id, text=text, show_alert=show_alert
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"回应回调查询失败:{str(e)}")
|
||
return False
|
||
|
||
def delete_msg(
|
||
self, message_id: int, chat_id: Optional[int] = None
|
||
) -> Optional[bool]:
|
||
"""
|
||
删除指定会话或默认会话中的消息。
|
||
|
||
:param message_id: 原消息标识
|
||
:param chat_id: 目标会话;为空时使用默认会话
|
||
:return: 删除成功返回 True,失败返回 False;配置缺失返回 None
|
||
"""
|
||
if not self._telegram_token or not self._telegram_chat_id:
|
||
return None
|
||
|
||
target_chat_id: Union[str, int]
|
||
try:
|
||
if chat_id:
|
||
target_chat_id = chat_id
|
||
else:
|
||
target_chat_id = self._telegram_chat_id
|
||
|
||
result = self._api.delete_message(
|
||
chat_id=target_chat_id, message_id=int(message_id)
|
||
)
|
||
if result:
|
||
logger.info(
|
||
f"成功删除Telegram消息: chat_id={target_chat_id}, message_id={message_id}"
|
||
)
|
||
return True
|
||
else:
|
||
logger.error(
|
||
f"删除Telegram消息失败: chat_id={target_chat_id}, message_id={message_id}"
|
||
)
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"删除Telegram消息异常: {str(e)}")
|
||
return False
|
||
|
||
def edit_msg(
|
||
self,
|
||
chat_id: Union[str, int],
|
||
message_id: Union[str, int],
|
||
text: str,
|
||
title: Optional[str] = None,
|
||
buttons: Optional[List[List[dict[str, Any]]]] = None,
|
||
stop_typing: bool = False,
|
||
parse_mode: Optional[str] = None,
|
||
rich_message: Optional[str] = None,
|
||
wait_for_interval: bool = False,
|
||
) -> Optional[bool]:
|
||
"""
|
||
限频更新原消息,富文本失败时尝试纯文本回退。
|
||
|
||
:param chat_id: 原会话标识
|
||
:param message_id: 原消息标识
|
||
:param text: 完整的新正文
|
||
:param title: 可选新标题
|
||
:param buttons: 新的内联按钮布局
|
||
:param stop_typing: 是否在操作收尾时请求停止输入状态,默认 False
|
||
:param parse_mode: 消息格式,默认 MarkdownV2;支持 HTML 和 plain 纯文本
|
||
:param rich_message: 可选富文本正文;非空时覆盖普通标题和正文
|
||
:param wait_for_interval: 是否等待消息级编辑间隔后再发送;429 冷却不会等待
|
||
:return: 更新成功或内容未变化返回 True;限流、空内容或失败返回 False;客户端缺失返回 None
|
||
"""
|
||
if not self._bot:
|
||
return None
|
||
|
||
if not self._reserve_edit(chat_id, message_id, wait_for_interval=wait_for_interval):
|
||
return False
|
||
|
||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||
try:
|
||
if rich_message:
|
||
reply_markup = (
|
||
self._create_inline_keyboard(buttons) if buttons else None
|
||
)
|
||
result = self.__edit_rich_message(
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
rich_message=rich_message,
|
||
reply_markup=reply_markup,
|
||
)
|
||
if result:
|
||
self._mark_edit_succeeded(chat_id, message_id)
|
||
return True
|
||
if self._is_chat_rate_limited(chat_id):
|
||
return False
|
||
# Rich Message 编辑失败时降级为纯文本覆盖,避免回复停留在旧内容。
|
||
logger.warning(
|
||
f"Telegram Rich Message 编辑失败,降级为纯文本:chat_id={chat_id}"
|
||
)
|
||
fallback_result = self.__edit_plain_fallback(
|
||
chat_id=str(chat_id),
|
||
message_id=int(message_id),
|
||
rich_message=rich_message,
|
||
reply_markup=reply_markup,
|
||
)
|
||
if fallback_result:
|
||
self._mark_edit_succeeded(chat_id, message_id)
|
||
return fallback_result
|
||
|
||
if title:
|
||
bold_title = self._format_title(title, parse_mode)
|
||
caption = f"{bold_title}\n{text}" if text else (bold_title or "")
|
||
elif text:
|
||
caption = text
|
||
else:
|
||
return False
|
||
|
||
edit_result = self.__edit_message(
|
||
chat_id=str(chat_id),
|
||
message_id=int(message_id),
|
||
text=caption,
|
||
buttons=buttons,
|
||
parse_mode=parse_mode,
|
||
)
|
||
if edit_result:
|
||
self._mark_edit_succeeded(chat_id, message_id)
|
||
return edit_result
|
||
except Exception as e:
|
||
if self._is_rate_limit_error(e):
|
||
self._mark_rate_limit(chat_id, e)
|
||
logger.error(f"编辑Telegram消息异常: {str(e)}")
|
||
return False
|
||
finally:
|
||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||
|
||
@staticmethod
|
||
def __is_no_text_edit_error(err: Exception) -> bool:
|
||
"""
|
||
判断 Telegram 是否因为原消息没有 text 字段而拒绝文本编辑。
|
||
"""
|
||
return "there is no text in the message to edit" in str(err).lower()
|
||
|
||
@staticmethod
|
||
def __is_message_not_modified_error(err: Exception) -> bool:
|
||
"""
|
||
判断 Telegram 是否因为消息内容未变化而拒绝编辑。
|
||
"""
|
||
return "message is not modified" in str(err).lower()
|
||
|
||
@staticmethod
|
||
def __is_http_url_content_error(err: Exception) -> bool:
|
||
"""
|
||
判断 Telegram 是否因为无法获取远端图片 URL 而拒绝编辑。
|
||
"""
|
||
return "failed to get http url content" in str(err).lower()
|
||
|
||
def __edit_message_text_or_caption(
|
||
self,
|
||
chat_id: str,
|
||
message_id: int,
|
||
text: str,
|
||
reply_markup: Optional[JsonDict] = None,
|
||
disable_web_page_preview: Optional[bool] = None,
|
||
parse_mode: Optional[str] = None,
|
||
) -> bool:
|
||
"""
|
||
编辑 Telegram 文本消息,原消息无文本时回退为 caption 编辑。
|
||
"""
|
||
prepared_text = self._prepare_text(text, parse_mode)
|
||
edit_text_kwargs: Dict[str, Any] = {
|
||
"chat_id": chat_id,
|
||
"message_id": message_id,
|
||
"text": prepared_text,
|
||
"parse_mode": parse_mode,
|
||
"reply_markup": reply_markup,
|
||
}
|
||
if disable_web_page_preview is not None:
|
||
edit_text_kwargs["disable_web_page_preview"] = (
|
||
disable_web_page_preview
|
||
)
|
||
try:
|
||
self._api.edit_message_text(**edit_text_kwargs)
|
||
except Exception as err:
|
||
if self.__is_message_not_modified_error(err):
|
||
logger.debug(f"Telegram消息内容未变化,跳过编辑:{str(err)}")
|
||
return True
|
||
if not self.__is_no_text_edit_error(err):
|
||
raise
|
||
try:
|
||
self._api.edit_message_caption(
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
caption=prepared_text,
|
||
parse_mode=parse_mode,
|
||
reply_markup=reply_markup,
|
||
)
|
||
except Exception as caption_err:
|
||
if self.__is_message_not_modified_error(caption_err):
|
||
logger.debug(
|
||
f"Telegram消息内容未变化,跳过编辑:{str(caption_err)}"
|
||
)
|
||
return True
|
||
raise
|
||
return True
|
||
|
||
def __edit_message(
|
||
self,
|
||
chat_id: str,
|
||
message_id: int,
|
||
text: str,
|
||
buttons: Optional[List[List[dict[str, Any]]]] = None,
|
||
image: Optional[str] = None,
|
||
disable_web_page_preview: Optional[bool] = None,
|
||
parse_mode: Optional[str] = None,
|
||
) -> Optional[bool]:
|
||
"""
|
||
编辑已发送的消息。
|
||
|
||
:param chat_id: 聊天ID
|
||
:param message_id: 消息ID
|
||
:param text: 新的消息内容
|
||
:param buttons: 按钮列表
|
||
:param image: 图片URL或路径
|
||
:param disable_web_page_preview: 是否禁用链接预览(仅纯文本编辑时生效)
|
||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||
:return: 编辑是否成功
|
||
"""
|
||
if not self._bot:
|
||
return None
|
||
|
||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||
try:
|
||
reply_markup = None
|
||
if buttons:
|
||
reply_markup = self._create_inline_keyboard(buttons)
|
||
|
||
if image:
|
||
media = {
|
||
"type": "photo",
|
||
"media": image,
|
||
"caption": self._prepare_text(text, parse_mode),
|
||
}
|
||
if parse_mode:
|
||
media["parse_mode"] = parse_mode
|
||
self._api.edit_message_media(
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
media=media,
|
||
reply_markup=reply_markup,
|
||
)
|
||
else:
|
||
return self.__edit_message_text_or_caption(
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
text=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
parse_mode=parse_mode,
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
if self._is_rate_limit_error(e):
|
||
self._mark_rate_limit(chat_id, e)
|
||
return False
|
||
if self.__is_message_not_modified_error(e):
|
||
logger.debug(f"Telegram消息内容未变化,跳过编辑:{str(e)}")
|
||
return True
|
||
if image and self.__is_http_url_content_error(e):
|
||
logger.warning(
|
||
f"Telegram图片编辑失败,降级为文本编辑:{str(e)}"
|
||
)
|
||
try:
|
||
return self.__edit_message_text_or_caption(
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
text=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
parse_mode=parse_mode,
|
||
)
|
||
except Exception as fallback_err:
|
||
e = fallback_err
|
||
logger.error(f"编辑消息失败:{str(e)}")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _build_rich_message_chunks(
|
||
rich_message: str,
|
||
) -> List[JsonDict]:
|
||
"""
|
||
将 GitHub 风格 Markdown 转换并拆分为 Telegram Rich Message。
|
||
|
||
:param rich_message: 完整的 Rich Markdown 正文
|
||
:return: 满足 Telegram 字节数和块数量限制的 InputRichMessage 结构
|
||
"""
|
||
converted = richify(rich_message, mode="html")
|
||
return [
|
||
{
|
||
key: value
|
||
for key, value in chunk.to_dict().items()
|
||
if key in _RICH_MESSAGE_FIELDS and value is not None
|
||
}
|
||
for chunk in split_rich(converted)
|
||
]
|
||
|
||
def __edit_rich_message(
|
||
self,
|
||
chat_id: Union[str, int],
|
||
message_id: Union[str, int],
|
||
rich_message: str,
|
||
reply_markup: Optional[JsonDict] = None,
|
||
) -> bool:
|
||
"""
|
||
编辑 Telegram Rich Message。
|
||
|
||
:param chat_id: 聊天 ID
|
||
:param message_id: 原消息 ID
|
||
:param rich_message: 完整的 Rich Markdown 正文
|
||
:param reply_markup: 内联键盘
|
||
:return: 编辑是否成功
|
||
"""
|
||
chunks = self._build_rich_message_chunks(rich_message)
|
||
if len(chunks) != 1:
|
||
logger.warning("Telegram Rich Message 超出单条限制,无法编辑原消息")
|
||
return False
|
||
try:
|
||
self._api.edit_message_text(
|
||
chat_id=chat_id,
|
||
message_id=int(message_id),
|
||
text=None,
|
||
rich_message=chunks[0],
|
||
reply_markup=reply_markup,
|
||
)
|
||
return True
|
||
except Exception as err:
|
||
if self.__is_message_not_modified_error(err):
|
||
logger.debug(f"Telegram消息内容未变化,跳过编辑:{str(err)}")
|
||
return True
|
||
if self._is_rate_limit_error(err):
|
||
self._mark_rate_limit(chat_id, err)
|
||
return False
|
||
logger.error(f"编辑 Telegram Rich Message 失败:{str(err)}")
|
||
return False
|
||
|
||
def __edit_plain_fallback(
|
||
self,
|
||
chat_id: str,
|
||
message_id: int,
|
||
rich_message: str,
|
||
reply_markup: Optional[JsonDict] = None,
|
||
) -> bool:
|
||
"""
|
||
Rich Message 编辑失败后的降级出口,用纯文本覆盖原消息。
|
||
|
||
``parse_mode`` 显式传空字符串,避免 ``edit_message_text`` 回落到客户端默认的
|
||
MarkdownV2——Rich Message 已经在那条解析路径上失败过一次。
|
||
|
||
:param chat_id: 聊天 ID
|
||
:param message_id: 原消息 ID
|
||
:param rich_message: 原始 Rich Markdown 正文
|
||
:param reply_markup: 内联键盘
|
||
:return: 降级编辑是否成功
|
||
"""
|
||
try:
|
||
self._api.edit_message_text(
|
||
chat_id=chat_id,
|
||
message_id=int(message_id),
|
||
text=rich_message,
|
||
parse_mode=TELEGRAM_PARSE_MODE_PLAIN,
|
||
reply_markup=reply_markup,
|
||
)
|
||
return True
|
||
except Exception as err:
|
||
if self.__is_message_not_modified_error(err):
|
||
logger.debug(f"Telegram消息内容未变化,跳过编辑:{str(err)}")
|
||
return True
|
||
if self._is_rate_limit_error(err):
|
||
self._mark_rate_limit(chat_id, err)
|
||
return False
|
||
logger.error(f"Telegram Rich Message 降级为纯文本编辑仍失败:{str(err)}")
|
||
return False
|
||
|
||
@retry(RetryException, logger=logger)
|
||
def __send_rich_message(
|
||
self,
|
||
chat_id: Union[str, int],
|
||
rich_message: str,
|
||
reply_markup: Optional[JsonDict] = None,
|
||
reply_to_message_id: Optional[Union[str, int]] = None,
|
||
) -> Any:
|
||
"""
|
||
发送 Telegram Rich Message,超限内容自动拆分为多条。
|
||
|
||
:param chat_id: 目标聊天 ID
|
||
:param rich_message: 完整的 Rich Markdown 正文
|
||
:param reply_markup: 首条消息携带的键盘
|
||
:param reply_to_message_id: 首条消息回复的原消息 ID
|
||
:return: 最后一条已发送的 Telegram 消息
|
||
:raises NonRetryableTelegramError: Telegram 以 400 拒绝该内容,不做退避重试
|
||
:raises RetryException: 网络异常或 5xx,可重试
|
||
"""
|
||
chunks = self._build_rich_message_chunks(rich_message)
|
||
reply_parameters = (
|
||
{"message_id": int(reply_to_message_id)}
|
||
if reply_to_message_id is not None
|
||
else None
|
||
)
|
||
sent = None
|
||
try:
|
||
for index, chunk in enumerate(chunks):
|
||
sent = self._api.send_rich_message(
|
||
chat_id=chat_id,
|
||
rich_message=chunk,
|
||
reply_markup=reply_markup if index == 0 else None,
|
||
reply_parameters=reply_parameters if index == 0 else None,
|
||
**self._topic_kwargs(chat_id),
|
||
)
|
||
return sent
|
||
except Exception as err:
|
||
# 保留 Telegram 返回的原始描述(错误码、description),否则日志只剩
|
||
# “发送失败”四个字,无法定位是内容非法还是接口不匹配。
|
||
if _is_non_retryable_api_error(err):
|
||
if self._is_rate_limit_error(err):
|
||
self._mark_rate_limit(chat_id, err, operation="发送")
|
||
raise NonRetryableTelegramError(
|
||
f"发送 Telegram Rich Message 失败:{err}"
|
||
) from err
|
||
raise RetryException(
|
||
f"发送 Telegram Rich Message 失败:{err}"
|
||
) from err
|
||
|
||
def __send_plain_fallback(
|
||
self,
|
||
chat_id: Union[str, int],
|
||
rich_message: str,
|
||
reply_markup: Optional[JsonDict] = None,
|
||
) -> Any:
|
||
"""
|
||
Rich Message 发送失败后的降级出口,用纯文本重发同一条内容。
|
||
|
||
Rich Message 依赖 Bot API 10.1 的 ``sendRichMessage``,内容里出现无法解析的
|
||
片段或自建 Bot API 未升级该方法时都会整体失败。通知内容本身仍有价值,因此
|
||
降级为纯文本,保证消息不丢失。
|
||
|
||
``parse_mode`` 必须显式传空字符串:``TelegramBotApi`` 在参数为 ``None`` 时会
|
||
回落到客户端默认的 MarkdownV2,那正是 Rich Message 已经失败过的解析路径。
|
||
|
||
:param chat_id: 目标聊天 ID
|
||
:param rich_message: 原始 Rich Markdown 正文
|
||
:param reply_markup: 内联键盘
|
||
:return: 降级发送的结果,失败返回 None
|
||
"""
|
||
# ``_topic_kwargs`` 只可能产出 message_thread_id,显式传参而不是 ** 展开:
|
||
# ** 展开到 send_message 会被 mypy 匹配到第 5 个形参(disable_web_page_preview,
|
||
# Optional[bool]),把 dict[str, int] 判为类型不兼容。
|
||
topic_kwargs = self._topic_kwargs(chat_id)
|
||
try:
|
||
return self._api.send_message(
|
||
chat_id=chat_id,
|
||
text=rich_message,
|
||
parse_mode=TELEGRAM_PARSE_MODE_PLAIN,
|
||
reply_markup=reply_markup,
|
||
message_thread_id=topic_kwargs.get("message_thread_id"),
|
||
)
|
||
except Exception as err:
|
||
if self._is_rate_limit_error(err):
|
||
self._mark_rate_limit(chat_id, err, operation="发送")
|
||
logger.error(f"Telegram Rich Message 降级为纯文本发送仍失败:{err}")
|
||
return None
|
||
|
||
def __send_request(
|
||
self,
|
||
userid: Optional[str] = None,
|
||
image: Optional[str] = "",
|
||
caption: str = "",
|
||
reply_markup: Optional[JsonDict] = None,
|
||
disable_web_page_preview: Optional[bool] = None,
|
||
parse_mode: Optional[str] = None,
|
||
reply_to_message_id: Optional[int] = None,
|
||
) -> Optional[JsonDict]:
|
||
"""
|
||
按内容长度和媒体类型拆分并发送普通消息。
|
||
|
||
:param userid: 目标会话标识;为空时使用默认会话
|
||
:param image: 可选封面地址,先下载为字节再发送
|
||
:param caption: 已组合标题、正文和链接的完整内容
|
||
:param reply_markup: 首条消息的内联键盘或强制回复结构
|
||
:param disable_web_page_preview: 是否禁用文本链接预览
|
||
:param parse_mode: 消息格式,默认 MarkdownV2;支持 HTML 和 plain 纯文本
|
||
:param reply_to_message_id: 发送消息回复的原消息标识
|
||
:return: 最后一条发送的 Bot API 消息;失败返回 None,已发送的分块不会撤回
|
||
"""
|
||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||
kwargs: Dict[str, Any] = {
|
||
"chat_id": userid or self._telegram_chat_id,
|
||
"parse_mode": parse_mode,
|
||
"reply_markup": reply_markup,
|
||
}
|
||
kwargs.update(self._topic_kwargs(kwargs["chat_id"]))
|
||
if reply_to_message_id:
|
||
kwargs["reply_to_message_id"] = reply_to_message_id
|
||
image_bytes = self.__process_image(image)
|
||
|
||
try:
|
||
# 图片消息的标题长度限制为1024,文本消息为4096
|
||
caption_limit = 1024 if image_bytes else 4096
|
||
if len(caption) < caption_limit:
|
||
ret = self.__send_short_message(image_bytes, caption,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
**kwargs)
|
||
elif (
|
||
self._is_plain_parse_mode(parse_mode)
|
||
or self._is_html_parse_mode(parse_mode)
|
||
):
|
||
ret = self.__send_long_plain_message(
|
||
image_bytes,
|
||
caption,
|
||
caption_limit,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
**kwargs,
|
||
)
|
||
else:
|
||
sent_idx: set[int] = set()
|
||
ret = self.__send_long_message(image_bytes, caption, sent_idx,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
**kwargs)
|
||
|
||
return ret
|
||
except Exception as e:
|
||
logger.error(f"发送Telegram消息失败: {e}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def __process_image(image_url: Optional[str]) -> Optional[bytes]:
|
||
"""
|
||
处理图片URL,获取图片内容。
|
||
"""
|
||
if not image_url:
|
||
return None
|
||
image = ImageHelper().fetch_image(image_url)
|
||
if not image:
|
||
logger.warn(f"图片获取失败: {image_url},仅发送文本消息")
|
||
return image
|
||
|
||
@retry(RetryException, logger=logger)
|
||
def __send_short_message(self, image: Optional[bytes], caption: str,
|
||
disable_web_page_preview: Optional[bool] = None, **kwargs: Any) -> Optional[JsonDict]:
|
||
"""
|
||
发送短消息。
|
||
"""
|
||
parse_mode = kwargs.get("parse_mode")
|
||
try:
|
||
if image:
|
||
return self.__send_photo_or_text(
|
||
photo=image,
|
||
caption=self._prepare_text(caption, parse_mode),
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
**kwargs,
|
||
)
|
||
else:
|
||
return self._api.send_message(
|
||
text=self._prepare_text(caption, parse_mode),
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
**kwargs
|
||
)
|
||
except Exception as err:
|
||
if self._is_rate_limit_error(err):
|
||
chat_id = kwargs.get("chat_id") or self._telegram_chat_id or ""
|
||
self._mark_rate_limit(chat_id, err, operation="发送")
|
||
raise NonRetryableTelegramError(
|
||
f"发送 Telegram {'图片' if image else '文本'}消息被限流:{err}"
|
||
) from err
|
||
raise RetryException(f"发送{'图片' if image else '文本'}消息失败:{err}") from err
|
||
|
||
def __send_photo_or_text(
|
||
self, *, photo: Any, caption: Optional[str],
|
||
disable_web_page_preview: Optional[bool] = None, **kwargs: Any,
|
||
) -> Optional[JsonDict]:
|
||
"""
|
||
海报上传失败立即降级为同一正文,保留会话、话题和按钮;429 不追加请求。
|
||
"""
|
||
try:
|
||
return self._api.send_photo(photo=photo, caption=caption, **kwargs)
|
||
except Exception as error:
|
||
if self._is_rate_limit_error(error) or not caption:
|
||
raise
|
||
logger.warning(f"Telegram 海报发送失败,改为发送文本:{error}")
|
||
return self._api.send_message(
|
||
text=caption, disable_web_page_preview=disable_web_page_preview, **kwargs,
|
||
)
|
||
|
||
@retry(RetryException, logger=logger)
|
||
def __send_long_plain_message(
|
||
self,
|
||
image: Optional[bytes],
|
||
caption: str,
|
||
caption_limit: int,
|
||
disable_web_page_preview: Optional[bool] = None,
|
||
**kwargs: Any,
|
||
) -> Optional[JsonDict]:
|
||
"""
|
||
按可见长度拆分普通或HTML正文并发送各分块,HTML保持实体和标签完整。
|
||
|
||
键盘仅附在第一块。非限流异常由装饰器重试整个方法,已成功的分块可能再次发送;
|
||
限流时记录会话冷却并停止立即重试。
|
||
|
||
:param image: 可选图片字节,仅用于第一块
|
||
:param caption: 完整正文,HTML分块关闭并重新打开跨块标签
|
||
:param caption_limit: 每块字符上限;图片说明通常为 1024,纯文本为 4096
|
||
:param disable_web_page_preview: 是否关闭文本分块的链接预览
|
||
:param kwargs: 目标会话、解析格式、话题、回复和键盘参数
|
||
:return: 最后一条发送的 Bot API 消息
|
||
"""
|
||
reply_markup = kwargs.pop("reply_markup", None)
|
||
if kwargs.get("parse_mode") == TELEGRAM_PARSE_MODE_HTML:
|
||
splitter = _HtmlMessageSplitter(caption_limit)
|
||
splitter.feed(caption)
|
||
chunks = splitter.finish()
|
||
else:
|
||
chunks = self._split_plain_text(caption, caption_limit)
|
||
ret = None
|
||
try:
|
||
for index, chunk in enumerate(chunks):
|
||
current_reply_markup = reply_markup if index == 0 else None
|
||
if image and index == 0:
|
||
ret = self.__send_photo_or_text(
|
||
**kwargs,
|
||
photo=image,
|
||
caption=chunk,
|
||
reply_markup=current_reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
continue
|
||
msg_kwargs = dict(**kwargs)
|
||
if disable_web_page_preview is not None:
|
||
msg_kwargs["disable_web_page_preview"] = disable_web_page_preview
|
||
ret = self._api.send_message(
|
||
**msg_kwargs,
|
||
text=chunk,
|
||
reply_markup=current_reply_markup,
|
||
)
|
||
return ret
|
||
except Exception as err:
|
||
if self._is_rate_limit_error(err):
|
||
chat_id = kwargs.get("chat_id") or self._telegram_chat_id or ""
|
||
self._mark_rate_limit(chat_id, err, operation="发送")
|
||
raise NonRetryableTelegramError(
|
||
f"发送 Telegram 长消息被限流:{err}"
|
||
) from err
|
||
raise RetryException("长消息发送失败") from err
|
||
|
||
@retry(RetryException, logger=logger)
|
||
def __send_long_message(
|
||
self, image: Optional[bytes], caption: str, sent_idx: set[int],
|
||
disable_web_page_preview: Optional[bool] = None, **kwargs: Any
|
||
) -> Optional[JsonDict]:
|
||
"""
|
||
通过 Markdown 转换器拆分长消息,并跳过已经发送的分块。
|
||
|
||
转换器可产生文字、图片或文件;键盘只附在第一个分块,失败重试不会撤回先前分块。
|
||
|
||
:param image: 可选首块封面字节
|
||
:param caption: 待转换的完整 Markdown 正文
|
||
:param sent_idx: 已成功发送的分块下标集合,会原地更新并用于装饰器重试时去重
|
||
:param disable_web_page_preview: 是否关闭文本分块的链接预览
|
||
:param kwargs: 目标会话、解析格式、话题、回复和键盘参数
|
||
:return: 本次发送的最后一条 Bot API 消息;无新分块时为 None
|
||
"""
|
||
|
||
reply_markup = kwargs.pop("reply_markup", None)
|
||
|
||
boxs: list[Text | File | Photo] = (
|
||
ThreadHelper()
|
||
.submit(lambda x: asyncio.run(telegramify(x)), caption)
|
||
.result()
|
||
)
|
||
|
||
ret = None
|
||
for i, item in enumerate(boxs):
|
||
if i in sent_idx:
|
||
# 跳过已发送消息
|
||
continue
|
||
|
||
try:
|
||
current_reply_markup = reply_markup if i == 0 else None
|
||
|
||
if item.content_type == ContentTypes.TEXT and (i != 0 or not image):
|
||
msg_kwargs = dict(**kwargs)
|
||
if disable_web_page_preview is not None:
|
||
msg_kwargs["disable_web_page_preview"] = disable_web_page_preview
|
||
ret = self._api.send_message(
|
||
**msg_kwargs,
|
||
text=self._telegramify_item_text(item),
|
||
reply_markup=current_reply_markup,
|
||
)
|
||
|
||
elif item.content_type == ContentTypes.PHOTO or (image and i == 0):
|
||
ret = self.__send_photo_or_text(
|
||
**kwargs,
|
||
photo=(
|
||
getattr(item, "file_name", ""),
|
||
getattr(item, "file_data", image),
|
||
),
|
||
caption=self._telegramify_item_caption(item),
|
||
reply_markup=current_reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
|
||
elif item.content_type == ContentTypes.FILE:
|
||
ret = self._api.send_document(
|
||
**kwargs,
|
||
document=(item.file_name, item.file_data),
|
||
caption=self._telegramify_item_caption(item),
|
||
reply_markup=current_reply_markup,
|
||
)
|
||
|
||
sent_idx.add(i)
|
||
|
||
except Exception as e:
|
||
if self._is_rate_limit_error(e):
|
||
chat_id = kwargs.get("chat_id") or self._telegram_chat_id or ""
|
||
self._mark_rate_limit(chat_id, e, operation="发送")
|
||
raise NonRetryableTelegramError(
|
||
f"发送 Telegram 消息被限流:{e}"
|
||
) from e
|
||
try:
|
||
raise RetryException(f"消息 [{i + 1}/{len(boxs)}] 发送失败") from e
|
||
except NameError:
|
||
raise
|
||
|
||
return ret
|
||
|
||
def register_commands(self, commands: Dict[str, dict[str, Any]]) -> None:
|
||
"""
|
||
先清除旧菜单,再按输入顺序注册命令。
|
||
|
||
:param commands: 以斜杠命令为键的映射,读取 description 作为显示说明;空映射不修改菜单
|
||
"""
|
||
if not self._bot:
|
||
return
|
||
if commands:
|
||
self._api.delete_my_commands()
|
||
self._api.set_my_commands(
|
||
commands=[
|
||
{"command": cmd[1:], "description": str(desc.get("description"))}
|
||
for cmd, desc in commands.items()
|
||
]
|
||
)
|
||
|
||
def delete_commands(self) -> None:
|
||
"""
|
||
清除机器人菜单命令;客户端未初始化时不执行。
|
||
"""
|
||
if not self._bot:
|
||
return
|
||
self._api.delete_my_commands()
|
||
|
||
def stop(self) -> bool:
|
||
"""
|
||
停止接收新输入状态任务,结束更新分发并关闭客户端。
|
||
|
||
停止时最多等待消息处理 10 秒,不等待已经发出的长轮询请求返回。
|
||
从消息处理线程自身调用时不等待该处理器,返回后不会再分发后续更新。
|
||
|
||
:return: 所有输入状态任务收尾且更新分发可安全停止时返回 True,否则返回 False
|
||
"""
|
||
converged = True
|
||
with self._typing_lifecycle_lock:
|
||
self._typing_accepting = False
|
||
# 封口与 owner 快照处于同一临界区,停止后不会漏掉并发新增任务。
|
||
for chat_id in list(self._typing_tasks.keys()):
|
||
if not self._stop_typing_task(chat_id):
|
||
converged = False
|
||
|
||
bot = self._bot
|
||
polling_thread = self._polling_thread
|
||
if bot:
|
||
# 在 handler 内触发的 stop 不等待自身:handler 返回后轮询循环即看到停止标记。
|
||
in_polling_thread = polling_thread is threading.current_thread()
|
||
dispatch_idle = bot.stop_polling(
|
||
0 if in_polling_thread else self._shutdown_timeout_seconds
|
||
)
|
||
if not dispatch_idle and not in_polling_thread:
|
||
# 保留 owner,调用方重试 stop 时继续等待同一个 handler。
|
||
logger.error("Telegram 消息处理未在关闭预算内结束")
|
||
return False
|
||
# 进行中的 getUpdates 不再等待;它返回后只会丢弃结果并退出线程。
|
||
bot.close()
|
||
self._polling_thread = None
|
||
self._bot = None
|
||
logger.info("Telegram消息接收服务已停止")
|
||
return converged
|