Files
MoviePilot/app/modules/telegram/telegram.py
@huluohu 76a4e33e44 fix(telegram): Rich Message 失败时保留原始错误并降级纯文本 (#6993)
* 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>
2026-10-15 07:32:16 +08:00

1884 lines
73 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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