mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-10 07:39:24 +08:00
* fix(telegram): Rich Message 失败时保留原始错误并降级纯文本
Telegram Rich Message 发送失败时存在三个叠加问题:
1. `__send_rich_message` 用固定文本 `RetryException("发送 Telegram Rich
Message 失败")` 包装异常,`str(e)` 里没有 Telegram 返回的 description,
日志只剩“发送失败”四个字,无法判断是内容非法、方法不存在还是格式问题。
2. 内容非法这类确定性失败(400)同样走满3 次退避重试(3s、6s),
期间同一条坏消息反复阻塞,且最终报错仍无有效信息。
3. 失败即丢失通知:Rich Message 发不出去时没有任何降级路径,
整条通知直接消失。
改动:
- 异常文本改为 f"...失败:{err}",保留 Telegram 原始 description 与错误码。
- 新增 `NonRetryableTelegramError`(继承 `ImmediateException`)与
`_is_non_retryable_api_error`,400 立即抛出以跳过 retry 退避;
5xx 与网络异常保持原有重试策略。
- `send_msg` / `edit_msg` 增加纯文本降级出口,Rich Message 失败时
用不带格式解析的纯文本重发,保证通知内容不丢失。
- 降级路径显式传 `parse_mode=""`:`TelegramBotApi._parse_mode` 在参数为
`None` 时会回落到客户端默认的 MarkdownV2,那正是 Rich Message 已经
失败过的解析路径,漏传会让降级重演同一个错误。
新增 tests/test_telegram_rich_message_fallback.py(14 项)覆盖错误信息保留、
400 免重试、5xx 仍重试、发送与编辑两条降级路径,以及 parse_mode 回落陷阱的
回归防护。
* fix(telegram): 修正 Rich Message 降级路径的 mypy 与架构基线
上一提交引入的两处门禁失败:
1. `__send_plain_fallback` 用 `**self._topic_kwargs(chat_id)` 展开参数,mypy
把 `**` 匹配到 `send_message` 第 5 个形参 `disable_web_page_preview`
(Optional[bool]),判定 `dict[str, int]` 不兼容,arg-type 由 1 增至 2。
改为显式传 `message_thread_id`,`_topic_kwargs` 只可能产出该键。
2. 新增 `app.schemas.exception` 导入带来 2 条依赖边,宿主基线由 9542 变为
9544。已用 `baseline.py --write-host` 固化,其余 3 个基线文件字节不变。
连带影响:docs/architecture-overview.md 与 docs/refactor/
optimization-checklist.md 硬编码了边数,`test_architecture_documents_
match_generated_quality_metrics` 断言两处必须与基线一致,同步为 9,544。
门禁与测试验证:
- scripts/architecture/mypy_ratchet.py exit 0(9163 个类型错误)
- scripts/architecture/baseline.py --check-host exit 0
- pytest(fallback + contract + telegram) 75 passed
---------
Co-authored-by: jxxghp <jxxghp@gmail.com>
1884 lines
73 KiB
Python
1884 lines
73 KiB
Python
import asyncio
|
||
import html as html_utils
|
||
import re
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, List, Optional, Union
|
||
|
||
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 = ""
|
||
# 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 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 与网络异常仍按原逻辑重试;429 由 Telegram 侧限流,同样不适合立即失败。
|
||
"""
|
||
return isinstance(err, TelegramApiError) and err.error_code == 400
|
||
|
||
|
||
class Telegram:
|
||
"""
|
||
Telegram 消息客户端,负责发送、编辑、接收和转发 Telegram 消息。
|
||
"""
|
||
|
||
_bot: Optional[TelegramBotApi] = None
|
||
_callback_handlers: Dict[str, Callable] = {} # 存储回调处理器
|
||
_bot_username: Optional[str] = None # Bot username for mention detection
|
||
_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,
|
||
):
|
||
"""
|
||
初始化参数
|
||
"""
|
||
# 即使配置不完整也保留基础属性,便于测试和未启用实例安全调用发送方法。
|
||
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._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
|
||
# telegram bot api 地址,格式:https://api.telegram.org;自定义地址不走代理。
|
||
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
|
||
|
||
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:
|
||
# Update user-chat mapping for callbacks too
|
||
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) -> bool:
|
||
"""把 Telegram SDK 回调同步转交统一消息入口。"""
|
||
return forward_message_to_host(payload, self._config_name)
|
||
|
||
@property
|
||
def bot(self):
|
||
"""
|
||
获取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: 文件ID
|
||
:return: 文件字节数据
|
||
"""
|
||
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 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 entities_to_markdownv2(item.text, item.entities)
|
||
return 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 entities_to_markdownv2(item.caption_text, item.caption_entities)
|
||
return 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 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:
|
||
# 如果没有获取到bot用户名,为了安全起见处理所有消息
|
||
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:
|
||
"""
|
||
获取状态
|
||
"""
|
||
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:
|
||
"""
|
||
启动持续发送正在输入状态的任务。
|
||
|
||
:return: 是否取得该会话的唯一 typing owner
|
||
"""
|
||
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():
|
||
"""延迟首发并定期发送 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:
|
||
"""
|
||
停止正在输入状态的任务,并保留尚未结束的 owner。
|
||
|
||
:return: 任务是否已经进入终态
|
||
"""
|
||
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:
|
||
"""
|
||
外部链路主动启动 typing 状态。
|
||
"""
|
||
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:
|
||
"""
|
||
外部链路主动停止 typing 状态。
|
||
"""
|
||
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: str,
|
||
text: Optional[str] = None,
|
||
image: Optional[str] = None,
|
||
userid: Optional[str] = None,
|
||
link: Optional[str] = None,
|
||
buttons: Optional[List[List[dict]]] = 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]:
|
||
"""
|
||
发送Telegram消息
|
||
:param title: 消息标题
|
||
:param text: 消息内容
|
||
:param image: 消息图片地址
|
||
:param userid: 用户ID,如有则只发消息给该用户
|
||
:param link: 跳转链接
|
||
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
||
:param force_reply: 是否请求 Telegram 客户端强制回复
|
||
:param original_message_id: 原消息ID,如果提供则编辑原消息
|
||
:param original_chat_id: 原消息的聊天ID,编辑消息时需要
|
||
:param disable_web_page_preview: 是否禁用链接预览
|
||
:param stop_typing: 发送完成后是否立即停止 typing
|
||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||
:param rich_message: 完整的 Telegram Rich Markdown 正文,设置后替代普通图文内容
|
||
:param private_delivery: 是否绕过最近会话映射,直接以用户 ID 作为私聊目标
|
||
:return: 包含 message_id, chat_id, success 的字典
|
||
"""
|
||
if not self._telegram_token or not self._telegram_chat_id:
|
||
return None
|
||
|
||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||
# Determine target chat_id with improved logic using user mapping
|
||
chat_id = self._determine_target_chat_id(
|
||
userid,
|
||
original_chat_id,
|
||
private_delivery=private_delivery,
|
||
)
|
||
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:
|
||
if original_message_id and original_chat_id and not force_reply:
|
||
result = self.__edit_rich_message(
|
||
chat_id=original_chat_id,
|
||
message_id=original_message_id,
|
||
rich_message=rich_message,
|
||
reply_markup=reply_markup,
|
||
)
|
||
self._stop_typing_if_needed(chat_id, 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:
|
||
# 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._stop_typing_if_needed(chat_id, stop_typing)
|
||
return self._sent_result(sent, chat_id)
|
||
|
||
# 判断是编辑消息还是发送新消息
|
||
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._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._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}
|
||
|
||
@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]:
|
||
"""
|
||
发送Telegram语音消息。
|
||
"""
|
||
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]:
|
||
"""
|
||
发送本地图片或文件给 Telegram 用户。
|
||
"""
|
||
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:
|
||
"""
|
||
确定目标聊天ID,使用用户映射确保回复到正确的聊天
|
||
:param userid: 用户ID
|
||
:param original_chat_id: 原消息的聊天ID
|
||
:return: 目标聊天ID
|
||
"""
|
||
# 私聊投递以渠道用户 ID 为目标,最近会话映射可能指向群聊,不能参与解析。
|
||
if private_delivery and userid:
|
||
return str(userid)
|
||
|
||
# 1. 优先使用原消息的聊天ID (编辑消息场景)
|
||
if original_chat_id:
|
||
return original_chat_id
|
||
|
||
# 2. 如果有userid,尝试从映射中获取用户的聊天ID
|
||
if userid:
|
||
mapped_chat_id = self._get_user_chat_id(userid)
|
||
if mapped_chat_id:
|
||
return mapped_chat_id
|
||
# 如果映射中没有,回退到使用userid作为聊天ID (私聊场景)
|
||
return userid
|
||
|
||
# 3. 最后使用默认聊天ID
|
||
return self._telegram_chat_id
|
||
|
||
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]]] = None,
|
||
original_message_id: Optional[int] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
stop_typing: bool = False,
|
||
parse_mode: Optional[str] = None,
|
||
) -> Optional[bool]:
|
||
"""
|
||
发送媒体列表消息
|
||
:param medias: 媒体信息列表
|
||
:param userid: 用户ID,如有则只发消息给该用户
|
||
:param title: 消息标题
|
||
:param link: 跳转链接
|
||
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
||
:param original_message_id: 原消息ID,如果提供则编辑原消息
|
||
:param original_chat_id: 原消息的聊天ID,编辑消息时需要
|
||
:param stop_typing: 发送完成后是否立即停止 typing
|
||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||
"""
|
||
if not self._telegram_token or not self._telegram_chat_id:
|
||
return None
|
||
|
||
# 列表消息也可能是一次交互的最终响应,默认在发送后结束 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.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]]] = None,
|
||
original_message_id: Optional[int] = None,
|
||
original_chat_id: Optional[str] = None,
|
||
stop_typing: bool = False,
|
||
parse_mode: Optional[str] = None,
|
||
) -> Optional[bool]:
|
||
"""
|
||
发送种子列表消息
|
||
:param torrents: 种子信息列表
|
||
:param userid: 用户ID,如有则只发消息给该用户
|
||
:param title: 消息标题
|
||
:param link: 跳转链接
|
||
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
||
:param original_message_id: 原消息ID,如果提供则编辑原消息
|
||
:param original_chat_id: 原消息的聊天ID,编辑消息时需要
|
||
:param stop_typing: 发送完成后是否立即停止 typing
|
||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||
"""
|
||
if not self._telegram_token or not self._telegram_chat_id:
|
||
return None
|
||
|
||
# 资源列表是搜索交互的常见出口,默认在发送后结束 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 ""
|
||
image = torrents[0].media_info.get_message_image()
|
||
for context in torrents:
|
||
torrent = context.torrent_info
|
||
site_name = torrent.site_name
|
||
meta = MetaInfo(torrent.title, torrent.description)
|
||
link = torrent.page_url
|
||
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]]) -> JsonDict:
|
||
"""
|
||
创建内联键盘
|
||
:param buttons: 按钮配置,格式:[[{"text": "按钮文本", "callback_data": "回调数据", "url": "链接"}]]
|
||
:return: Bot API InlineKeyboardMarkup 结构
|
||
"""
|
||
keyboard = []
|
||
for row in buttons:
|
||
button_row = []
|
||
for button in row:
|
||
if "url" in button:
|
||
# URL按钮
|
||
btn = {"text": button["text"], "url": button["url"]}
|
||
else:
|
||
# 回调按钮
|
||
btn = {"text": button["text"], "callback_data": button["callback_data"]}
|
||
button_row.append(btn)
|
||
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]:
|
||
"""
|
||
回应回调查询
|
||
"""
|
||
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]:
|
||
"""
|
||
删除Telegram消息
|
||
:param message_id: 消息ID
|
||
:param chat_id: 聊天ID
|
||
:return: 删除是否成功
|
||
"""
|
||
if not self._telegram_token or not self._telegram_chat_id:
|
||
return None
|
||
|
||
try:
|
||
# 确定要删除消息的聊天ID
|
||
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]]] = None,
|
||
stop_typing: bool = False,
|
||
parse_mode: Optional[str] = None,
|
||
rich_message: Optional[str] = None,
|
||
) -> Optional[bool]:
|
||
"""
|
||
编辑Telegram消息(公开方法)
|
||
:param chat_id: 聊天ID
|
||
:param message_id: 消息ID
|
||
:param text: 新的消息内容
|
||
:param title: 消息标题
|
||
:param buttons: 新的按钮列表
|
||
:param stop_typing: 编辑完成后是否立即停止 typing
|
||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||
:param rich_message: 完整的 Telegram Rich Markdown 正文,设置后替代普通文本
|
||
:return: 编辑是否成功
|
||
"""
|
||
if not self._bot:
|
||
return None
|
||
|
||
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:
|
||
return True
|
||
# Rich Message 编辑失败时降级为纯文本覆盖,避免回复停留在旧内容。
|
||
logger.warning(
|
||
f"Telegram Rich Message 编辑失败,降级为纯文本:chat_id={chat_id}"
|
||
)
|
||
return self.__edit_plain_fallback(
|
||
chat_id=str(chat_id),
|
||
message_id=int(message_id),
|
||
rich_message=rich_message,
|
||
reply_markup=reply_markup,
|
||
)
|
||
|
||
# 组合标题和文本
|
||
if title:
|
||
bold_title = self._format_title(title, parse_mode)
|
||
caption = f"{bold_title}\n{text}" if text else bold_title
|
||
elif text:
|
||
caption = text
|
||
else:
|
||
return False
|
||
|
||
return self.__edit_message(
|
||
chat_id=str(chat_id),
|
||
message_id=int(message_id),
|
||
text=caption,
|
||
buttons=buttons,
|
||
parse_mode=parse_mode,
|
||
)
|
||
except Exception as 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]]] = 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:
|
||
# 如果有图片,使用edit_message_media
|
||
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:
|
||
# 如果没有图片,使用edit_message_text
|
||
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_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
|
||
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
|
||
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):
|
||
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:
|
||
logger.error(f"Telegram Rich Message 降级为纯文本发送仍失败:{err}")
|
||
return None
|
||
|
||
def __send_request(
|
||
self,
|
||
userid: Optional[str] = None,
|
||
image="",
|
||
caption="",
|
||
reply_markup: Optional[JsonDict] = None,
|
||
disable_web_page_preview: Optional[bool] = None,
|
||
parse_mode: Optional[str] = None,
|
||
reply_to_message_id: Optional[int] = None,
|
||
):
|
||
"""
|
||
向Telegram发送报文,返回发送的消息对象
|
||
:param reply_markup: 内联键盘
|
||
:param disable_web_page_preview: 是否禁用链接预览
|
||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||
:param reply_to_message_id: 回复的原消息ID
|
||
:return: 发送成功返回消息对象,失败返回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 = self.__process_image(image)
|
||
|
||
try:
|
||
# 图片消息的标题长度限制为1024,文本消息为4096
|
||
caption_limit = 1024 if image else 4096
|
||
if len(caption) < caption_limit:
|
||
ret = self.__send_short_message(image, 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,
|
||
caption,
|
||
caption_limit,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
**kwargs,
|
||
)
|
||
else:
|
||
sent_idx = set()
|
||
ret = self.__send_long_message(image, 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):
|
||
"""
|
||
发送短消息
|
||
"""
|
||
parse_mode = kwargs.get("parse_mode")
|
||
try:
|
||
if image:
|
||
return self._api.send_photo(
|
||
photo=image,
|
||
caption=self._prepare_text(caption, parse_mode),
|
||
**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:
|
||
raise RetryException(f"发送{'图片' if image else '文本'}消息失败")
|
||
|
||
@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,
|
||
):
|
||
"""
|
||
按 Telegram 长度限制发送长文本。
|
||
"""
|
||
reply_markup = kwargs.pop("reply_markup", None)
|
||
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._api.send_photo(
|
||
**kwargs,
|
||
photo=image,
|
||
caption=chunk,
|
||
reply_markup=current_reply_markup,
|
||
)
|
||
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:
|
||
raise RetryException("长消息发送失败") from err
|
||
|
||
@retry(RetryException, logger=logger)
|
||
def __send_long_message(
|
||
self, image: Optional[bytes], caption: str, sent_idx: set,
|
||
disable_web_page_preview: Optional[bool] = None, **kwargs
|
||
):
|
||
"""
|
||
发送长消息
|
||
"""
|
||
|
||
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._api.send_photo(
|
||
**kwargs,
|
||
photo=(
|
||
getattr(item, "file_name", ""),
|
||
getattr(item, "file_data", image),
|
||
),
|
||
caption=self._telegramify_item_caption(item),
|
||
reply_markup=current_reply_markup,
|
||
)
|
||
|
||
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:
|
||
try:
|
||
raise RetryException(f"消息 [{i + 1}/{len(boxs)}] 发送失败") from e
|
||
except NameError:
|
||
raise
|
||
|
||
return ret
|
||
|
||
def register_commands(self, commands: Dict[str, dict]):
|
||
"""
|
||
注册菜单命令
|
||
"""
|
||
if not self._bot:
|
||
return
|
||
# 设置bot命令
|
||
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):
|
||
"""
|
||
清理菜单命令
|
||
"""
|
||
if not self._bot:
|
||
return
|
||
# 清理菜单命令
|
||
self._api.delete_my_commands()
|
||
|
||
def stop(self) -> bool:
|
||
"""
|
||
停止 Telegram 消息接收服务,并返回 polling/typing owner 是否收敛。
|
||
"""
|
||
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
|