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
+1 -1
View File
@@ -67,7 +67,7 @@ The legacy roots have no physical directories in the source tree. Current images
| `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `display/`, `stdio.py`, `package.py`, `resource.py`, `rust.py`, `fsproxy.py` | | `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `display/`, `stdio.py`, `package.py`, `resource.py`, `rust.py`, `fsproxy.py` |
| `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py`, `wechat_crypt.py` | | `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py`, `wechat_crypt.py` |
| `app/application/` | 聚焦应用服务、用例命令,以及由用例拥有的持久化 Port/Protocol | SQLAlchemy、Session、Oper 等具体 DB 实现,多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `outbox.py`, `subscription/write.py`, `workflow.py` | | `app/application/` | 聚焦应用服务、用例命令,以及由用例拥有的持久化 Port/Protocol | SQLAlchemy、Session、Oper 等具体 DB 实现,多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `outbox.py`, `subscription/write.py`, `workflow.py` |
| `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接:`interaction.py` 通用交互契约和视图工具;`router.py` 统一交互优先级和回调分发;`site.py`/`subscribe.py`/`skill.py` 对应命令的会话、输入解析和视图;`media.py` 媒体交互状态(业务工作流仍由 `MediaInteractionChain` 执行);`plugin.py` 插件输入接管和插件按钮回调;`agent.py` Agent 选择状态、回调协议和 WebAgent 消息桥接;`message.py` 通知渲染、模板和队列。不作为推荐给插件直接使用的公开 SDK | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `message.py`, `interaction.py`, `router.py`, `agent.py` | | `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接:`ingress.py` 统一渠道回环入口;`interaction.py` 通用交互契约和视图工具;`router.py` 统一交互优先级和回调分发;`site.py`/`subscribe.py`/`skill.py` 对应命令的会话、输入解析和视图;`media.py` 媒体交互状态(业务工作流仍由 `MediaInteractionChain` 执行);`plugin.py` 插件输入接管和插件按钮回调;`agent.py` Agent 选择状态、回调协议和 WebAgent 消息桥接;`message.py` 通知渲染、模板和队列。不作为推荐给插件直接使用的公开 SDK | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `ingress.py`, `message.py`, `interaction.py`, `router.py`, `agent.py` |
| `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、路径/URL 安全、SSRF 和签名策略 | 通用 URL 解析、进程运行策略、普通业务校验 | `access.py`, `auth.py`, `cookie.py`, `passkey.py`, `otp.py`, `twofactor.py`, `url.py` | | `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、路径/URL 安全、SSRF 和签名策略 | 通用 URL 解析、进程运行策略、普通业务校验 | `access.py`, `auth.py`, `cookie.py`, `passkey.py`, `otp.py`, `twofactor.py`, `url.py` |
| `app/chain/` | Reusable use-case orchestration across modules, Application services, injected ports, events, and caches; chains reach modules only through `run_module` dispatch on method-name contracts | Transport schemas, backend-specific protocol details, generic primitives, direct Oper/DB imports, direct imports of module internals (classes, exceptions, constants) | `media.py`, `download.py`, `subscribe.py`, `transfer.py` | | `app/chain/` | Reusable use-case orchestration across modules, Application services, injected ports, events, and caches; chains reach modules only through `run_module` dispatch on method-name contracts | Transport schemas, backend-specific protocol details, generic primitives, direct Oper/DB imports, direct imports of module internals (classes, exceptions, constants) | `media.py`, `download.py`, `subscribe.py`, `transfer.py` |
| `app/db/oper/` | 面向表和持久化值的 SQLAlchemy 数据访问;接收调用方 Session,只查询、暂存或 flush | Application 业务规则、隐式事务所有权、外部副作用 | `subscribe.py`, `site.py`, `workflow.py` | | `app/db/oper/` | 面向表和持久化值的 SQLAlchemy 数据访问;接收调用方 Session,只查询、暂存或 flush | Application 业务规则、隐式事务所有权、外部副作用 | `subscribe.py`, `site.py`, `workflow.py` |
+115
View File
@@ -0,0 +1,115 @@
"""消息渠道回环进入宿主消息 API 的统一适配边界。"""
from collections.abc import Callable, Mapping
from typing import Any
from urllib.parse import urlencode
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
from app.runtime.log import logger
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
BackgroundSubmitter = Callable[..., object]
def build_message_ingress_url(source: str | None) -> str:
"""按当前运行配置构造安全编码的本地消息入口 URL。"""
query = {"token": settings.API_TOKEN}
if source:
query["source"] = source
return f"http://127.0.0.1:{settings.PORT}/api/v1/message?{urlencode(query)}"
def forward_message_to_host(
payload: Mapping[str, Any],
source: str | None,
*,
timeout: float = 15,
) -> bool:
"""同步转发渠道 payload,统一判断本地入口是否确认接收。"""
response = None
try:
response = RequestUtils(timeout=timeout).post_res(
build_message_ingress_url(source),
json=dict(payload),
)
if response is None:
logger.error(f"转发渠道消息到本地入口失败:source={source or '-'} - 无响应")
return False
if response.status_code >= 400:
logger.error(
"转发渠道消息到本地入口失败:"
f"source={source or '-'} - HTTP {response.status_code}"
)
return False
return True
except Exception as error:
logger.error(f"转发渠道消息到本地入口失败:source={source or '-'} - {error}")
return False
finally:
close = getattr(response, "close", None)
if callable(close):
try:
close()
except Exception as error:
logger.debug(
f"释放本地消息入口响应失败:source={source or '-'} - {error}"
)
async def async_forward_message_to_host(
payload: Mapping[str, Any],
source: str | None,
*,
timeout: float = 15,
) -> bool:
"""异步转发渠道 payload,供自有事件循环的消息 SDK 复用同一确认语义。"""
response = None
try:
response = await AsyncRequestUtils(timeout=timeout).post_res(
build_message_ingress_url(source),
json=dict(payload),
)
if response is None:
logger.error(f"转发渠道消息到本地入口失败:source={source or '-'} - 无响应")
return False
if response.status_code >= 400:
logger.error(
"转发渠道消息到本地入口失败:"
f"source={source or '-'} - HTTP {response.status_code}"
)
return False
return True
except Exception as error:
logger.error(f"转发渠道消息到本地入口失败:source={source or '-'} - {error}")
return False
finally:
if response is not None:
try:
await response.aclose()
except Exception as error:
logger.debug(
f"释放本地消息入口响应失败:source={source or '-'} - {error}"
)
def submit_message_to_host(
payload: Mapping[str, Any],
source: str | None,
*,
submit: BackgroundSubmitter,
timeout: float = 15,
) -> bool:
"""把同步回环转发提交给调用方注入的受管后台执行器。"""
try:
submit(
forward_message_to_host,
dict(payload),
source,
timeout=timeout,
)
except Exception as error:
logger.error(f"提交渠道消息转发任务失败:source={source or '-'} - {error}")
return False
return True
+9 -20
View File
@@ -3,16 +3,15 @@ import re
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple, Union from typing import Optional, List, Dict, Any, Tuple, Union
from urllib.parse import quote
import discord import discord
from discord import app_commands from discord import app_commands
import httpx
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import async_forward_message_to_host
from app.domain.context import MediaInfo, Context from app.domain.context import MediaInfo, Context
from app.domain.metainfo import MetaInfo from app.domain.metainfo import MetaInfo
from app.runtime.log import logger from app.runtime.log import logger
@@ -60,17 +59,10 @@ class Discord:
self._token = DISCORD_BOT_TOKEN self._token = DISCORD_BOT_TOKEN
self._guild_id = self._to_int(DISCORD_GUILD_ID) self._guild_id = self._to_int(DISCORD_GUILD_ID)
self._channel_id = self._to_int(DISCORD_CHANNEL_ID) self._channel_id = self._to_int(DISCORD_CHANNEL_ID)
self._config_name = kwargs.get("name")
logger.debug( logger.debug(
f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}" f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}"
) )
base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/"
self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}"
if kwargs.get("name"):
# URL encode the source name to handle special characters in config names
encoded_name = quote(kwargs.get("name"), safe="")
self._ds_url = f"{self._ds_url}&source={encoded_name}"
logger.debug(f"[Discord] 消息回调 URL: {self._ds_url}")
intents = discord.Intents.default() intents = discord.Intents.default()
intents.message_content = True intents.message_content = True
intents.messages = True intents.messages = True
@@ -1313,13 +1305,10 @@ class Discord:
return content return content
async def _post_to_ds(self, payload: Dict[str, Any]) -> None: async def _post_to_ds(self, payload: Dict[str, Any]) -> None:
try: """把 Discord 事件异步转交统一消息入口。"""
proxy = None if not await async_forward_message_to_host(
if settings.PROXY: payload,
proxy = settings.PROXY.get("https") or settings.PROXY.get("http") self._config_name,
async with httpx.AsyncClient( timeout=10,
timeout=10, verify=False, proxy=proxy ):
) as client: logger.error("转发 Discord 消息失败")
await client.post(self._ds_url, json=payload)
except Exception as err:
logger.error(f"转发 Discord 消息失败:{err}")
+7 -11
View File
@@ -53,6 +53,7 @@ from lark_oapi.event.callback.model.p2_card_action_trigger import (
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import submit_message_to_host
from app.domain.context import Context, MediaInfo from app.domain.context import Context, MediaInfo
from app.application.security.user import get_configured_user_channel_lookup from app.application.security.user import get_configured_user_channel_lookup
from app.application.messaging.agent import matches_channel_admin from app.application.messaging.agent import matches_channel_admin
@@ -61,6 +62,7 @@ from app.schemas.message import IncomingMessage
from app.schemas.message import Message from app.schemas.message import Message
from app.schemas.types import NotificationChannel, MessageType from app.schemas.types import NotificationChannel, MessageType
from app.adapters.network.http import RequestUtils from app.adapters.network.http import RequestUtils
from app.runtime.thread import ThreadHelper
class UserOper: class UserOper:
@@ -295,19 +297,13 @@ class Feishu:
ws_client._service_id = "" ws_client._service_id = ""
ws_client._lock.release() ws_client._lock.release()
def _forward_to_message_chain(self, payload: dict) -> None: def _forward_to_message_chain(self, payload: dict) -> bool:
"""将飞书入站消息转发到统一消息入口,复用现有交互主链。""" """将飞书入站消息转发到统一消息入口,复用现有交互主链。"""
return submit_message_to_host(
def _run() -> None: payload,
try: self._name,
RequestUtils(timeout=15).post_res( submit=ThreadHelper().submit,
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}&source={self._name}",
json=payload,
) )
except Exception as err:
logger.error(f"飞书转发消息失败:{err}")
threading.Thread(target=_run, daemon=True).start()
@staticmethod @staticmethod
def _parse_message_content(message) -> Tuple[ def _parse_message_content(message) -> Tuple[
+8 -13
View File
@@ -15,9 +15,11 @@ from app.runtime.cache import FileCache
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import submit_message_to_host
from app.domain.context import MediaInfo, Context from app.domain.context import MediaInfo, Context
from app.domain.metainfo import MetaInfo from app.domain.metainfo import MetaInfo
from app.runtime.log import logger from app.runtime.log import logger
from app.runtime.thread import ThreadHelper
from app.modules.qqbot.api import ( from app.modules.qqbot.api import (
get_access_token, get_access_token,
get_gateway_url, get_gateway_url,
@@ -103,20 +105,13 @@ class QQBot:
except Exception as e: except Exception as e:
logger.debug(f"QQ Bot 保存 known_targets 失败: {e}") logger.debug(f"QQ Bot 保存 known_targets 失败: {e}")
def _forward_to_message_chain(self, payload: dict) -> None: def _forward_to_message_chain(self, payload: dict) -> bool:
"""直接调用消息链处理,避免 HTTP 开销""" """通过受管线程池把 QQ Bot 入站 payload 转交统一消息入口。"""
return submit_message_to_host(
def _run(): payload,
try: self._config_name,
# 回调 submit=ThreadHelper().submit,
RequestUtils(timeout=15).post_res(
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}&source={self._config_name}",
json=payload
) )
except Exception as e:
logger.error(f"QQ Bot 转发消息失败: {e}")
threading.Thread(target=_run, daemon=True).start()
def _on_gateway_message(self, payload: dict) -> None: def _on_gateway_message(self, payload: dict) -> None:
"""Gateway 收到消息时转发至 MP 消息链,并记录发送者用于广播""" """Gateway 收到消息时转发至 MP 消息链,并记录发送者用于广播"""
+15 -19
View File
@@ -3,9 +3,7 @@ import re
from threading import Lock from threading import Lock
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote
import requests
from slack_bolt import App from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient from slack_sdk import WebClient
@@ -13,6 +11,7 @@ from slack_sdk import WebClient
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import forward_message_to_host
from app.domain.context import MediaInfo, Context from app.domain.context import MediaInfo, Context
from app.domain.metainfo import MetaInfo from app.domain.metainfo import MetaInfo
from app.runtime.log import logger from app.runtime.log import logger
@@ -27,7 +26,6 @@ class Slack:
_client: WebClient = None _client: WebClient = None
_service: SocketModeHandler = None _service: SocketModeHandler = None
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
_channel = "" _channel = ""
_oauth_token = "" _oauth_token = ""
_MAX_SLASH_COMMANDS = 50 _MAX_SLASH_COMMANDS = 50
@@ -65,6 +63,7 @@ class Slack:
self._client = slack_app.client self._client = slack_app.client
self._channel = SLACK_CHANNEL self._channel = SLACK_CHANNEL
self._oauth_token = SLACK_OAUTH_TOKEN self._oauth_token = SLACK_OAUTH_TOKEN
self._config_name = kwargs.get("name")
self._app_id = (SLACK_APP_ID or "").strip() self._app_id = (SLACK_APP_ID or "").strip()
self._command_request_url = (SLACK_COMMAND_REQUEST_URL or "").strip() self._command_request_url = (SLACK_COMMAND_REQUEST_URL or "").strip()
self._manifest_client = ( self._manifest_client = (
@@ -74,41 +73,30 @@ class Slack:
) )
self._registered_command_names: set[str] = set() self._registered_command_names: set[str] = set()
# 标记消息来源
if kwargs.get("name"):
# URL encode the source name to handle special characters
encoded_name = quote(kwargs.get('name'), safe='')
self._ds_url = f"{self._ds_url}&source={encoded_name}"
# 注册消息响应 # 注册消息响应
@slack_app.event("message") @slack_app.event("message")
def slack_message(message): def slack_message(message):
with requests.post(self._ds_url, json=message, timeout=10) as local_res: self._forward_to_message_chain(message, timeout=10)
logger.debug("message: %s processed, response is: %s" % (message, local_res.text))
@slack_app.action(re.compile(r"actionId-.*")) @slack_app.action(re.compile(r"actionId-.*"))
def slack_action(ack, body): def slack_action(ack, body):
ack() ack()
with requests.post(self._ds_url, json=body, timeout=60) as local_res: self._forward_to_message_chain(body, timeout=60)
logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
@slack_app.event("app_mention") @slack_app.event("app_mention")
def slack_mention(say, body): def slack_mention(say, body):
say(f"收到,请稍等... <@{body.get('event', {}).get('user')}>") say(f"收到,请稍等... <@{body.get('event', {}).get('user')}>")
with requests.post(self._ds_url, json=body, timeout=10) as local_res: self._forward_to_message_chain(body, timeout=10)
logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
@slack_app.shortcut(re.compile(r"/*")) @slack_app.shortcut(re.compile(r"/*"))
def slack_shortcut(ack, body): def slack_shortcut(ack, body):
ack() ack()
with requests.post(self._ds_url, json=body, timeout=10) as local_res: self._forward_to_message_chain(body, timeout=10)
logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
@slack_app.command(re.compile(r"/*")) @slack_app.command(re.compile(r"/*"))
def slack_command(ack, body): def slack_command(ack, body):
ack() ack()
with requests.post(self._ds_url, json=body, timeout=10) as local_res: self._forward_to_message_chain(body, timeout=10)
logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
# 启动服务 # 启动服务
try: try:
@@ -121,6 +109,14 @@ class Slack:
except Exception as err: except Exception as err:
logger.error("Slack消息接收服务启动失败: %s" % str(err)) logger.error("Slack消息接收服务启动失败: %s" % str(err))
def _forward_to_message_chain(self, payload: dict, *, timeout: float) -> bool:
"""把 Slack SDK 回调同步转交统一消息入口。"""
return forward_message_to_host(
payload,
self._config_name,
timeout=timeout,
)
def stop(self): def stop(self):
if self._service: if self._service:
try: try:
+9 -17
View File
@@ -6,7 +6,7 @@ import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Union from typing import Any, Callable, Dict, List, Optional, Union
from urllib.parse import urljoin, quote from urllib.parse import urljoin
from app.modules.telegram.compat import ensure_urllib3_header_param_compat from app.modules.telegram.compat import ensure_urllib3_header_param_compat
@@ -42,6 +42,7 @@ settings = RuntimeSettingsCompat()
from app.domain.context import MediaInfo, Context # noqa: E402 from app.domain.context import MediaInfo, Context # noqa: E402
from app.domain.metainfo import MetaInfo # noqa: E402 from app.domain.metainfo import MetaInfo # noqa: E402
from app.application.image import ImageHelper # noqa: E402 from app.application.image import ImageHelper # noqa: E402
from app.application.messaging.ingress import forward_message_to_host # noqa: E402
from app.runtime.thread import ThreadHelper # noqa: E402 from app.runtime.thread import ThreadHelper # noqa: E402
from app.runtime.log import logger # noqa: E402 from app.runtime.log import logger # noqa: E402
from app.runtime.execution import retry # noqa: E402 from app.runtime.execution import retry # noqa: E402
@@ -74,9 +75,6 @@ class Telegram:
Telegram 消息客户端负责发送编辑接收和转发 Telegram 消息 Telegram 消息客户端负责发送编辑接收和转发 Telegram 消息
""" """
_ds_url = (
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
)
_bot: TeleBot = None _bot: TeleBot = None
_callback_handlers: Dict[str, Callable] = {} # 存储回调处理器 _callback_handlers: Dict[str, Callable] = {} # 存储回调处理器
_user_chat_mapping: Dict[ _user_chat_mapping: Dict[
@@ -137,11 +135,7 @@ class Telegram:
logger.error(f"获取bot信息失败: {e}") logger.error(f"获取bot信息失败: {e}")
self._bot_username = None self._bot_username = None
# 标记渠道来源 self._config_name = kwargs.get("name")
if kwargs.get("name"):
# URL encode the source name to handle special characters
encoded_name = quote(kwargs.get("name"), safe="")
self._ds_url = f"{self._ds_url}&source={encoded_name}"
@_bot.message_handler(commands=["start", "help"]) @_bot.message_handler(commands=["start", "help"])
def send_welcome(message): def send_welcome(message):
@@ -164,10 +158,7 @@ class Telegram:
if not payload: if not payload:
logger.warn("Telegram消息序列化失败,跳过转发") logger.warn("Telegram消息序列化失败,跳过转发")
return return
response = RequestUtils(timeout=15).post_res( if not self._forward_to_message_chain(payload):
self._ds_url, json=payload
)
if not response or response.status_code >= 400:
logger.warn("Telegram消息转发失败") logger.warn("Telegram消息转发失败")
@_bot.callback_query_handler(func=lambda call: True) @_bot.callback_query_handler(func=lambda call: True)
@@ -208,10 +199,7 @@ class Telegram:
_bot.answer_callback_query(call.id) _bot.answer_callback_query(call.id)
# 发送给主程序处理 # 发送给主程序处理
response = RequestUtils(timeout=15).post_res( if not self._forward_to_message_chain(callback_json):
self._ds_url, json=callback_json
)
if not response or response.status_code >= 400:
logger.warn("Telegram按钮回调转发失败") logger.warn("Telegram按钮回调转发失败")
except Exception as err: except Exception as err:
@@ -232,6 +220,10 @@ class Telegram:
self._polling_thread.start() self._polling_thread.start()
logger.info("Telegram消息接收服务启动") logger.info("Telegram消息接收服务启动")
def _forward_to_message_chain(self, payload: dict) -> bool:
"""把 Telegram SDK 回调同步转交统一消息入口。"""
return forward_message_to_host(payload, self._config_name)
@property @property
def bot(self): def bot(self):
""" """
+8 -12
View File
@@ -15,10 +15,12 @@ from app.runtime.cache import FileCache
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import submit_message_to_host
from app.domain.context import MediaInfo, Context from app.domain.context import MediaInfo, Context
from app.domain.metainfo import MetaInfo from app.domain.metainfo import MetaInfo
from app.application.messaging.agent import matches_channel_admin from app.application.messaging.agent import matches_channel_admin
from app.runtime.log import logger from app.runtime.log import logger
from app.runtime.thread import ThreadHelper
from app.schemas.message import IncomingMessage from app.schemas.message import IncomingMessage
from app.schemas.types import NotificationChannel from app.schemas.types import NotificationChannel
from app.adapters.network.http import RequestUtils from app.adapters.network.http import RequestUtils
@@ -34,7 +36,6 @@ class WeChatBot:
""" """
_default_ws_url = "wss://openws.work.weixin.qq.com" _default_ws_url = "wss://openws.work.weixin.qq.com"
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
_heartbeat_interval = 30 _heartbeat_interval = 30
_ack_timeout = 10 _ack_timeout = 10
@@ -512,18 +513,13 @@ class WeChatBot:
) )
self._forward_to_message_chain(payload) self._forward_to_message_chain(payload)
def _forward_to_message_chain(self, payload: dict) -> None: def _forward_to_message_chain(self, payload: dict) -> bool:
def _run(): """通过受管线程池把企业微信 payload 转交统一消息入口。"""
try: return submit_message_to_host(
# 回调 payload,
RequestUtils(timeout=15).post_res( self._config_name,
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}&source={self._config_name}", submit=ThreadHelper().submit,
json=payload
) )
except Exception as err:
logger.error(f"企业微信智能机器人转发消息失败:{err}")
threading.Thread(target=_run, daemon=True).start()
@staticmethod @staticmethod
def _normalize_target(userid: Optional[str], default_chat_id: Optional[str]) -> Tuple[Optional[str], int]: def _normalize_target(userid: Optional[str], default_chat_id: Optional[str]) -> Tuple[Optional[str], int]:
+9 -22
View File
@@ -20,6 +20,7 @@ from app.runtime.cache import FileCache
from app.runtime.settings import RuntimeSettingsCompat from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import forward_message_to_host
from app.domain.context import Context, MediaInfo from app.domain.context import Context, MediaInfo
from app.domain.metainfo import MetaInfo from app.domain.metainfo import MetaInfo
from app.runtime.log import logger from app.runtime.log import logger
@@ -1477,9 +1478,6 @@ class WechatClawBot:
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._poll_thread: Optional[threading.Thread] = None self._poll_thread: Optional[threading.Thread] = None
self._state = self._load_state() self._state = self._load_state()
self._message_endpoint = (
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}&source={quote(self._config_name, safe='')}"
)
if self._state.get("bot_token") and self._auto_start_polling: if self._state.get("bot_token") and self._auto_start_polling:
self._start_polling() self._start_polling()
@@ -1751,33 +1749,18 @@ class WechatClawBot:
username=message.username, username=message.username,
context_token=message.context_token, context_token=message.context_token,
) )
response = None
try: try:
response = RequestUtils(timeout=15).post_res( if not self._forward_to_message_chain(
self._message_endpoint, message.to_message_payload()
json=message.to_message_payload(), ):
)
if response is None:
logger.error(
f"转发微信 ClawBot 消息失败:message_id={message.message_id}, "
"本地消息入口无响应"
)
elif response.status_code != 200:
logger.error( logger.error(
"转发微信 ClawBot 消息失败:" "转发微信 ClawBot 消息失败:"
f"message_id={message.message_id}, status={response.status_code}, " f"message_id={message.message_id}"
f"body={self._short_text(response.text)}"
) )
except Exception as err: except Exception as err:
logger.error( logger.error(
f"转发微信 ClawBot 消息失败:message_id={message.message_id}, error={err}" f"转发微信 ClawBot 消息失败:message_id={message.message_id}, error={err}"
) )
finally:
if response is not None:
try:
response.close()
except Exception:
pass
consecutive_failures = 0 consecutive_failures = 0
except Exception as err: except Exception as err:
consecutive_failures += 1 consecutive_failures += 1
@@ -1789,6 +1772,10 @@ class WechatClawBot:
break break
self._stop_event.wait(delay) self._stop_event.wait(delay)
def _forward_to_message_chain(self, payload: dict) -> bool:
"""把 WeChatClawBot 轮询消息同步转交统一消息入口。"""
return forward_message_to_host(payload, self._config_name)
def _build_known_targets(self) -> List[Dict[str, Any]]: def _build_known_targets(self) -> List[Dict[str, Any]]:
known_targets = self._state.get("known_targets") or {} known_targets = self._state.get("known_targets") or {}
items = [] items = []
@@ -63,6 +63,9 @@ Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同
调度,不表示执行完成。 调度,不表示执行完成。
- Webhook E0 广播、消息入口和 Seerr 订阅入口均已迁入 lifespan TaskRegistry,具备 owner、停止接收和 - Webhook E0 广播、消息入口和 Seerr 订阅入口均已迁入 lifespan TaskRegistry,具备 owner、停止接收和
有限等待语义;进程崩溃时仍允许丢失,不因此提升为 durable。 有限等待语义;进程崩溃时仍允许丢失,不因此提升为 durable。
- Slack、Telegram、Discord、飞书、QQBot、企业微信与 WeChatClawBot 的渠道回环统一经
`application.messaging.ingress` 进入同一个 API/TaskRegistry 主链;需要立即返回 SDK 回调的渠道把同步
HTTP 交给宿主共享线程池,模块关闭后由线程池生命周期等待,不再创建逐消息 daemon 线程。
- 主仓不再新增或保留裸 FastAPI `BackgroundTasks`;若任务源于已提交的用户数据且不可从数据库重建, - 主仓不再新增或保留裸 FastAPI `BackgroundTasks`;若任务源于已提交的用户数据且不可从数据库重建,
必须提升为 E2,进入 Outbox 或持久任务表。 必须提升为 E2,进入 Outbox 或持久任务表。
+2 -2
View File
@@ -176,7 +176,7 @@ flowchart TB
| `app/application/` | 读取配置/持久化状态的聚焦应用服务:识别、过滤、通知、RSS、站点、下载器、媒体服务器、存储、整理规则等;同一主题拆成子包 | `recognition.py``rules.py``rss.py``site/``subscription/``plugin/` | | `app/application/` | 读取配置/持久化状态的聚焦应用服务:识别、过滤、通知、RSS、站点、下载器、媒体服务器、存储、整理规则等;同一主题拆成子包 | `recognition.py``rules.py``rss.py``site/``subscription/``plugin/` |
| `app/application/subscription/` | 订阅新增、查询、变更、删除、媒体身份与搜索契约 | `write.py``contract.py``mutation.py``delete.py``identity.py``search.py` | | `app/application/subscription/` | 订阅新增、查询、变更、删除、媒体身份与搜索契约 | `write.py``contract.py``mutation.py``delete.py``identity.py``search.py` |
| `app/application/plugin/` | 插件市场、安装、运行时端口、文件夹操作和动态路由用例;具体 FastAPI 路由适配器在 adapters 层 | `catalog.py``install.py``runtime.py``folders.py``routes.py` | | `app/application/plugin/` | 插件市场、安装、运行时端口、文件夹操作和动态路由用例;具体 FastAPI 路由适配器在 adapters 层 | `catalog.py``install.py``runtime.py``folders.py``routes.py` |
| `app/application/messaging/` | 消息渲染/路由、命令交互会话、插件按钮回调、Agent 消息桥接 | `message.py``router.py``agent.py` | | `app/application/messaging/` | 渠道回环入口、消息渲染/路由、命令交互会话、插件按钮回调、Agent 消息桥接 | `ingress.py``message.py``router.py``agent.py` |
| `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、SSRF 与 URL/路径安全 | `auth.py``url.py``twofactor.py` | | `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、SSRF 与 URL/路径安全 | `auth.py``url.py``twofactor.py` |
| `app/chain/` | 跨入口复用的用例编排:订阅、搜索、下载、整理、媒体、消息等 Chain | `subscribe.py``search.py``transfer.py` | | `app/chain/` | 跨入口复用的用例编排:订阅、搜索、下载、整理、媒体、消息等 Chain | `subscribe.py``search.py``transfer.py` |
| `app/modules/` | 可插拔后端:下载器、媒体服务器、元数据源、消息渠道、索引器、存储 | `qbittorrent/``emby/``telegram/``themoviedb/` | | `app/modules/` | 可插拔后端:下载器、媒体服务器、元数据源、消息渠道、索引器、存储 | `qbittorrent/``emby/``telegram/``themoviedb/` |
@@ -478,7 +478,7 @@ flowchart LR
IT --> Chain IT --> Chain
``` ```
- `app/application/messaging/` 负责消息渲染、模板、队列(`message.py`)、交互会话与视图; - `app/application/messaging/` 负责渠道回环入口(`ingress.py`)、消息渲染、模板、队列(`message.py`)、交互会话与视图;
业务工作流仍由对应 Chain 执行(如媒体交互的业务部分在 `MediaInteractionChain`)。 业务工作流仍由对应 Chain 执行(如媒体交互的业务部分在 `MediaInteractionChain`)。
- 该包不作为推荐给插件直接使用的公开 SDK。 - 该包不作为推荐给插件直接使用的公开 SDK。
@@ -6,7 +6,7 @@
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
> 规范优先级:`AGENTS.md``docs/rules/` 高于本文 > 规范优先级:`AGENTS.md``docs/rules/` 高于本文
> 相关文档:`docs/architecture-overview.md``docs/refactor/backend-architecture-governance.md``docs/refactor/backend-module-refactor-compatibility.md` > 相关文档:`docs/architecture-overview.md``docs/refactor/backend-architecture-governance.md``docs/refactor/backend-module-refactor-compatibility.md`
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口。 > 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界
## 当前复核结论(2026-08-24 ## 当前复核结论(2026-08-24
@@ -15,7 +15,7 @@
### 长期整改阶段 0:治理门禁恢复(2026-08-23 ### 长期整改阶段 0:治理门禁恢复(2026-08-23
- 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `805` 个模块、`6503` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。 - 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `806` 个模块、`6528` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。
- 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistrynormal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。 - 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistrynormal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。
- 官方插件快照覆盖 `plugins.v3``plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。 - 官方插件快照覆盖 `plugins.v3``plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。
- SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing``__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。 - SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing``__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。
@@ -92,13 +92,27 @@
- 插件兼容边界保持不变:`MoviePilotServerHelper.sub_reg_async``sub_done_async` 的类方法、签名和返回值 - 插件兼容边界保持不变:`MoviePilotServerHelper.sub_reg_async``sub_done_async` 的类方法、签名和返回值
继续保留给旧插件;仅宿主生产调用清零,因此没有改动插件仓、SDK/Compat 映射或事件 payload。 继续保留给旧插件;仅宿主生产调用清零,因此没有改动插件仓、SDK/Compat 映射或事件 payload。
### 长期整改阶段 4:消息渠道回环与线程所有权统一(2026-08-24)
- Slack、Telegram、Discord、飞书、QQBot、企业微信与 WeChatClawBot 原先分别拼接本地消息 API URL、
执行 HTTP 并判断响应;其中飞书、QQBot、企业微信还为每条入站消息创建不可追踪的 daemon 线程。现在七者统一复用
`app.application.messaging.ingress` 的 URL 编码、请求、状态确认、异常日志和响应释放语义。
- SDK 回调需要同步确认的 Slack/Telegram/WeChatClawBot 保留同步调用,Discord 保留自有事件循环内的
异步调用;飞书、QQBot、企业微信继续立即返回,但任务改由现有 `ThreadHelper` 共享执行器承载,应用
关闭时由既有线程池生命周期等待,不再产生逐消息游离线程。HTTP 到达后仍由 `/api/v1/message`
TaskRegistry owner 执行消息主链。
- 新门禁扫描全部宿主 `app/modules/**/*.py`,禁止渠道再次硬编码 `/api/v1/message`;新增渠道必须使用统一
ingress。source、token 统一通过 query encoder 处理,修复配置名含 `&` 等字符时被拆成额外参数的问题。
- 兼容边界不变:七个渠道类、模块方法、配置字段、消息 payload、同步/异步 SDK 回调方式和
`/api/v1/message` HTTP 合同均未改;没有修改插件仓或向 SDK/Compat 新增宿主内部入口。
### 总体判断 ### 总体判断
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**: 当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
- 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。 - 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。
- `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。 - `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。
- 依赖图当前为 `805` 个 Python 模块、`6503` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。 - 依赖图当前为 `806` 个 Python 模块、`6528` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。
- 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。 - 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。
综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。 综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。
@@ -118,6 +132,8 @@
`finally` 取消并等待清理,断线和 ASGI 取消均不会留下请求级 task。 `finally` 取消并等待清理,断线和 ASGI 取消均不会留下请求级 task。
订阅删除的宿主生产者也已完成 durable 分级:消息交互和远程删除不再调用裸线程统计入口,而是 订阅删除的宿主生产者也已完成 durable 分级:消息交互和远程删除不再调用裸线程统计入口,而是
与业务删除原子暂存事件和统计 intent;旧类方法只作为插件 ABI 保留,不纳入宿主可靠性证明。 与业务删除原子暂存事件和统计 intent;旧类方法只作为插件 ABI 保留,不纳入宿主可靠性证明。
消息渠道回环也不再各自拥有 URL、HTTP 判定和逐消息线程:七种宿主渠道共享 ingress,三种立即返回
渠道交给生命周期持有的共享线程池;这仍是 E0 投递,不宣称跨进程恢复。
2. **Model/Base 的数据库装饰器和隐式会话 ABI 已全部清零。** 查询、写事务和 `legacy_*` 装饰器均为 `0`;所有 Model `db` 参数要求显式 Session,Base CRUD 仅在调用方事务内查询或 stage。可无会话构造的入口统一留在 Oper,经组合根事务执行器运行;插件 SDK 不再导出宿主 Model。后续重点转为减少 ORM 对象跨层流转,并保持 Model 隐式事务零回退。 2. **Model/Base 的数据库装饰器和隐式会话 ABI 已全部清零。** 查询、写事务和 `legacy_*` 装饰器均为 `0`;所有 Model `db` 参数要求显式 Session,Base CRUD 仅在调用方事务内查询或 stage。可无会话构造的入口统一留在 Oper,经组合根事务执行器运行;插件 SDK 不再导出宿主 Model。后续重点转为减少 ORM 对象跨层流转,并保持 Model 隐式事务零回退。
Oper 内部的执行入口也已统一:最后一处 `AgentTaskOper` 直接 transaction runner 调用已迁入 Oper 内部的执行入口也已统一:最后一处 `AgentTaskOper` 直接 transaction runner 调用已迁入
+1 -1
View File
@@ -69,7 +69,7 @@ to make the directory tree look symmetrical.
| `app/application/plugin/` | Plugin market catalog, installation command, runtime port, folder operations and dynamic-route use cases; filenames remain single words (`catalog.py`, `install.py`, `runtime.py`, `folders.py`, `routes.py`) | | `app/application/plugin/` | Plugin market catalog, installation command, runtime port, folder operations and dynamic-route use cases; filenames remain single words (`catalog.py`, `install.py`, `runtime.py`, `folders.py`, `routes.py`) |
| `app/application/server/` | MoviePilot Server reporting and sharing use cases; local data readers and transport callbacks are injected by startup | | `app/application/server/` | MoviePilot Server reporting and sharing use cases; local data readers and transport callbacks are injected by startup |
| `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here | | `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here |
| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` agent choice state, callback protocol and WebAgent bridge; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use | | `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `ingress.py` owns the single channel-to-host loopback boundary; `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` agent choice state, callback protocol and WebAgent bridge; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use |
| `app/application/security/` | Authentication, authorization, cookies, passkeys, OTP/two-factor, path/URL safety, SSRF and signing policy | | `app/application/security/` | Authentication, authorization, cookies, passkeys, OTP/two-factor, path/URL safety, SSRF and signing policy |
Application services may use domain rules and runtime contracts. They own the Application services may use domain rules and runtime contracts. They own the
+1
View File
@@ -26,6 +26,7 @@ files =
app/application/workflow.py, app/application/workflow.py,
app/application/chain/context.py, app/application/chain/context.py,
app/application/chain/durable_events.py, app/application/chain/durable_events.py,
app/application/messaging/ingress.py,
app/application/subscription/delete.py, app/application/subscription/delete.py,
app/application/subscription/identity.py, app/application/subscription/identity.py,
app/application/subscription/mutation.py, app/application/subscription/mutation.py,
+29 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [], "runtime_to_db": [],
"workflow_to_db": [] "workflow_to_db": []
}, },
"edge_count": 6503, "edge_count": 6528,
"edge_sha256": "57c326a81dbba07909871a97df1c73416850205c3d385a15afe3a07e3e44c00c", "edge_sha256": "2e30c775e78f578d804f69972ab1348e1e9d0872b00bdbfcffad08398fae68ac",
"edges": [ "edges": [
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
@@ -2605,6 +2605,12 @@
"app.application.messaging.chat -> app.schemas", "app.application.messaging.chat -> app.schemas",
"app.application.messaging.chat -> app.schemas.agent", "app.application.messaging.chat -> app.schemas.agent",
"app.application.messaging.chat -> app.schemas.exception", "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",
"app.application.messaging.interaction -> app.schemas.message", "app.application.messaging.interaction -> app.schemas.message",
"app.application.messaging.interaction -> app.schemas.notification", "app.application.messaging.interaction -> app.schemas.notification",
@@ -4036,6 +4042,9 @@
"app.modules.discord -> app.schemas.message", "app.modules.discord -> app.schemas.message",
"app.modules.discord -> app.schemas.notification", "app.modules.discord -> app.schemas.notification",
"app.modules.discord -> app.schemas.types", "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",
"app.modules.discord.discord -> app.domain.context", "app.modules.discord.discord -> app.domain.context",
"app.modules.discord.discord -> app.domain.metainfo", "app.modules.discord.discord -> app.domain.metainfo",
@@ -4140,6 +4149,7 @@
"app.modules.feishu.feishu -> app.application", "app.modules.feishu.feishu -> app.application",
"app.modules.feishu.feishu -> app.application.messaging", "app.modules.feishu.feishu -> app.application.messaging",
"app.modules.feishu.feishu -> app.application.messaging.agent", "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",
"app.modules.feishu.feishu -> app.application.security.user", "app.modules.feishu.feishu -> app.application.security.user",
"app.modules.feishu.feishu -> app.domain", "app.modules.feishu.feishu -> app.domain",
@@ -4147,6 +4157,7 @@
"app.modules.feishu.feishu -> app.runtime", "app.modules.feishu.feishu -> app.runtime",
"app.modules.feishu.feishu -> app.runtime.log", "app.modules.feishu.feishu -> app.runtime.log",
"app.modules.feishu.feishu -> app.runtime.settings", "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",
"app.modules.feishu.feishu -> app.schemas.message", "app.modules.feishu.feishu -> app.schemas.message",
"app.modules.feishu.feishu -> app.schemas.types", "app.modules.feishu.feishu -> app.schemas.types",
@@ -4865,6 +4876,9 @@
"app.modules.qqbot.qqbot -> app.adapters", "app.modules.qqbot.qqbot -> app.adapters",
"app.modules.qqbot.qqbot -> app.adapters.network", "app.modules.qqbot.qqbot -> app.adapters.network",
"app.modules.qqbot.qqbot -> app.adapters.network.http", "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",
"app.modules.qqbot.qqbot -> app.domain.context", "app.modules.qqbot.qqbot -> app.domain.context",
"app.modules.qqbot.qqbot -> app.domain.metainfo", "app.modules.qqbot.qqbot -> app.domain.metainfo",
@@ -4878,6 +4892,7 @@
"app.modules.qqbot.qqbot -> app.runtime.cache", "app.modules.qqbot.qqbot -> app.runtime.cache",
"app.modules.qqbot.qqbot -> app.runtime.log", "app.modules.qqbot.qqbot -> app.runtime.log",
"app.modules.qqbot.qqbot -> app.runtime.settings", "app.modules.qqbot.qqbot -> app.runtime.settings",
"app.modules.qqbot.qqbot -> app.runtime.thread",
"app.modules.redis -> app.adapters", "app.modules.redis -> app.adapters",
"app.modules.redis -> app.adapters.cache", "app.modules.redis -> app.adapters.cache",
"app.modules.redis -> app.adapters.cache.redis", "app.modules.redis -> app.adapters.cache.redis",
@@ -4922,6 +4937,9 @@
"app.modules.slack.slack -> app.adapters", "app.modules.slack.slack -> app.adapters",
"app.modules.slack.slack -> app.adapters.network", "app.modules.slack.slack -> app.adapters.network",
"app.modules.slack.slack -> app.adapters.network.http", "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",
"app.modules.slack.slack -> app.domain.context", "app.modules.slack.slack -> app.domain.context",
"app.modules.slack.slack -> app.domain.metainfo", "app.modules.slack.slack -> app.domain.metainfo",
@@ -4993,6 +5011,8 @@
"app.modules.telegram.telegram -> app.adapters.network.http", "app.modules.telegram.telegram -> app.adapters.network.http",
"app.modules.telegram.telegram -> app.application", "app.modules.telegram.telegram -> app.application",
"app.modules.telegram.telegram -> app.application.image", "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",
"app.modules.telegram.telegram -> app.domain.context", "app.modules.telegram.telegram -> app.domain.context",
"app.modules.telegram.telegram -> app.domain.metainfo", "app.modules.telegram.telegram -> app.domain.metainfo",
@@ -5395,6 +5415,7 @@
"app.modules.wechat.wechatbot -> app.application", "app.modules.wechat.wechatbot -> app.application",
"app.modules.wechat.wechatbot -> app.application.messaging", "app.modules.wechat.wechatbot -> app.application.messaging",
"app.modules.wechat.wechatbot -> app.application.messaging.agent", "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",
"app.modules.wechat.wechatbot -> app.domain.context", "app.modules.wechat.wechatbot -> app.domain.context",
"app.modules.wechat.wechatbot -> app.domain.metainfo", "app.modules.wechat.wechatbot -> app.domain.metainfo",
@@ -5404,6 +5425,7 @@
"app.modules.wechat.wechatbot -> app.runtime.cache", "app.modules.wechat.wechatbot -> app.runtime.cache",
"app.modules.wechat.wechatbot -> app.runtime.log", "app.modules.wechat.wechatbot -> app.runtime.log",
"app.modules.wechat.wechatbot -> app.runtime.settings", "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",
"app.modules.wechat.wechatbot -> app.schemas.message", "app.modules.wechat.wechatbot -> app.schemas.message",
"app.modules.wechat.wechatbot -> app.schemas.types", "app.modules.wechat.wechatbot -> app.schemas.types",
@@ -5424,6 +5446,9 @@
"app.modules.wechatclawbot.wechatclawbot -> app.adapters", "app.modules.wechatclawbot.wechatclawbot -> app.adapters",
"app.modules.wechatclawbot.wechatclawbot -> app.adapters.network", "app.modules.wechatclawbot.wechatclawbot -> app.adapters.network",
"app.modules.wechatclawbot.wechatclawbot -> app.adapters.network.http", "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",
"app.modules.wechatclawbot.wechatclawbot -> app.domain.context", "app.modules.wechatclawbot.wechatclawbot -> app.domain.context",
"app.modules.wechatclawbot.wechatclawbot -> app.domain.metainfo", "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",
"app.workflow.actions.transfer_file -> app.workflow.actions" "app.workflow.actions.transfer_file -> app.workflow.actions"
], ],
"module_count": 805, "module_count": 806,
"modules": [ "modules": [
"app", "app",
"app.adapters", "app.adapters",
@@ -6791,6 +6816,7 @@
"app.application.messaging", "app.application.messaging",
"app.application.messaging.agent", "app.application.messaging.agent",
"app.application.messaging.chat", "app.application.messaging.chat",
"app.application.messaging.ingress",
"app.application.messaging.interaction", "app.application.messaging.interaction",
"app.application.messaging.media", "app.application.messaging.media",
"app.application.messaging.message", "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 == []