766 lines
31 KiB
Python
766 lines
31 KiB
Python
"""多渠道网关入站消息调度器。"""
|
||
|
||
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",
|
||
)
|