refactor: unify message ingress forwarding

This commit is contained in:
jxxghp
2026-08-24 03:01:17 +08:00
parent e274672a6a
commit e1230c0277
16 changed files with 463 additions and 127 deletions
+29 -3
View File
@@ -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",
+224
View File
@@ -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 == []