mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-09 22:43:44 +08:00
feat(agent): support file attachments and local file replies
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user