mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: unify message ingress forwarding
This commit is contained in:
+29
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6503,
|
||||
"edge_sha256": "57c326a81dbba07909871a97df1c73416850205c3d385a15afe3a07e3e44c00c",
|
||||
"edge_count": 6528,
|
||||
"edge_sha256": "2e30c775e78f578d804f69972ab1348e1e9d0872b00bdbfcffad08398fae68ac",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -2605,6 +2605,12 @@
|
||||
"app.application.messaging.chat -> app.schemas",
|
||||
"app.application.messaging.chat -> app.schemas.agent",
|
||||
"app.application.messaging.chat -> app.schemas.exception",
|
||||
"app.application.messaging.ingress -> app.adapters",
|
||||
"app.application.messaging.ingress -> app.adapters.network",
|
||||
"app.application.messaging.ingress -> app.adapters.network.http",
|
||||
"app.application.messaging.ingress -> app.runtime",
|
||||
"app.application.messaging.ingress -> app.runtime.log",
|
||||
"app.application.messaging.ingress -> app.runtime.settings",
|
||||
"app.application.messaging.interaction -> app.schemas",
|
||||
"app.application.messaging.interaction -> app.schemas.message",
|
||||
"app.application.messaging.interaction -> app.schemas.notification",
|
||||
@@ -4036,6 +4042,9 @@
|
||||
"app.modules.discord -> app.schemas.message",
|
||||
"app.modules.discord -> app.schemas.notification",
|
||||
"app.modules.discord -> app.schemas.types",
|
||||
"app.modules.discord.discord -> app.application",
|
||||
"app.modules.discord.discord -> app.application.messaging",
|
||||
"app.modules.discord.discord -> app.application.messaging.ingress",
|
||||
"app.modules.discord.discord -> app.domain",
|
||||
"app.modules.discord.discord -> app.domain.context",
|
||||
"app.modules.discord.discord -> app.domain.metainfo",
|
||||
@@ -4140,6 +4149,7 @@
|
||||
"app.modules.feishu.feishu -> app.application",
|
||||
"app.modules.feishu.feishu -> app.application.messaging",
|
||||
"app.modules.feishu.feishu -> app.application.messaging.agent",
|
||||
"app.modules.feishu.feishu -> app.application.messaging.ingress",
|
||||
"app.modules.feishu.feishu -> app.application.security",
|
||||
"app.modules.feishu.feishu -> app.application.security.user",
|
||||
"app.modules.feishu.feishu -> app.domain",
|
||||
@@ -4147,6 +4157,7 @@
|
||||
"app.modules.feishu.feishu -> app.runtime",
|
||||
"app.modules.feishu.feishu -> app.runtime.log",
|
||||
"app.modules.feishu.feishu -> app.runtime.settings",
|
||||
"app.modules.feishu.feishu -> app.runtime.thread",
|
||||
"app.modules.feishu.feishu -> app.schemas",
|
||||
"app.modules.feishu.feishu -> app.schemas.message",
|
||||
"app.modules.feishu.feishu -> app.schemas.types",
|
||||
@@ -4865,6 +4876,9 @@
|
||||
"app.modules.qqbot.qqbot -> app.adapters",
|
||||
"app.modules.qqbot.qqbot -> app.adapters.network",
|
||||
"app.modules.qqbot.qqbot -> app.adapters.network.http",
|
||||
"app.modules.qqbot.qqbot -> app.application",
|
||||
"app.modules.qqbot.qqbot -> app.application.messaging",
|
||||
"app.modules.qqbot.qqbot -> app.application.messaging.ingress",
|
||||
"app.modules.qqbot.qqbot -> app.domain",
|
||||
"app.modules.qqbot.qqbot -> app.domain.context",
|
||||
"app.modules.qqbot.qqbot -> app.domain.metainfo",
|
||||
@@ -4878,6 +4892,7 @@
|
||||
"app.modules.qqbot.qqbot -> app.runtime.cache",
|
||||
"app.modules.qqbot.qqbot -> app.runtime.log",
|
||||
"app.modules.qqbot.qqbot -> app.runtime.settings",
|
||||
"app.modules.qqbot.qqbot -> app.runtime.thread",
|
||||
"app.modules.redis -> app.adapters",
|
||||
"app.modules.redis -> app.adapters.cache",
|
||||
"app.modules.redis -> app.adapters.cache.redis",
|
||||
@@ -4922,6 +4937,9 @@
|
||||
"app.modules.slack.slack -> app.adapters",
|
||||
"app.modules.slack.slack -> app.adapters.network",
|
||||
"app.modules.slack.slack -> app.adapters.network.http",
|
||||
"app.modules.slack.slack -> app.application",
|
||||
"app.modules.slack.slack -> app.application.messaging",
|
||||
"app.modules.slack.slack -> app.application.messaging.ingress",
|
||||
"app.modules.slack.slack -> app.domain",
|
||||
"app.modules.slack.slack -> app.domain.context",
|
||||
"app.modules.slack.slack -> app.domain.metainfo",
|
||||
@@ -4993,6 +5011,8 @@
|
||||
"app.modules.telegram.telegram -> app.adapters.network.http",
|
||||
"app.modules.telegram.telegram -> app.application",
|
||||
"app.modules.telegram.telegram -> app.application.image",
|
||||
"app.modules.telegram.telegram -> app.application.messaging",
|
||||
"app.modules.telegram.telegram -> app.application.messaging.ingress",
|
||||
"app.modules.telegram.telegram -> app.domain",
|
||||
"app.modules.telegram.telegram -> app.domain.context",
|
||||
"app.modules.telegram.telegram -> app.domain.metainfo",
|
||||
@@ -5395,6 +5415,7 @@
|
||||
"app.modules.wechat.wechatbot -> app.application",
|
||||
"app.modules.wechat.wechatbot -> app.application.messaging",
|
||||
"app.modules.wechat.wechatbot -> app.application.messaging.agent",
|
||||
"app.modules.wechat.wechatbot -> app.application.messaging.ingress",
|
||||
"app.modules.wechat.wechatbot -> app.domain",
|
||||
"app.modules.wechat.wechatbot -> app.domain.context",
|
||||
"app.modules.wechat.wechatbot -> app.domain.metainfo",
|
||||
@@ -5404,6 +5425,7 @@
|
||||
"app.modules.wechat.wechatbot -> app.runtime.cache",
|
||||
"app.modules.wechat.wechatbot -> app.runtime.log",
|
||||
"app.modules.wechat.wechatbot -> app.runtime.settings",
|
||||
"app.modules.wechat.wechatbot -> app.runtime.thread",
|
||||
"app.modules.wechat.wechatbot -> app.schemas",
|
||||
"app.modules.wechat.wechatbot -> app.schemas.message",
|
||||
"app.modules.wechat.wechatbot -> app.schemas.types",
|
||||
@@ -5424,6 +5446,9 @@
|
||||
"app.modules.wechatclawbot.wechatclawbot -> app.adapters",
|
||||
"app.modules.wechatclawbot.wechatclawbot -> app.adapters.network",
|
||||
"app.modules.wechatclawbot.wechatclawbot -> app.adapters.network.http",
|
||||
"app.modules.wechatclawbot.wechatclawbot -> app.application",
|
||||
"app.modules.wechatclawbot.wechatclawbot -> app.application.messaging",
|
||||
"app.modules.wechatclawbot.wechatclawbot -> app.application.messaging.ingress",
|
||||
"app.modules.wechatclawbot.wechatclawbot -> app.domain",
|
||||
"app.modules.wechatclawbot.wechatclawbot -> app.domain.context",
|
||||
"app.modules.wechatclawbot.wechatclawbot -> app.domain.metainfo",
|
||||
@@ -6520,7 +6545,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 805,
|
||||
"module_count": 806,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -6791,6 +6816,7 @@
|
||||
"app.application.messaging",
|
||||
"app.application.messaging.agent",
|
||||
"app.application.messaging.chat",
|
||||
"app.application.messaging.ingress",
|
||||
"app.application.messaging.interaction",
|
||||
"app.application.messaging.media",
|
||||
"app.application.messaging.message",
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""多消息渠道复用统一宿主回环入口的契约测试。"""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.messaging import ingress
|
||||
from app.modules.discord import discord as discord_module
|
||||
from app.modules.feishu import feishu as feishu_module
|
||||
from app.modules.qqbot import qqbot as qqbot_module
|
||||
from app.modules.slack import slack as slack_module
|
||||
from app.modules.telegram import telegram as telegram_module
|
||||
from app.modules.wechat import wechatbot as wechat_module
|
||||
from app.modules.wechatclawbot import wechatclawbot as clawbot_module
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_forward_message_to_host_encodes_source_and_closes_response(monkeypatch):
|
||||
"""统一入口必须安全编码查询参数并释放本地 HTTP 响应。"""
|
||||
response = SimpleNamespace(status_code=200, close=MagicMock())
|
||||
post_res = MagicMock(return_value=response)
|
||||
request = MagicMock()
|
||||
request.post_res = post_res
|
||||
request_factory = MagicMock(return_value=request)
|
||||
monkeypatch.setattr(
|
||||
ingress,
|
||||
"settings",
|
||||
SimpleNamespace(PORT=3000, API_TOKEN="token value"),
|
||||
)
|
||||
monkeypatch.setattr(ingress, "RequestUtils", request_factory)
|
||||
|
||||
assert ingress.forward_message_to_host(
|
||||
{"text": "hello"},
|
||||
"channel & one",
|
||||
timeout=9,
|
||||
) is True
|
||||
|
||||
request_factory.assert_called_once_with(timeout=9)
|
||||
url = post_res.call_args.args[0]
|
||||
assert urlparse(url).path == "/api/v1/message"
|
||||
assert parse_qs(urlparse(url).query) == {
|
||||
"token": ["token value"],
|
||||
"source": ["channel & one"],
|
||||
}
|
||||
assert post_res.call_args.kwargs["json"] == {"text": "hello"}
|
||||
response.close.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code", [400, 500])
|
||||
def test_forward_message_to_host_rejects_unconfirmed_response(
|
||||
monkeypatch,
|
||||
status_code,
|
||||
):
|
||||
"""本地入口无响应或返回错误状态时不得宣称渠道消息已接收。"""
|
||||
response = SimpleNamespace(status_code=status_code, close=MagicMock())
|
||||
request = MagicMock()
|
||||
request.post_res.return_value = response
|
||||
monkeypatch.setattr(
|
||||
ingress,
|
||||
"settings",
|
||||
SimpleNamespace(PORT=3000, API_TOKEN="token"),
|
||||
)
|
||||
monkeypatch.setattr(ingress, "RequestUtils", MagicMock(return_value=request))
|
||||
|
||||
assert ingress.forward_message_to_host({}, "channel") is False
|
||||
response.close.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_forward_message_to_host_uses_same_contract(monkeypatch):
|
||||
"""自有事件循环的渠道必须复用同一 URL、确认规则和异步资源释放。"""
|
||||
response = SimpleNamespace(status_code=200, aclose=AsyncMock())
|
||||
request = MagicMock()
|
||||
request.post_res = AsyncMock(return_value=response)
|
||||
request_factory = MagicMock(return_value=request)
|
||||
monkeypatch.setattr(
|
||||
ingress,
|
||||
"settings",
|
||||
SimpleNamespace(PORT=3000, API_TOKEN="token value"),
|
||||
)
|
||||
monkeypatch.setattr(ingress, "AsyncRequestUtils", request_factory)
|
||||
|
||||
assert await ingress.async_forward_message_to_host(
|
||||
{"text": "hello"},
|
||||
"discord & one",
|
||||
timeout=10,
|
||||
) is True
|
||||
|
||||
request_factory.assert_called_once_with(timeout=10)
|
||||
url = request.post_res.await_args.args[0]
|
||||
assert parse_qs(urlparse(url).query) == {
|
||||
"token": ["token value"],
|
||||
"source": ["discord & one"],
|
||||
}
|
||||
response.aclose.assert_awaited_once_with()
|
||||
|
||||
|
||||
def test_submit_message_to_host_copies_payload_and_reports_admission_failure():
|
||||
"""异步渠道提交时冻结顶层 payload,执行器拒绝任务则返回 False。"""
|
||||
submitted = []
|
||||
|
||||
def submit(function, *args, **kwargs):
|
||||
"""记录受管执行器收到的函数和参数。"""
|
||||
submitted.append((function, args, kwargs))
|
||||
|
||||
payload = {"text": "before"}
|
||||
assert ingress.submit_message_to_host(
|
||||
payload,
|
||||
"channel",
|
||||
submit=submit,
|
||||
) is True
|
||||
payload["text"] = "after"
|
||||
|
||||
assert submitted[0][0] is ingress.forward_message_to_host
|
||||
assert submitted[0][1] == ({"text": "before"}, "channel")
|
||||
assert submitted[0][2] == {"timeout": 15}
|
||||
|
||||
def reject(*_args, **_kwargs):
|
||||
"""模拟生命周期关闭后的执行器拒绝新任务。"""
|
||||
raise RuntimeError("executor closed")
|
||||
|
||||
assert ingress.submit_message_to_host({}, "channel", submit=reject) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "client_type", "source_attr"),
|
||||
[
|
||||
(feishu_module, feishu_module.Feishu, "_name"),
|
||||
(qqbot_module, qqbot_module.QQBot, "_config_name"),
|
||||
(wechat_module, wechat_module.WeChatBot, "_config_name"),
|
||||
],
|
||||
)
|
||||
def test_threaded_channels_submit_through_managed_executor(
|
||||
monkeypatch,
|
||||
module,
|
||||
client_type,
|
||||
source_attr,
|
||||
):
|
||||
"""原裸线程渠道必须把回环任务交给共享 ThreadHelper。"""
|
||||
calls = []
|
||||
executor = SimpleNamespace(submit=lambda *_args, **_kwargs: None)
|
||||
|
||||
def submit_message(payload, source, *, submit, timeout=15):
|
||||
"""记录渠道传给统一提交边界的参数。"""
|
||||
calls.append((payload, source, submit, timeout))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(module, "ThreadHelper", lambda: executor)
|
||||
monkeypatch.setattr(module, "submit_message_to_host", submit_message)
|
||||
client = object.__new__(client_type)
|
||||
setattr(client, source_attr, "channel-main")
|
||||
|
||||
assert client._forward_to_message_chain({"text": "hello"}) is True
|
||||
assert calls == [({"text": "hello"}, "channel-main", executor.submit, 15)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "client_type", "source_attr"),
|
||||
[
|
||||
(telegram_module, telegram_module.Telegram, "_config_name"),
|
||||
(clawbot_module, clawbot_module.WechatClawBot, "_config_name"),
|
||||
],
|
||||
)
|
||||
def test_sync_channels_forward_through_shared_ingress(
|
||||
monkeypatch,
|
||||
module,
|
||||
client_type,
|
||||
source_attr,
|
||||
):
|
||||
"""同步轮询渠道必须复用统一回环请求和确认语义。"""
|
||||
forward = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(module, "forward_message_to_host", forward)
|
||||
client = object.__new__(client_type)
|
||||
setattr(client, source_attr, "channel-main")
|
||||
|
||||
assert client._forward_to_message_chain({"text": "hello"}) is True
|
||||
forward.assert_called_once_with({"text": "hello"}, "channel-main")
|
||||
|
||||
|
||||
def test_slack_preserves_callback_timeout_through_shared_ingress(monkeypatch):
|
||||
"""Slack action 的历史长超时必须继续传给统一入口。"""
|
||||
forward = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(slack_module, "forward_message_to_host", forward)
|
||||
client = object.__new__(slack_module.Slack)
|
||||
client._config_name = "slack-main"
|
||||
|
||||
assert client._forward_to_message_chain({"action": "run"}, timeout=60) is True
|
||||
forward.assert_called_once_with(
|
||||
{"action": "run"},
|
||||
"slack-main",
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_uses_shared_async_ingress(monkeypatch):
|
||||
"""Discord 自有事件循环不得继续维护独立 httpx 回环实现。"""
|
||||
forward = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(discord_module, "async_forward_message_to_host", forward)
|
||||
client = object.__new__(discord_module.Discord)
|
||||
client._config_name = "discord-main"
|
||||
|
||||
await client._post_to_ds({"text": "hello"})
|
||||
|
||||
forward.assert_awaited_once_with(
|
||||
{"text": "hello"},
|
||||
"discord-main",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def test_message_modules_cannot_reimplement_loopback_endpoint():
|
||||
"""消息模块不得重新拼接宿主 URL,新增渠道必须复用统一 ingress。"""
|
||||
violations = []
|
||||
for path in (PROJECT_ROOT / "app" / "modules").rglob("*.py"):
|
||||
if "/api/v1/message" in path.read_text(encoding="utf-8-sig"):
|
||||
violations.append(path.relative_to(PROJECT_ROOT).as_posix())
|
||||
|
||||
assert violations == []
|
||||
Reference in New Issue
Block a user