mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
feat(telegram): support rich Agent messages
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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`.
|
||||
</communication_runtime>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 隐藏列表只负责收敛接口暴露面,不替代权限控制。
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 <command>` 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
|
||||
|
||||
@@ -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- **完成**"
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 == (
|
||||
'<h1>流式结果</h1><ul><li><b>完成</b></li></ul>'
|
||||
)
|
||||
|
||||
|
||||
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 "<h1>处理完成</h1>" in send_kwargs["rich_message"].html
|
||||
assert "<table>" 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 "<h1>更新结果</h1>" 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()
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user