feat(telegram): support rich Agent messages

This commit is contained in:
jxxghp
2026-08-22 10:11:18 +08:00
parent 1c07333909
commit 9273d68aa7
16 changed files with 444 additions and 33 deletions
+82
View File
@@ -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- **完成**"
+10 -1
View File
@@ -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):
+1 -1
View File
@@ -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",
+116
View File
@@ -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()