ForcePilot/backend/package/yuxi/channel/message/dispatcher.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

766 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""多渠道网关入站消息调度器。"""
from __future__ import annotations
import asyncio
import base64
import json
import time
import uuid
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.channel.constants import InboundRejectionReason
from yuxi.channel.exceptions import ChannelConfigurationError
from yuxi.channel.message.dedupe import MessageDeduper
from yuxi.channel.message.models import RunAcceptedResponse
from yuxi.channel.metrics import (
channel_messages_received_total,
channel_rate_limited_total,
)
from yuxi.channel.middlewares.protocols import InboundContext, InboundMiddleware, InboundResult
from yuxi.channel.middlewares.registry import InboundMiddlewareRegistry
from yuxi.channel.plugins.protocol import BindingRoute, ChannelHealthStatus, InboundMessage, OutboundMessage
from yuxi.channel.ports import (
InboundPort,
LifecyclePort,
StatusPort,
TransportPort,
)
from yuxi.channel.routing.router import BindingRouter
from yuxi.channel.session.manager import SessionManager
from yuxi.services.agent_run_service import create_agent_run_view
from yuxi.services.run_queue_service import get_redis_client
from yuxi.storage.postgres.model_channel import ChannelSession
from yuxi.utils.image_processor import process_uploaded_image
from yuxi.utils.logging_config import logger
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from yuxi.channel.config import ChannelConfigManager
from yuxi.channel.lifecycle.manager import ChannelLifecycleManager
from yuxi.channel.outbound.dispatcher import OutboundDispatcher
from yuxi.channel.plugins.protocol import ChannelPlugin
from yuxi.channel.plugins.registry import ChannelRegistry
from yuxi.channel.security.policy import SecurityPolicy
class ChannelGateway:
"""多渠道网关核心调度器。
负责 Webhook 入站、传输层消息回调、入站消息标准化、安全校验、
会话/路由解析以及调用 ``AgentRunService`` 创建运行。
"""
def __init__(
self,
registry: ChannelRegistry,
config_manager: ChannelConfigManager,
session_manager: SessionManager,
binding_router: BindingRouter,
outbound_dispatcher: OutboundDispatcher,
security_policy: SecurityPolicy,
lifecycle_manager: ChannelLifecycleManager,
inbound_registry: InboundMiddlewareRegistry | None = None,
dedupe: MessageDeduper | None = None,
) -> None:
self.registry = registry
self.config_manager = config_manager
self.session_manager = session_manager
self.binding_router = binding_router
self.outbound = outbound_dispatcher
self.security = security_policy
self.lifecycle = lifecycle_manager
self._inbound_registry = inbound_registry or InboundMiddlewareRegistry()
self._dedupe = dedupe or MessageDeduper()
self._listen_task: asyncio.Task | None = None
async def start_config_change_listener(self) -> None:
"""启动 Redis Pub/Sub 配置变更监听任务。"""
self._listen_task = asyncio.create_task(self._listen_config_changes())
async def stop_config_change_listener(self) -> None:
"""停止配置变更监听任务。"""
if self._listen_task is not None:
self._listen_task.cancel()
try:
await self._listen_task
except asyncio.CancelledError:
pass
self._listen_task = None
async def _listen_config_changes(self) -> None:
"""订阅 ch:config:changed失效路由缓存并视情况重启/停止渠道实例。
连接异常时自动重连,避免配置变更通知长期丢失。
"""
channel = "ch:config:changed"
while True:
try:
redis = await get_redis_client()
pubsub = redis.pubsub()
await pubsub.subscribe(channel)
logger.info("Subscribed to channel config change notifications")
try:
async for message in pubsub.listen():
if message.get("type") != "message":
continue
raw_data = message.get("data", "{}")
logger.debug("Received channel config change notification: %s", raw_data)
try:
payload = json.loads(raw_data)
except Exception:
logger.warning("Invalid channel config change payload: %s", raw_data)
continue
channel_type = payload.get("channel_type")
account_id = payload.get("account_id")
action = payload.get("action", "updated")
if not channel_type or not account_id:
logger.warning(
"Channel config change payload missing channel_type or account_id: %s",
payload,
)
continue
logger.info(
"Processing channel config change notification: channel_type=%s account_id=%s action=%s",
channel_type,
account_id,
action,
)
await self._handle_config_change(channel_type, account_id, action)
finally:
try:
await pubsub.unsubscribe(channel)
await pubsub.close()
except Exception:
logger.warning("Failed to cleanup channel config change pubsub")
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Channel config change listener error, reconnecting in 5s")
await asyncio.sleep(5)
async def _handle_config_change(self, channel_type: str, account_id: str, action: str) -> None:
"""处理单条配置变更通知。"""
logger.info("Channel config changed: %s/%s action=%s", channel_type, account_id, action)
invalidated = await self.binding_router.invalidate_account_cache(channel_type, account_id)
logger.info(
"Invalidated %d route cache entries for %s/%s",
invalidated,
channel_type,
account_id,
)
self._inbound_registry.invalidate(channel_type, account_id)
self.outbound.invalidate(channel_type, account_id)
self.security.checker_registry.invalidate(channel_type, account_id)
try:
config = await self.config_manager.get_config(channel_type, account_id)
except Exception:
config = None
plugin = self.registry.get_plugin(channel_type)
if config is not None and plugin is not None:
if isinstance(plugin, LifecyclePort):
try:
await plugin.on_config_changed(config, account_id)
except Exception:
logger.exception(
"Plugin on_config_changed failed for %s/%s",
channel_type,
account_id,
)
else:
logger.debug(
"Plugin %s does not implement LifecyclePort, skipping on_config_changed",
channel_type,
)
if action in {"deleted", "disabled"}:
await self.lifecycle.stop_channel(channel_type, account_id)
elif action in {"created", "updated", "enabled"}:
await self.lifecycle.stop_channel(channel_type, account_id)
if config is not None and config.get("enabled"):
try:
await self.lifecycle.start_channel(channel_type, account_id)
except Exception:
logger.bind(
event="channel_restart_failed",
channel_type=channel_type,
account_id=account_id,
).exception("Failed to restart channel after config change")
async def start_all_channels(self) -> None:
await self.lifecycle.start_all()
async def stop_all_channels(self) -> None:
await self.lifecycle.stop_all()
async def stop(self) -> None:
"""停止配置变更监听并关闭所有渠道。"""
await self.stop_config_change_listener()
await self.stop_all_channels()
async def start_channel(self, channel_type: str, account_id: str) -> None:
await self.lifecycle.start_channel(channel_type, account_id)
async def stop_channel(self, channel_type: str, account_id: str) -> None:
await self.lifecycle.stop_channel(channel_type, account_id)
async def handle_webhook(self, channel_type: str, request: Request) -> Response:
"""处理外部渠道 Webhook 入站请求。"""
plugin = self.registry.get_plugin(channel_type)
if plugin is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unknown channel type: {channel_type}",
)
if not isinstance(plugin, InboundPort):
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Channel does not support inbound webhook",
)
meta = plugin.get_meta()
canonical_type = meta.channel_type
# account_id 优先从 URL 查询参数获取,未提供时由插件从请求体/头中解析。
account_id = request.query_params.get("account_id")
if not account_id:
account_id = await plugin.resolve_account_id_from_webhook(request)
if not account_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing account_id",
)
try:
config = await self.config_manager.get_config(canonical_type, account_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Channel config not found: {exc}",
) from exc
request.state.channel_config = config
valid = await plugin.validate_webhook(request)
if not valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Webhook validation failed",
)
should_continue = True
early_response = None
transformed_body = None
try:
should_continue, early_response, transformed_body = await plugin.preprocess_event(
request, config, account_id
)
except Exception as exc:
logger.warning("Failed to preprocess inbound webhook: %s", exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid webhook payload",
) from exc
if not should_continue:
if early_response is not None:
return Response(
content=json.dumps(early_response, ensure_ascii=False),
media_type="application/json",
status_code=status.HTTP_200_OK,
)
return Response(status_code=status.HTTP_200_OK)
request.state.channel_raw_body = transformed_body
try:
inbound = await plugin.normalize_inbound(request)
except Exception as exc:
logger.warning("Failed to normalize inbound webhook: %s", exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid webhook payload",
) from exc
inbound.channel_type = canonical_type
inbound.account_id = account_id
# 扫码事件识别:插件可选提供 normalize_scan_event 将原始请求转换为扫码事件。
supports_scan = getattr(plugin, "supports_scan_pairing", None)
normalize_scan = getattr(plugin, "normalize_scan_event", None)
if not inbound.is_scan_event and callable(supports_scan) and supports_scan(config):
try:
scan_inbound = await normalize_scan(request, config, account_id)
if scan_inbound is not None:
scan_inbound.channel_type = canonical_type
scan_inbound.account_id = account_id
inbound = scan_inbound
except Exception:
logger.exception("Failed to normalize scan event")
result = await self._process_inbound(canonical_type, inbound)
if not result.accepted:
logger.bind(
event="channel_webhook_rejected",
channel_type=canonical_type,
account_id=account_id,
message_id=inbound.channel_message_id,
sender_id=inbound.sender_id,
reason=result.reason,
).info("Webhook message rejected")
if not result.accepted and result.pairing_code:
response_body = await self._build_webhook_response(plugin, request, inbound)
if response_body is None:
response_body = {"pairing_code": result.pairing_code}
if result.qr_content:
response_body["qr_content"] = result.qr_content
if result.qr_reply and result.qr_reply.content:
response_body["message"] = result.qr_reply.content
return Response(
content=json.dumps(response_body, ensure_ascii=False),
media_type="application/json",
status_code=status.HTTP_200_OK,
)
if result.accepted and result.qr_reply and result.qr_reply.content:
response_body = await self._build_webhook_response(plugin, request, inbound)
if response_body is None:
response_body = {"message": result.qr_reply.content}
return Response(
content=json.dumps(response_body, ensure_ascii=False),
media_type="application/json",
status_code=status.HTTP_200_OK,
)
if not result.accepted:
# 内部/配置/未知渠道错误返回非 2xx允许外部平台重试
# 限流返回 429其他业务拒绝返回 200避免无意义重试。
if result.reason == InboundRejectionReason.INTERNAL_ERROR:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal error processing webhook",
)
if result.reason == InboundRejectionReason.CONFIG_ERROR:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Channel configuration error",
)
if result.reason == InboundRejectionReason.UNKNOWN_CHANNEL:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Unknown channel type",
)
if result.reason == InboundRejectionReason.RATE_LIMITED:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limited",
)
return Response(status_code=status.HTTP_200_OK)
response_body = await self._build_webhook_response(plugin, request, inbound)
if response_body is not None:
return Response(
content=json.dumps(response_body, ensure_ascii=False),
media_type="application/json",
status_code=status.HTTP_200_OK,
)
return Response(status_code=status.HTTP_200_OK)
async def _build_webhook_response(
self,
plugin: ChannelPlugin,
request: Request,
inbound: InboundMessage,
) -> dict[str, Any] | None:
"""构造插件要求的 Webhook 响应体(如飞书 challenge"""
build_response = getattr(plugin, "build_webhook_response", None)
if callable(build_response):
try:
response = await build_response(request, inbound)
if response is not None:
return response
except Exception as exc:
logger.warning("Plugin build_webhook_response failed: %s", exc)
raw_event = inbound.raw_event
if isinstance(raw_event, dict) and "challenge" in raw_event:
return {"challenge": raw_event["challenge"]}
return None
async def on_transport_message(self, raw: bytes, channel_type: str, account_id: str) -> None:
"""传输层WebSocket/Polling消息公开入口。
供 ``ChannelGatewayBootstrap`` 在生命周期启动前注册为消息处理器,
避免直接访问私有方法 ``_on_transport_message``。
"""
await self._on_transport_message(raw, channel_type, account_id)
async def _on_transport_message(self, raw: bytes, channel_type: str, account_id: str) -> None:
"""传输层WebSocket/Polling消息回调入口。"""
plugin = self.registry.get_plugin(channel_type)
if plugin is None:
logger.warning("No plugin registered for channel type: %s", channel_type)
return
if not isinstance(plugin, TransportPort):
logger.warning(
"Plugin for %s does not implement TransportPort",
channel_type,
)
return
try:
config = await self.config_manager.get_config(channel_type, account_id)
except Exception as exc:
logger.exception("Failed to load config for transport message: %s", exc)
return
inbound = await plugin.on_transport_message(raw, config, account_id)
if inbound is None:
return
inbound.channel_type = channel_type
inbound.account_id = account_id
await self._process_inbound(channel_type, inbound)
async def _process_inbound(self, channel_type: str, inbound: InboundMessage) -> RunAcceptedResponse:
"""入站消息核心处理链路:通过 InboundMiddlewareRegistry 编排中间件链。"""
labels = {"channel_type": channel_type, "account_id": inbound.account_id or ""}
channel_messages_received_total.inc(labels)
start_time = time.perf_counter()
plugin = self.registry.get_plugin(channel_type)
if plugin is None:
await self._dedupe.safe_clear_processed(inbound.channel_message_id)
return RunAcceptedResponse(accepted=False, reason=InboundRejectionReason.UNKNOWN_CHANNEL)
try:
config = await self.config_manager.get_config(channel_type, inbound.account_id)
except Exception:
logger.bind(
event="channel_config_load_failed",
channel_type=channel_type,
account_id=inbound.account_id,
message_id=inbound.channel_message_id,
sender_id=inbound.sender_id,
).exception("Failed to load channel config for inbound message")
await self._dedupe.safe_clear_processed(inbound.channel_message_id)
return RunAcceptedResponse(accepted=False, reason=InboundRejectionReason.CONFIG_ERROR)
if inbound.is_scan_event:
# 扫码事件绕过中间件链,需在此处单独检查去重。
if await self._dedupe.is_processed(inbound.channel_message_id):
return RunAcceptedResponse(accepted=False, reason=InboundRejectionReason.DUPLICATE)
return await self._process_scan_event(channel_type, inbound, config, plugin)
ctx = InboundContext(
channel_type=channel_type,
account_id=inbound.account_id or "",
inbound=inbound,
config=config,
config_mw={},
plugin=plugin,
)
try:
chain = self._inbound_registry.resolve_chain(config)
if not chain:
logger.bind(
event="channel_config_invalid",
channel_type=channel_type,
account_id=inbound.account_id,
message_id=inbound.channel_message_id,
sender_id=inbound.sender_id,
).error("Inbound middleware chain is empty")
await self._dedupe.safe_clear_processed(inbound.channel_message_id)
return RunAcceptedResponse(accepted=False, reason=InboundRejectionReason.CONFIG_ERROR)
ctx.config_mw = self._inbound_registry.get_middleware_config(ctx.config, chain[0].name)
result = await chain[0].process(ctx, self._build_next(ctx, chain, 1))
except ChannelConfigurationError:
logger.bind(
event="channel_config_invalid",
channel_type=channel_type,
account_id=inbound.account_id,
message_id=inbound.channel_message_id,
sender_id=inbound.sender_id,
).exception("Invalid inbound middleware chain configuration")
await self._dedupe.safe_clear_processed(inbound.channel_message_id)
return RunAcceptedResponse(accepted=False, reason=InboundRejectionReason.CONFIG_ERROR)
except Exception:
duration_ms = int((time.perf_counter() - start_time) * 1000)
logger.bind(
event="channel_message_error",
channel_type=channel_type,
account_id=inbound.account_id,
session_key=ctx.session.session_key if ctx.session else None,
message_id=inbound.channel_message_id,
sender_id=inbound.sender_id,
duration_ms=duration_ms,
).exception("Channel inbound message processing failed")
await self._dedupe.safe_clear_processed(inbound.channel_message_id)
return RunAcceptedResponse(accepted=False, reason=InboundRejectionReason.INTERNAL_ERROR)
if result.accepted:
duration_ms = int((time.perf_counter() - start_time) * 1000)
session_key = getattr(ctx.session, "session_key", None) if ctx.session else None
agent_id = getattr(ctx.route, "agent_id", None) if ctx.route else None
matched_by = getattr(ctx.route, "matched_by", None) if ctx.route else None
logger.bind(
event="channel_message_received",
channel_type=channel_type,
account_id=inbound.account_id,
session_key=session_key,
message_id=inbound.channel_message_id,
sender_id=inbound.sender_id,
agent_id=agent_id,
matched_by=matched_by,
duration_ms=duration_ms,
).info("Channel inbound message processed")
return RunAcceptedResponse(
accepted=result.accepted,
reason=result.reason,
run_id=result.run_id,
pairing_code=result.pairing_code,
qr_content=result.qr_content,
qr_reply=result.qr_reply,
)
def _build_next(
self,
ctx: InboundContext,
chain: list[InboundMiddleware],
index: int,
) -> Callable[[], Awaitable[InboundResult]]:
"""构造中间件链的 next 闭包,并在调用前注入目标中间件的 config_mw。"""
if index >= len(chain):
async def _terminal() -> InboundResult:
return InboundResult(accepted=True)
return _terminal
mw = chain[index]
async def _next() -> InboundResult:
ctx.config_mw = self._inbound_registry.get_middleware_config(ctx.config, mw.name)
return await mw.process(ctx, self._build_next(ctx, chain, index + 1))
return _next
async def _process_scan_event(
self,
channel_type: str,
inbound: InboundMessage,
config: dict,
plugin,
) -> RunAcceptedResponse:
"""处理扫码事件:跳过 allowlist保留 bot_loop 与 rate_limit 检查,验证并绑定身份。"""
actor_id = inbound.sender_id or inbound.peer_id or ""
is_bot = False
if isinstance(inbound.raw_event, dict):
is_bot = bool(inbound.raw_event.get("is_bot", False))
if not self.security.check_bot_loop(actor_id, is_bot):
return RunAcceptedResponse(
accepted=False,
reason=InboundRejectionReason.BOT_LOOP_DETECTED,
)
if not await self.security.check_rate_limit(config, plugin, inbound, actor_id):
channel_rate_limited_total.inc({"channel_type": channel_type, "account_id": inbound.account_id or ""})
await self._dedupe.safe_clear_processed(inbound.channel_message_id)
return RunAcceptedResponse(
accepted=False,
reason=InboundRejectionReason.RATE_LIMITED,
)
code = inbound.content.strip()
if not code:
return RunAcceptedResponse(
accepted=False,
reason=InboundRejectionReason.DM_PAIRING_REQUIRED,
qr_reply=OutboundMessage(
content="配对码不能为空,请重新扫描二维码。",
content_type="text",
),
)
peer_id = inbound.peer_id or inbound.sender_id or ""
record = await self.security.verify_pairing_code_record(
channel_type,
inbound.account_id,
peer_id,
code,
)
if record is None:
return RunAcceptedResponse(
accepted=False,
reason=InboundRejectionReason.DM_PAIRING_REQUIRED,
qr_reply=OutboundMessage(
content="二维码已过期或无效,请重新获取二维码后扫描。",
content_type="text",
),
)
platform_user_id = record.platform_user_id
if not platform_user_id:
pairing_cfg = config.get("pairing", {})
if pairing_cfg.get("auto_bind_on_scan") and peer_id:
platform_user_id = peer_id
if not platform_user_id:
return RunAcceptedResponse(
accepted=False,
reason=InboundRejectionReason.DM_PAIRING_REQUIRED,
qr_reply=OutboundMessage(
content="配对记录缺少平台用户信息,请重新获取二维码。",
content_type="text",
),
)
if not inbound.sender_id:
return RunAcceptedResponse(
accepted=False,
reason=InboundRejectionReason.DM_PAIRING_REQUIRED,
qr_reply=OutboundMessage(
content="无法识别发送者身份,请重新扫描二维码。",
content_type="text",
),
)
if self.security.identity is None:
return RunAcceptedResponse(
accepted=False,
reason=InboundRejectionReason.INTERNAL_ERROR,
qr_reply=OutboundMessage(
content="身份绑定服务暂不可用,请稍后重试。",
content_type="text",
),
)
await self.security.identity.link(
channel_type=channel_type,
channel_sender_id=inbound.sender_id,
platform_user_id=platform_user_id,
account_id=inbound.account_id,
paired_by="qr",
)
return RunAcceptedResponse(
accepted=True,
qr_reply=OutboundMessage(
content="绑定成功,可以继续对话。",
content_type="text",
),
)
async def create_agent_run(
self,
session: ChannelSession,
route: BindingRoute,
inbound: InboundMessage,
db: AsyncSession,
) -> dict:
"""调用 ``create_agent_run_view`` 创建 Agent 运行。"""
current_uid = f"channel:{inbound.channel_type}:{inbound.account_id}"
thread_id = uuid.uuid5(uuid.NAMESPACE_OID, route.session_key).hex
image_content = await self._extract_image_content(inbound)
session_metadata = session.channel_metadata or {}
meta = {
"source": "channel",
"channel_type": inbound.channel_type,
"account_id": inbound.account_id,
"session_key": route.session_key,
"channel_message_id": inbound.channel_message_id,
"reply_to_mode": session_metadata.get("reply_to_mode"),
"auto_thread_id": session_metadata.get("auto_thread_id"),
"parent_conversation_candidates": session_metadata.get("parent_conversation_candidates", []),
}
return await create_agent_run_view(
query=inbound.content,
agent_id=route.agent_id,
thread_id=thread_id,
meta=meta,
image_content=image_content,
current_uid=current_uid,
db=db,
)
async def _extract_image_content(self, inbound: InboundMessage) -> str | None:
"""下载并处理第一个图片附件为 base64 字符串。"""
image_media = next(
(m for m in inbound.media if m.media_type == "image"),
None,
)
if image_media is None:
return None
plugin = self.registry.get_plugin(inbound.channel_type)
if plugin is None:
return None
download_attachment = getattr(plugin, "download_attachment", None)
if download_attachment is None:
return None
downloaded = await download_attachment(inbound, image_media)
if not downloaded:
return None
data, mime_type = downloaded
if not data:
return None
processed = await asyncio.to_thread(process_uploaded_image, data, file_name=image_media.file_name or "image")
if processed.get("success"):
return processed.get("image_content")
# 回退:直接 base64 编码
return base64.b64encode(data).decode("utf-8")
async def health_check(self, channel_type: str, account_id: str) -> ChannelHealthStatus:
"""获取指定渠道账户的健康状态。"""
plugin = self.registry.get_plugin(channel_type)
if plugin is None:
return ChannelHealthStatus(
healthy=False,
state="unknown",
enabled=False,
last_error="Unknown channel type",
)
try:
config = await self.config_manager.get_config(channel_type, account_id)
if not isinstance(plugin, StatusPort):
return ChannelHealthStatus(
healthy=False,
state="not_supported",
enabled=False,
last_error="Channel does not support health checks",
)
return await plugin.health_check(config, account_id)
except Exception:
logger.bind(
event="channel_health_check_failed",
channel_type=channel_type,
account_id=account_id,
).exception("Channel health check failed")
return ChannelHealthStatus(
healthy=False,
state="error",
enabled=False,
last_error="Failed to retrieve channel health",
)