feat(agent): support file attachments and local file replies

This commit is contained in:
jxxghp
2026-04-14 15:22:01 +08:00
parent 81828948dd
commit 7a5e513f25
18 changed files with 1268 additions and 84 deletions

View File

@@ -1,6 +1,8 @@
import base64
import json
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from urllib.parse import quote
@@ -8,6 +10,7 @@ from urllib.parse import quote
from telebot import apihelper
from app.agent.tools.impl.send_message import SendMessageInput
from app.agent.tools.impl.send_local_file import SendLocalFileInput
from app.agent import MoviePilotAgent, AgentChain
from app.chain.message import MessageChain
from app.core.config import settings
@@ -161,6 +164,32 @@ class AgentImageSupportTest(unittest.TestCase):
self.assertEqual(handle_kwargs["text"], "")
self.assertEqual(handle_kwargs["audio_refs"], ["tg://voice_file_id/voice-1"])
def test_process_allows_file_only_message(self):
chain = MessageChain()
message = CommingMessage(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
files=[
CommingMessage.MessageAttachment(
ref="tg://document_file_id/doc-1",
name="note.txt",
mime_type="text/plain",
size=12,
)
],
)
with patch.object(chain, "message_parser", return_value=message), patch.object(
chain, "handle_message"
) as handle_message:
chain.process(body="{}", form={}, args={"source": "telegram-test"})
handle_kwargs = handle_message.call_args.kwargs
self.assertEqual(handle_kwargs["text"], "")
self.assertEqual(handle_kwargs["files"][0].ref, "tg://document_file_id/doc-1")
def test_image_message_routes_to_agent_even_when_global_agent_is_disabled(self):
chain = MessageChain()
@@ -205,6 +234,36 @@ class AgentImageSupportTest(unittest.TestCase):
self.assertEqual(handle_ai_message.call_args.kwargs["text"], "帮我推荐一部电影")
self.assertTrue(handle_ai_message.call_args.kwargs["reply_with_voice"])
def test_file_message_routes_to_agent_even_when_global_agent_is_disabled(self):
chain = MessageChain()
with patch.object(chain, "load_cache", return_value={}), patch.object(
chain.messagehelper, "put"
), patch.object(chain.messageoper, "add"), patch.object(
chain, "_handle_ai_message"
) as handle_ai_message, patch.object(
settings, "AI_AGENT_ENABLE", True
), patch.object(
settings, "AI_AGENT_GLOBAL", False
):
chain.handle_message(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
text="",
files=[
CommingMessage.MessageAttachment(
ref="tg://document_file_id/doc-1",
name="report.txt",
mime_type="text/plain",
)
],
)
handle_ai_message.assert_called_once()
self.assertEqual(handle_ai_message.call_args.kwargs["files"][0].name, "report.txt")
def test_transcribe_audio_refs_supports_new_channel_refs(self):
chain = MessageChain()
audio_refs = [
@@ -276,6 +335,41 @@ class AgentImageSupportTest(unittest.TestCase):
self.assertIsNone(notification.voice_path)
self.assertEqual(notification.text, "这是语音回复")
def test_agent_process_wraps_request_as_structured_json(self):
agent = MoviePilotAgent(
session_id="session-1",
user_id="user-1",
channel=MessageChannel.Telegram.value,
source="telegram-test",
username="tester",
)
with patch(
"app.agent.memory.memory_manager.get_agent_messages", return_value=[]
), patch.object(agent, "_execute_agent", new_callable=AsyncMock) as execute_agent:
import asyncio
asyncio.run(
agent.process(
"帮我总结这个文件",
files=[
{
"name": "report.txt",
"local_path": "/tmp/report.txt",
"status": "ready",
}
],
)
)
messages = execute_agent.await_args.args[0]
human_message = messages[-1]
content = human_message.content
self.assertIsInstance(content, list)
payload = json.loads(content[0]["text"])
self.assertEqual(payload["message"], "帮我总结这个文件")
self.assertEqual(payload["files"][0]["local_path"], "/tmp/report.txt")
def test_slack_images_use_authenticated_data_url_download(self):
chain = MessageChain()
@@ -345,6 +439,15 @@ class AgentImageSupportTest(unittest.TestCase):
self.assertEqual(payload.image_url, "https://example.com/poster.png")
def test_send_local_file_input_accepts_file_payload(self):
payload = SendLocalFileInput(
explanation="send generated report",
file_path="/tmp/report.txt",
message="请下载查看",
)
self.assertEqual(payload.file_path, "/tmp/report.txt")
def test_discord_extract_images_supports_attachment_content_type(self):
images = DiscordModule._extract_images(
{
@@ -380,6 +483,26 @@ class AgentImageSupportTest(unittest.TestCase):
],
)
def test_discord_extract_files_supports_non_media_attachment(self):
files = DiscordModule._extract_files(
{
"attachments": [
{
"content_type": "application/pdf",
"filename": "guide.pdf",
"url": "https://cdn.discordapp.com/guide.pdf",
"size": 1024,
}
]
}
)
self.assertEqual(files[0].name, "guide.pdf")
self.assertEqual(
files[0].ref,
"discord://file/" + quote("https://cdn.discordapp.com/guide.pdf", safe=""),
)
def test_discord_send_direct_message_returns_chat_id(self):
module = DiscordModule()
client = Mock()
@@ -466,6 +589,46 @@ class AgentImageSupportTest(unittest.TestCase):
self.assertIsNotNone(message)
self.assertEqual(message.images, ["wxwork://media_id/media-1"])
def test_wechat_message_parser_extracts_file_media_id(self):
module = WechatModule()
xml_message = b"""
<xml>
<FromUserName><![CDATA[user-1]]></FromUserName>
<MsgType><![CDATA[file]]></MsgType>
<MediaId><![CDATA[file-media-1]]></MediaId>
<FileName><![CDATA[manual.pdf]]></FileName>
</xml>
"""
crypt = Mock()
crypt.DecryptMsg.return_value = (0, xml_message)
with patch.object(
module,
"get_config",
return_value=SimpleNamespace(
name="wechat-test",
config={
"WECHAT_TOKEN": "token",
"WECHAT_ENCODING_AESKEY": "encoding",
"WECHAT_CORPID": "corpid",
},
),
), patch.object(
module, "get_instance", return_value=SimpleNamespace(send_msg=Mock())
), patch(
"app.modules.wechat.WXBizMsgCrypt",
return_value=crypt,
):
message = module.message_parser(
source="wechat-test",
body=b"encrypted",
form={},
args={"msg_signature": "sig", "timestamp": "1", "nonce": "n"},
)
self.assertIsNotNone(message)
self.assertEqual(message.files[0].ref, "wxwork://file_media_id/file-media-1")
def test_wechat_bot_parser_accepts_image_only_payload(self):
module = WechatModule()
body = json.dumps(
@@ -594,6 +757,38 @@ class AgentImageSupportTest(unittest.TestCase):
["vocechat://file/%2Fuploads%2Fvoice.ogg"],
)
def test_vocechat_message_parser_extracts_generic_file_payload(self):
module = VoceChatModule()
body = json.dumps(
{
"detail": {
"type": "normal",
"content_type": "vocechat/file",
"content": "/uploads/manual.pdf",
"properties": {"content_type": "application/pdf"},
},
"from_uid": 7910,
"target": {"gid": 2},
}
)
with patch.object(
module,
"get_config",
return_value=SimpleNamespace(
name="vocechat-test", config={"channel_id": "2"}
),
):
message = module.message_parser(
source="vocechat-test",
body=body,
form={},
args={},
)
self.assertIsNotNone(message)
self.assertEqual(message.files[0].ref, "vocechat://file/%2Fuploads%2Fmanual.pdf")
def test_vocechat_post_message_passes_image_and_correct_target(self):
module = VoceChatModule()
client = Mock()
@@ -623,6 +818,64 @@ class AgentImageSupportTest(unittest.TestCase):
link=None,
)
def test_slack_post_message_passes_local_file(self):
module = SlackModule()
client = Mock()
with tempfile.TemporaryDirectory() as tempdir:
file_path = Path(tempdir) / "guide.pdf"
file_path.write_bytes(b"pdf")
with patch.object(
module,
"get_configs",
return_value={"slack-test": SimpleNamespace(name="slack-test")},
), patch.object(
module, "check_message", return_value=True
), patch.object(
module, "get_instance", return_value=client
):
module.post_message(
Notification(
title="手册",
text="请下载",
file_path=str(file_path),
file_name="guide.pdf",
userid="U123",
)
)
client.send_file.assert_called_once()
def test_discord_post_message_passes_local_file(self):
module = DiscordModule()
client = Mock()
with tempfile.TemporaryDirectory() as tempdir:
file_path = Path(tempdir) / "guide.pdf"
file_path.write_bytes(b"pdf")
with patch.object(
module,
"get_configs",
return_value={"discord-test": SimpleNamespace(name="discord-test")},
), patch.object(
module, "check_message", return_value=True
), patch.object(
module, "get_instance", return_value=client
):
module.post_message(
Notification(
title="手册",
text="请下载",
file_path=str(file_path),
file_name="guide.pdf",
userid="user-1",
)
)
client.send_file.assert_called_once()
def test_qq_message_parser_accepts_image_only_attachment(self):
module = QQBotModule()
@@ -745,5 +998,97 @@ class AgentImageSupportTest(unittest.TestCase):
["synology://file/" + quote("https://example.com/voice.ogg", safe="")],
)
def test_synology_message_parser_accepts_generic_file_attachment(self):
module = SynologyChatModule()
with patch.object(
module,
"get_config",
return_value=SimpleNamespace(name="synology-test", config={}),
), patch.object(
module,
"get_instance",
return_value=SimpleNamespace(check_token=lambda token: token == "token-1"),
):
message = module.message_parser(
source="synology-test",
body={},
form={
"token": "token-1",
"user_id": "42",
"username": "tester",
"attachments": json.dumps(
[
{
"url": "https://example.com/manual.pdf",
"content_type": "application/pdf",
"filename": "manual.pdf",
}
]
),
},
args={},
)
self.assertIsNotNone(message)
self.assertEqual(
message.files[0].ref,
"synology://file/" + quote("https://example.com/manual.pdf", safe=""),
)
def test_prepare_agent_files_saves_local_file(self):
chain = MessageChain()
with tempfile.TemporaryDirectory() as tempdir, patch.object(
settings, "TEMP_PATH", Path(tempdir)
), patch.object(
chain,
"_download_message_file_bytes",
return_value="你好MoviePilot".encode("utf-8"),
):
prepared = chain._prepare_agent_files(
session_id="session-1",
files=[
CommingMessage.MessageAttachment(
ref="tg://document_file_id/doc-1",
name="note.txt",
mime_type="text/plain",
)
],
channel=MessageChannel.Telegram,
source="telegram-test",
)
self.assertEqual(prepared[0]["status"], "ready")
self.assertTrue(Path(prepared[0]["local_path"]).exists())
def test_telegram_post_message_passes_file_to_client(self):
module = TelegramModule()
client = Mock()
with tempfile.TemporaryDirectory() as tempdir:
file_path = Path(tempdir) / "report.txt"
file_path.write_text("hello", encoding="utf-8")
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(
Notification(
title="报告",
text="请下载",
file_path=str(file_path),
file_name="report.txt",
userid="user-1",
)
)
client.send_file.assert_called_once()
if __name__ == "__main__":
unittest.main()