From 9273d68aa76a32b1d4a2d83f8fa06be36fa729d8 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 22 Aug 2026 10:11:12 +0800 Subject: [PATCH] feat(telegram): support rich Agent messages --- app/agent/callback/__init__.py | 17 ++- app/agent/orchestrator.py | 7 + app/agent/prompt/System Core Prompt.txt | 1 + app/agent/prompt/__init__.py | 18 +++ app/agent/tools/impl/send_message.py | 36 ++++-- app/modules/telegram/module.py | 5 +- app/modules/telegram/telegram.py | 150 +++++++++++++++++++++- app/schemas/message.py | 2 + docs/mcp-api.md | 2 + pyproject.toml | 2 +- skills/moviepilot-cli/SKILL.md | 6 +- tests/test_agent_telegram_rich_message.py | 82 ++++++++++++ tests/test_agent_tool_streaming.py | 11 +- tests/test_builtin_skill_boundaries.py | 2 +- tests/test_telegram.py | 116 +++++++++++++++++ uv.lock | 20 +-- 16 files changed, 444 insertions(+), 33 deletions(-) create mode 100644 tests/test_agent_telegram_rich_message.py diff --git a/app/agent/callback/__init__.py b/app/agent/callback/__init__.py index 4f7c54c31..d72b398f5 100644 --- a/app/agent/callback/__init__.py +++ b/app/agent/callback/__init__.py @@ -486,6 +486,14 @@ class StreamingHandler: except (ValueError, KeyError): return False + def _get_rich_message(self, text: str) -> Optional[str]: + """ + 为 Telegram 流式消息返回 Rich Markdown,其他渠道继续使用原有格式。 + """ + if self._channel == NotificationChannel.Telegram.value: + return text + return None + async def _flush_loop(self): """ 定时刷新循环,定期将缓冲区内容发送/编辑到用户 @@ -557,6 +565,7 @@ class StreamingHandler: original_chat_id=self._original_chat_id, title=self._title, text=current_text, + rich_message=self._get_rich_message(current_text), save_history=False, ), ) @@ -603,6 +612,7 @@ class StreamingHandler: original_chat_id=self._original_chat_id, title=self._title, text=current_text, + rich_message=self._get_rich_message(current_text), save_history=False, ), ) @@ -623,6 +633,11 @@ class StreamingHandler: except (ValueError, KeyError): return + metadata = dict(self._message_response.metadata or {}) + rich_message = self._get_rich_message(current_text) + if rich_message: + # 通用编辑接口不增加渠道专属参数,通过元数据交给 Telegram 模块消费。 + metadata["telegram_rich_message"] = rich_message success = await run_in_threadpool( chain.edit_message, channel=channel_enum, @@ -631,7 +646,7 @@ class StreamingHandler: chat_id=self._message_response.chat_id, text=current_text, title=self._title, - metadata=self._message_response.metadata, + metadata=metadata, ) if success: with self._lock: diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index 7053d9acd..a52d19ee2 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -2395,6 +2395,12 @@ class MoviePilotAgent: 发送 Agent 消息;后台任务不绑定原渠道,交由通知链广播。 """ broadcast = self.is_background + rich_message = ( + message + if not broadcast + and self.channel == NotificationChannel.Telegram.value + else None + ) self._save_assistant_display_message_once(message) await AgentChain().async_post_message( Message( @@ -2407,6 +2413,7 @@ class MoviePilotAgent: original_chat_id=None if broadcast else self.original_chat_id, title=title, text=message, + rich_message=rich_message, save_history=False, ) ) diff --git a/app/agent/prompt/System Core Prompt.txt b/app/agent/prompt/System Core Prompt.txt index 3326b478b..599ab7cad 100644 --- a/app/agent/prompt/System Core Prompt.txt +++ b/app/agent/prompt/System Core Prompt.txt @@ -105,6 +105,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel - Channel-aware formatting: Follow the capability rules below for Markdown, plain text, buttons, and voice replies. {button_choice_spec} - Voice replies: {voice_reply_spec} +{rich_message_spec} - If the current channel supports image sending and an image would materially help, you may use the `send_message` tool with `image_url` to send it. - If the current channel supports file sending and you need to return a local image or file for the user to download, use `send_local_file`. diff --git a/app/agent/prompt/__init__.py b/app/agent/prompt/__init__.py index 726cb7190..de5c21733 100644 --- a/app/agent/prompt/__init__.py +++ b/app/agent/prompt/__init__.py @@ -137,6 +137,7 @@ class PromptManager: if caps: markdown_spec = self._generate_formatting_instructions(caps) button_choice_spec = self._generate_button_choice_instructions(msg_channel) + rich_message_spec = self._generate_rich_message_instructions(msg_channel) # MoviePilot系统信息 moviepilot_info = self._get_moviepilot_info() @@ -148,6 +149,7 @@ class PromptManager: moviepilot_info=moviepilot_info, voice_reply_spec=voice_reply_spec, button_choice_spec=button_choice_spec, + rich_message_spec=rich_message_spec, ) return base_prompt @@ -352,6 +354,22 @@ class PromptManager: "content as a text fallback and still completes the reply." ) + @staticmethod + def _generate_rich_message_instructions( + channel: NotificationChannel = None, + ) -> str: + """根据渠道生成 Telegram Rich Message 回复提示。""" + if channel != NotificationChannel.Telegram: + return "" + return ( + "- Telegram final replies: Prefer the `send_message` tool with its " + "`rich_message` argument. Put the complete reply in that argument using " + "GitHub-style Markdown; headings, lists, tables, blockquotes, code blocks, " + "and links are converted to Telegram Rich Message blocks. Do not also set " + "`message`, `title`, or `image_url` for the same reply. Use a normal plain " + "reply only when the response is very short and has no useful structure." + ) + @staticmethod def _generate_button_choice_instructions( channel: NotificationChannel = None, diff --git a/app/agent/tools/impl/send_message.py b/app/agent/tools/impl/send_message.py index faf3b0ad4..6fba5fadd 100644 --- a/app/agent/tools/impl/send_message.py +++ b/app/agent/tools/impl/send_message.py @@ -26,12 +26,25 @@ class SendMessageInput(BaseModel): None, description="Optional image URL to send together with the message on channels that support images (such as Telegram and Slack)", ) + rich_message: Optional[str] = Field( + None, + description=( + "Optional complete Telegram Rich Message body written in GitHub-style " + "Markdown. Telegram renders this instead of message, title, and image_url; " + "other channels use it as the plain-text fallback when message is empty." + ), + ) @model_validator(mode="after") def validate_payload(self) -> "SendMessageInput": """校验消息内容和可选格式参数。""" - if not self.message and not self.title and not self.image_url: - raise ValueError("message、title、image_url 至少需要提供一个") + if ( + not self.message + and not self.title + and not self.image_url + and not self.rich_message + ): + raise ValueError("message、title、image_url、rich_message 至少需要提供一个") return self @@ -50,15 +63,18 @@ class SendMessageTool(MoviePilotTool): description: str = ( "Send notification message to the user through configured notification channels " "(Telegram, Slack, WeChat, etc.). Supports optional image_url on channels that can " - "send images. This is a terminal response tool: after it sends the user-facing " - "message, do not send another final text reply with the same content." + "send images. On Telegram, prefer the optional rich_message parameter for structured " + "final replies; write its complete content in GitHub-style Markdown. This is a " + "terminal response tool: after it sends the user-facing message, do not send another " + "final text reply with the same content." ) args_schema: Type[BaseModel] = SendMessageInput require_admin: bool = True def get_tool_message(self, **kwargs) -> Optional[str]: """根据消息参数生成友好的提示消息""" - message = kwargs.get("message", "") or "" + rich_message = kwargs.get("rich_message") or "" + message = kwargs.get("message", "") or rich_message title = kwargs.get("title") or "" image_url = kwargs.get("image_url") @@ -66,6 +82,8 @@ class SendMessageTool(MoviePilotTool): if len(message) > 50: message = message[:50] + "..." + if rich_message: + return f"发送富文本消息: {message}" if title and image_url: return f"发送图文消息: [{title}] {message}" if title: @@ -79,15 +97,16 @@ class SendMessageTool(MoviePilotTool): message: Optional[str] = None, title: Optional[str] = None, image_url: Optional[str] = None, + rich_message: Optional[str] = None, **kwargs, ) -> str: """发送消息到当前会话渠道。""" - title = title or ("图片" if image_url and not message else "") - text = message or "" + title = title or ("图片" if image_url and not message and not rich_message else "") + text = message or rich_message or "" logger.info( f"执行工具: {self.name}, 参数: title={title}, message={text}, " - f"image_url={image_url}" + f"image_url={image_url}, rich_message={bool(rich_message)}" ) try: await self.send_message( @@ -100,6 +119,7 @@ class SendMessageTool(MoviePilotTool): title=title, text=text, image=image_url, + rich_message=rich_message, save_history=False, ) ) diff --git a/app/modules/telegram/module.py b/app/modules/telegram/module.py index 9901793c5..48328875a 100644 --- a/app/modules/telegram/module.py +++ b/app/modules/telegram/module.py @@ -515,6 +515,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): original_chat_id=message.original_chat_id, disable_web_page_preview=message.disable_web_page_preview, parse_mode=message.parse_mode, + rich_message=message.rich_message, ) def post_medias_message( @@ -616,7 +617,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): :param text: 新的消息内容 :param title: 消息标题 :param buttons: 新的按钮列表 - :param metadata: 其他元信息 + :param metadata: 其他元信息;telegram_rich_message 用于流式富文本编辑 :param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML :return: 编辑是否成功 """ @@ -634,6 +635,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): title=title, buttons=buttons, parse_mode=parse_mode, + rich_message=(metadata or {}).get("telegram_rich_message"), ) if result: return True @@ -739,6 +741,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): original_chat_id=original_chat_id, disable_web_page_preview=message.disable_web_page_preview, parse_mode=message.parse_mode, + rich_message=message.rich_message, private_delivery=message.private_delivery, ) if result and result.get("success"): diff --git a/app/modules/telegram/telegram.py b/app/modules/telegram/telegram.py index a1ab9a5ed..181d4cc29 100644 --- a/app/modules/telegram/telegram.py +++ b/app/modules/telegram/telegram.py @@ -19,12 +19,14 @@ from telebot.types import ( # noqa: E402 InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto, + InputRichMessage as TelebotInputRichMessage, + ReplyParameters, ) try: from telebot.types import ForceReply # noqa: E402 except ImportError: ForceReply = None -from telegramify_markdown import standardize, telegramify # noqa: E402 +from telegramify_markdown import richify, split_rich, standardize, telegramify # noqa: E402 try: from telegramify_markdown import entities_to_markdownv2 # noqa: E402 except ImportError: @@ -615,6 +617,7 @@ class Telegram: 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[dict]: """ @@ -631,6 +634,7 @@ class Telegram: :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 的字典 """ @@ -644,8 +648,8 @@ class Telegram: original_chat_id, private_delivery=private_delivery, ) - if not title and not text: - logger.warn("标题和内容不能同时为空") + if not title and not text and not rich_message: + logger.warn("标题、内容和富文本内容不能同时为空") self._stop_typing_if_needed(chat_id, stop_typing) return {"success": False} @@ -670,6 +674,45 @@ class Telegram: elif force_reply and ForceReply: 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 + ) + 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 + ), + ) + self._stop_typing_if_needed(chat_id, stop_typing) + if sent and hasattr(sent, "message_id"): + return { + "success": True, + "message_id": sent.message_id, + "chat_id": sent.chat.id if hasattr(sent, "chat") else chat_id, + } + if sent: + return {"success": True} + return {"success": False} + # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: if force_reply and reply_markup and not buttons: @@ -1165,6 +1208,7 @@ class Telegram: buttons: Optional[List[List[dict]]] = None, stop_typing: bool = False, parse_mode: Optional[str] = None, + rich_message: Optional[str] = None, ) -> Optional[bool]: """ 编辑Telegram消息(公开方法) @@ -1175,6 +1219,7 @@ class Telegram: :param buttons: 新的按钮列表 :param stop_typing: 编辑完成后是否立即停止 typing :param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML + :param rich_message: 完整的 Telegram Rich Markdown 正文,设置后替代普通文本 :return: 编辑是否成功 """ if not self._bot: @@ -1182,6 +1227,17 @@ class Telegram: parse_mode = self._normalize_parse_mode(parse_mode) try: + if rich_message: + reply_markup = ( + self._create_inline_keyboard(buttons) if buttons else None + ) + return self.__edit_rich_message( + chat_id=chat_id, + message_id=message_id, + rich_message=rich_message, + reply_markup=reply_markup, + ) + # 组合标题和文本 if title: bold_title = self._format_title(title, parse_mode) @@ -1351,6 +1407,94 @@ class Telegram: logger.error(f"编辑消息失败:{str(e)}") return False + @staticmethod + def _build_rich_message_chunks( + rich_message: str, + ) -> List[TelebotInputRichMessage]: + """ + 将 GitHub 风格 Markdown 转换并拆分为 Telegram Rich Message。 + + :param rich_message: 完整的 Rich Markdown 正文 + :return: 满足 Telegram 字节数和块数量限制的消息片段 + """ + converted = richify(rich_message, mode="html") + return [ + TelebotInputRichMessage(**chunk.to_dict()) + 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[InlineKeyboardMarkup] = 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._bot.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 + + @retry(RetryException, logger=logger) + def __send_rich_message( + self, + chat_id: Union[str, int], + rich_message: str, + reply_markup: Optional[InlineKeyboardMarkup] = 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 消息 + """ + chunks = self._build_rich_message_chunks(rich_message) + reply_parameters = ( + ReplyParameters(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._bot.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, + ) + return sent + except Exception as err: + raise RetryException("发送 Telegram Rich Message 失败") from err + def __send_request( self, userid: Optional[str] = None, diff --git a/app/schemas/message.py b/app/schemas/message.py index 982f34377..b48ea2335 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -271,6 +271,8 @@ class Message(BaseModel): disable_web_page_preview: Optional[bool] = None # 消息文本格式;Telegram 支持 MarkdownV2、HTML、plain,飞书直发支持 plain parse_mode: Optional[str] = None + # Telegram Rich Message 完整 Markdown 正文;其他渠道可使用 text 作为回退 + rich_message: Optional[str] = None # 是否写入消息历史 save_history: bool = True diff --git a/docs/mcp-api.md b/docs/mcp-api.md index df5a7a3de..ca2d1760b 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -291,6 +291,8 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized` 内置工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。插件工具的参数结构由插件自身声明。 +`send_message` 新增可选的 `rich_message` 字符串参数,用于传入一份完整的 GitHub 风格 Markdown 正文。Telegram 渠道会把它转换为 Bot API Rich Message,支持标题、列表、表格、引用、代码块和链接,并按 Rich Message 限制自动分段;没有使用该参数时继续走原有普通消息链路。广播到其它通知渠道时,同一正文会作为普通 `text` 回退。`rich_message` 是完整正文,不应再同时传 `message`、`title` 或 `image_url` 表达同一份内容。内置 Agent 在 Telegram 会话中的普通回复、流式首发和后续流式编辑都会优先使用该富文本链路。 + 内置 Agent 的本地文件与命令工具 `read_file`、`write_file`、`edit_file`、 `apply_patch`、`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的 用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。 diff --git a/pyproject.toml b/pyproject.toml index dc8aa23c1..d7a4ccb29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ "pyparsing~=3.3.2", "pyquery~=2.0.1", "pystray~=0.19.5", - "pytelegrambotapi~=4.34.0", + "pytelegrambotapi~=4.36.0", "python-dateutil~=2.9.0.post0", "python-dotenv~=1.2.2", "python-multipart~=0.0.32", diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md index f64897fa9..58ec3baa3 100644 --- a/skills/moviepilot-cli/SKILL.md +++ b/skills/moviepilot-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-cli -version: 7 +version: 8 description: >- Use this skill when the user asks to operate MoviePilot through the local `moviepilot tool` MCP CLI for normal product workflows: media search, torrent @@ -66,6 +66,10 @@ Always run `show ` before calling a command — parameter names are not ## Workflows +### Send a Message + +Run `moviepilot tool show send_message` before sending. For a structured Telegram reply, prefer the optional `rich_message` argument and put the complete GitHub-style Markdown body in it. Headings, lists, tables, blockquotes, code blocks, and links are converted to Telegram Rich Message content. Do not repeat the same content in `message`, `title`, or `image_url`; use those ordinary fields when Rich Message is not needed. Other configured channels receive the Rich Markdown source as their plain-text fallback. + ### Search and Download #### 1. Search TMDB diff --git a/tests/test_agent_telegram_rich_message.py b/tests/test_agent_telegram_rich_message.py new file mode 100644 index 000000000..9fc5545c6 --- /dev/null +++ b/tests/test_agent_telegram_rich_message.py @@ -0,0 +1,82 @@ +"""Agent Telegram Rich Message 契约测试。""" + +import asyncio +from unittest.mock import AsyncMock, patch + +from app.agent.orchestrator import MoviePilotAgent +from app.agent.prompt import prompt_manager +from app.agent.tools.impl.send_message import SendMessageInput, SendMessageTool +from app.chain.agent import AgentChain +from app.schemas.types import NotificationChannel + + +def test_send_message_input_accepts_rich_message_only() -> None: + """Rich Message 本身应能构成完整的工具载荷。""" + payload = SendMessageInput(rich_message="# 结果\n\n- 成功") + + assert payload.message is None + assert payload.rich_message == "# 结果\n\n- 成功" + + +def test_send_message_tool_keeps_plain_fallback_for_rich_message() -> None: + """工具应同时保留跨渠道文本回退和 Telegram Rich Markdown。""" + + async def _run(): + tool = SendMessageTool(session_id="session-1", user_id="10001") + tool.set_message_attr( + channel=NotificationChannel.Telegram.value, + source="telegram-test", + username="tester", + ) + tool.set_agent_context(agent_context={}) + with patch( + "app.agent.tools.base.ToolChain.async_post_message", + new_callable=AsyncMock, + ) as async_post_message: + result = await tool.run(rich_message="# 结果\n\n- **成功**") + return result, async_post_message + + result, async_post_message = asyncio.run(_run()) + message = async_post_message.await_args.args[0] + + assert result == "消息已发送" + assert message.text == "# 结果\n\n- **成功**" + assert message.rich_message == "# 结果\n\n- **成功**" + assert message.save_history is False + + +def test_agent_prompt_prefers_rich_message_only_for_telegram() -> None: + """仅 Telegram 会话应收到 Rich Message 优先提示。""" + telegram_prompt = prompt_manager.get_agent_prompt( + channel=NotificationChannel.Telegram.value + ) + wechat_prompt = prompt_manager.get_agent_prompt( + channel=NotificationChannel.Wechat.value + ) + + assert "`rich_message` argument" in telegram_prompt + assert "GitHub-style Markdown" in telegram_prompt + assert "`rich_message` argument" not in wechat_prompt + + +def test_agent_direct_telegram_reply_uses_rich_message() -> None: + """Agent 常规 Telegram 回复也应自动携带 Rich Markdown。""" + + async def _run(): + agent = MoviePilotAgent(session_id="telegram-session", user_id="10001") + agent.channel = NotificationChannel.Telegram.value + agent.source = "telegram-test" + agent.username = "tester" + with patch.object( + AgentChain, + "async_post_message", + new_callable=AsyncMock, + ) as async_post_message: + await agent.send_agent_message("# 结果\n\n- **完成**") + return async_post_message + + async_post_message = asyncio.run(_run()) + message = async_post_message.await_args.args[0] + + assert message.text == "# 结果\n\n- **完成**" + assert message.rich_message == "# 结果\n\n- **完成**" diff --git a/tests/test_agent_tool_streaming.py b/tests/test_agent_tool_streaming.py index 2e960b793..e462109f1 100644 --- a/tests/test_agent_tool_streaming.py +++ b/tests/test_agent_tool_streaming.py @@ -365,7 +365,10 @@ class TestAgentToolStreaming: assert run_in_threadpool_mock.await_count == 1 assert run_in_threadpool_mock.await_args.args[0].__name__ == "send_direct_message" - assert run_in_threadpool_mock.await_args.args[1].mtype == MessageType.Agent + notification = run_in_threadpool_mock.await_args.args[1] + assert notification.mtype == MessageType.Agent + assert notification.text == "hello" + assert notification.rich_message == "hello" assert handler.has_sent_message def test_flush_edits_message_via_threadpool(self): @@ -392,6 +395,12 @@ class TestAgentToolStreaming: assert run_in_threadpool_mock.await_count == 1 assert run_in_threadpool_mock.await_args.args[0].__name__ == "edit_message" + assert ( + run_in_threadpool_mock.await_args.kwargs["metadata"][ + "telegram_rich_message" + ] + == "hello world" + ) assert handler._sent_text == "hello world" def test_stop_streaming_waits_inflight_initial_flush_before_final_edit(self): diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index be5d56b9c..eb53bca3c 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -24,7 +24,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: expected_versions = { "database-operation": "4", "moviepilot-api": "13", - "moviepilot-cli": "7", + "moviepilot-cli": "8", "moviepilot-update": "3", "organize-files": "3", "transfer-failed-retry": "4", diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 517e7fa67..318617688 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -54,6 +54,25 @@ def test_send_msg_success(telegram): assert result and result.get("success") +def test_edit_msg_with_rich_message(telegram): + """Telegram 流式编辑应继续使用 Rich Message 协议。""" + telegram._bot.edit_message_text.return_value = SimpleNamespace(message_id=101) + + result = telegram.edit_msg( + chat_id="10001", + message_id=101, + text="# 旧回退", + rich_message="# 流式结果\n\n- **完成**", + ) + + assert result is True + kwargs = telegram._bot.edit_message_text.call_args.kwargs + assert kwargs["text"] is None + assert kwargs["rich_message"].html == ( + '

流式结果

' + ) + + def test_telegram_parser_preserves_reply_to_message_id(): """Telegram ForceReply 回复应保留来源消息和被回复消息的 message_id。""" module = TelegramModule() @@ -331,6 +350,50 @@ def test_send_msg_with_html_parse_mode_keeps_html(telegram): ) +def test_send_msg_uses_rich_message_api(telegram): + """Rich Markdown 应转换后通过 Telegram sendRichMessage 发送。""" + telegram.bot.send_rich_message.return_value = SimpleNamespace( + message_id=101, + chat=SimpleNamespace(id=10001), + ) + + result = telegram.send_msg( + title="", + rich_message=( + "# 处理完成\n\n" + "| 项目 | 结果 |\n" + "| --- | --- |\n" + "| 下载 | **成功** |" + ), + buttons=[[{"text": "查看", "url": "https://example.com"}]], + ) + + assert result == {"success": True, "message_id": 101, "chat_id": 10001} + telegram.bot.send_message.assert_not_called() + send_kwargs = telegram.bot.send_rich_message.call_args.kwargs + assert send_kwargs["chat_id"] == "fake_chat_id" + assert send_kwargs["rich_message"].markdown is None + assert "

处理完成

" in send_kwargs["rich_message"].html + assert "" in send_kwargs["rich_message"].html + assert send_kwargs["reply_markup"] is not None + + +def test_send_msg_edits_rich_message(telegram): + """带原消息定位信息的 Rich Message 应使用富文本编辑接口。""" + result = telegram.send_msg( + title="", + rich_message="# 更新结果\n\n- 已完成", + original_message_id=101, + original_chat_id="10001", + ) + + assert result == {"success": True, "message_id": 101, "chat_id": "10001"} + edit_kwargs = telegram.bot.edit_message_text.call_args.kwargs + assert edit_kwargs["text"] is None + assert edit_kwargs["message_id"] == 101 + assert "

更新结果

" in edit_kwargs["rich_message"].html + + def test_telegram_module_passes_parse_mode_to_client(): """模块发送通知时应透传消息指定的parse_mode""" module = TelegramModule() @@ -359,6 +422,59 @@ def test_telegram_module_passes_parse_mode_to_client(): assert client.send_msg.call_args.kwargs["parse_mode"] == "HTML" +def test_telegram_module_passes_rich_message_to_client(): + """Telegram 模块应把消息模型中的 Rich Markdown 透传给客户端。""" + module = TelegramModule() + client = Mock() + + with patch.object( + module, + "get_configs", + return_value={"telegram-test": SimpleNamespace(name="telegram-test")}, + ), patch.object( + module, "check_message", return_value=True + ), patch.object( + module, "get_instance", return_value=client + ): + module.post_message( + Message( + channel=NotificationChannel.Telegram, + source="telegram-test", + rich_message="# 智能体回复\n\n- 已完成", + ) + ) + + client.send_msg.assert_called_once() + assert client.send_msg.call_args.kwargs["rich_message"].startswith("# 智能体回复") + + +def test_telegram_module_passes_streaming_rich_message_to_edit_client(): + """Telegram 模块应把流式 Rich Markdown 元数据透传给编辑客户端。""" + module = TelegramModule() + module._channel = NotificationChannel.Telegram + client = Mock() + client.edit_msg.return_value = True + + with patch.object( + module, + "get_configs", + return_value={"telegram-test": SimpleNamespace(name="telegram-test")}, + ), patch.object( + module, "get_instance", return_value=client + ): + result = module.edit_message( + channel=NotificationChannel.Telegram, + source="telegram-test", + message_id=101, + chat_id="10001", + text="# 普通回退", + metadata={"telegram_rich_message": "# 流式富文本"}, + ) + + assert result is True + assert client.edit_msg.call_args.kwargs["rich_message"] == "# 流式富文本" + + def test_telegram_module_plain_post_message_keeps_chat_without_editing_source_message(): """普通通知应保留原会话目标,同时避免把来源消息 ID 当成编辑目标。""" module = TelegramModule() diff --git a/uv.lock b/uv.lock index 1f26446a2..0fbbba493 100644 --- a/uv.lock +++ b/uv.lock @@ -1645,7 +1645,7 @@ requires-dist = [ { name = "pyparsing", specifier = "~=3.3.2" }, { name = "pyquery", specifier = "~=2.0.1" }, { name = "pystray", specifier = "~=0.19.5" }, - { name = "pytelegrambotapi", specifier = "~=4.34.0" }, + { name = "pytelegrambotapi", specifier = "~=4.36.0" }, { name = "python-dateutil", specifier = "~=2.9.0.post0" }, { name = "python-dotenv", specifier = "~=1.2.2" }, { name = "python-multipart", specifier = "~=0.0.32" }, @@ -1750,18 +1750,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, - { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, - { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, - { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, - { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, - { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, @@ -2459,15 +2447,15 @@ wheels = [ [[package]] name = "pytelegrambotapi" -version = "4.34.0" +version = "4.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/6d/11705e63ac5922bddb8fe93dbe6010b2f46e1366c1e534d0c3866b46140d/pytelegrambotapi-4.34.0.tar.gz", hash = "sha256:ec56b339690c4f4a2c867cc677d01a9bfa9e4af58227a54596e2f366c0947df9", size = 1388791, upload-time = "2026-06-03T20:25:28.851Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/61/a342a1e13206e99b4cf9fd7ecf109c589bd2ad4b4d0ea7b8859750a7c3c6/pytelegrambotapi-4.36.1.tar.gz", hash = "sha256:1baa4154452cf93e654e74f3cb2800d5a2e8b11018c2fff45b247b7e6a7fb027", size = 1407140, upload-time = "2026-08-13T17:27:57.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/e9/18d614a0846a33639dc88c561122e665209798196f074f7dbca60c6145a0/pytelegrambotapi-4.34.0-py3-none-any.whl", hash = "sha256:470bec408033ceebe089cf968cc92cc1ff2d79b912ee5a983a07926ff79d4053", size = 317168, upload-time = "2026-06-03T20:25:27.425Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8a/f119aff83a7dd4570f6d6e9fa85bde75388d1574f43b8f656cee0d7ddcaf/pytelegrambotapi-4.36.1-py3-none-any.whl", hash = "sha256:2a3524c553ea7363d1b6d9ab4557f9cd9adb037642287687cdf05e158d670767", size = 334956, upload-time = "2026-08-13T17:27:56.302Z" }, ] [[package]]