ForcePilot/backend/package/yuxi/channel/message/processor.py
Kris 0b19470c70 feat(channel/message): 新增消息处理核心模块及相关工具类
本次提交新增了渠道消息处理的完整核心模块,包含以下核心功能:
1.  新增会话围栏类,实现会话并发控制与过期清理
2.  新增媒体清理器,实现过期媒体文件自动清理
3.  新增熔断器组件,实现服务降级与故障隔离
4.  新增消息处理器,完成渠道消息的完整流转处理
5.  新增限流器组件,实现渠道级和账户级流量控制
6.  新增链路追踪模块,集成Langfuse实现调用链路监控
7.  新增指标统计模块,实现消息处理全链路指标采集
8.  新增统一消息模型,封装全渠道消息格式
9.  新增块回复流水线,实现流式回复的合并与去重
10. 新增本地媒体存储模块,实现媒体文件的本地管理
11. 新增回复分发器,实现回复内容的有序发送与延迟处理
12. 完善__init__.py导出所有核心模块与工具类
2026-05-21 10:27:16 +08:00

686 lines
28 KiB
Python

import asyncio
import logging
import time
from collections.abc import AsyncIterator, Callable
from sqlalchemy import select
from yuxi.channel.hooks.lifecycle import HookEvent, LifecycleHookRegistry
from yuxi.channel.message.block_reply_pipeline import create_block_reply_pipeline
from yuxi.channel.message.bridge import AgentBridge
from yuxi.channel.message.conversation_fence import ConversationFence
from yuxi.channel.message.dispatch import MessageDispatcher
from yuxi.channel.message.idempotency import claim, release
from yuxi.channel.message.langfuse_trace import ChannelTrace, create_channel_trace
from yuxi.channel.message.metrics import (
record_dispatch_duration_ms,
record_message,
record_rate_limit_reject,
set_inflight,
)
from yuxi.channel.message.models import (
PeerKind,
StreamCancelledError,
StreamStatus,
StreamTimeoutError,
UnifiedMessage,
)
from yuxi.channel.message.rate_limiter import rate_limit_manager
from yuxi.channel.protocols import (
InboundMessageHook,
MessageSendingHook,
OutboundProtocol,
SendLifecycleHook,
)
from yuxi.channel.plugins.registry import ChannelPluginRegistry
from yuxi.channel.routing.matcher import RouteMatcher
from yuxi.channel.routing.models import RouteBinding
from yuxi.channel.routing.session_key import SessionKeyBuilder
from yuxi.channel.security.allowlist import AllowlistChecker
from yuxi.channel.security.identity_link import IdentityLinkResolver
from yuxi.channel.security.pairing import InMemoryPairingStore, PairingManager
from yuxi.channel.message.circuit_breaker import CircuitBreaker
from yuxi.repositories.channel_binding_repo import ChannelBindingRepository, to_route_binding
from yuxi.repositories.channel_msg_record_repo import ChannelMsgRecordRepository
from yuxi.repositories.channel_thread_mapping_repo import ChannelThreadMappingRepository
from yuxi.repositories.channel_user_mapping_repo import ChannelUserMappingRepository
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import User
logger = logging.getLogger(__name__)
_BINDINGS_CACHE_TTL = 60.0
_IDEMPOTENCY_TTL = 300
_FALLBACK_MESSAGES: dict[str, str] = {
"no_route": "暂不支持该渠道的消息处理。",
"route_error": "消息路由异常,请稍后重试。",
"dispatch_error": "消息处理异常,请稍后重试。",
"approval_denied": "操作未通过审批。",
"approval_check_error": "审批校验异常,请稍后重试。",
"rate_limited": "当前消息量较大,请稍后重试。",
}
class MessageProcessor:
def __init__(
self,
stream_fn: Callable[..., AsyncIterator[bytes]],
channel_manager=None,
*,
msg_repo=None,
binding_repo=None,
user_mapping_repo=None,
thread_mapping_repo=None,
allowlist=None,
pairing=None,
identity_link_resolver=None,
response_prefix: str = "",
human_delay: tuple[float, float] | None = None,
):
self._bridge = AgentBridge(stream_fn, channel_manager)
self._msg_repo = msg_repo or ChannelMsgRecordRepository()
self._binding_repo = binding_repo or ChannelBindingRepository()
self._user_mapping_repo = user_mapping_repo or ChannelUserMappingRepository()
self._thread_mapping_repo = thread_mapping_repo or ChannelThreadMappingRepository()
self._hooks = LifecycleHookRegistry()
self._allowlist = allowlist or AllowlistChecker()
if pairing:
self._pairing = pairing
else:
self._pairing = PairingManager(InMemoryPairingStore(), allowlist=self._allowlist)
if allowlist is None:
self._allowlist.load_dm_allowlist(["*"])
self._allowlist.load_group_allowlist(["*"])
self._response_prefix = response_prefix
self._human_delay = human_delay
self._identity_links = identity_link_resolver or IdentityLinkResolver()
self._matcher = RouteMatcher()
self._session_key_builder = SessionKeyBuilder()
self._active_message_count = 0
self._active_count_lock = asyncio.Lock()
self._cached_bindings: list[RouteBinding] = []
self._bindings_ts: float = 0.0
self._fence = ConversationFence()
self._self_user_ids: dict[str, set[str]] = {}
def set_self_user_ids(self, channel_type: str, account_id: str, user_ids: set[str]) -> None:
key = f"{channel_type}:{account_id}"
self._self_user_ids[key] = user_ids
@property
def hooks(self) -> LifecycleHookRegistry:
return self._hooks
async def _get_bindings(self) -> list[RouteBinding]:
now = time.monotonic()
if now - self._bindings_ts < _BINDINGS_CACHE_TTL and self._cached_bindings:
return self._cached_bindings
db_bindings = await self._binding_repo.list_all()
self._cached_bindings = [to_route_binding(b) for b in db_bindings]
self._bindings_ts = now
await self._matcher.set_bindings(self._cached_bindings)
return self._cached_bindings
async def _get_user_for_channel(self, internal_user_id: str | None) -> User | None:
if not internal_user_id:
return None
try:
uid = int(internal_user_id)
except (ValueError, TypeError):
return None
async with pg_manager.get_async_session_context() as db:
return (await db.execute(select(User).where(User.id == uid))).scalar_one_or_none()
def _is_echo(self, msg: UnifiedMessage) -> bool:
key = f"{msg.channel_type}:{msg.account_id}"
self_ids = self._self_user_ids.get(key)
if not self_ids:
return False
return msg.sender.id in self_ids
async def _ensure_security_loaded(self) -> None:
await self._identity_links.ensure_loaded()
self._session_key_builder = SessionKeyBuilder(
identity_links=self._identity_links.list_links()
)
async def process(self, msg: UnifiedMessage) -> None:
if self._is_echo(msg):
logger.debug("Skipping echo message: %s/%s", msg.channel_type, msg.msg_id)
return
limiter = await rate_limit_manager.get_limiter(msg.channel_type, msg.account_id)
acquire_result = await limiter.try_acquire()
if not acquire_result:
record_rate_limit_reject(msg.channel_type)
logger.warning(
"Rate limited: channel=%s account=%s retry_after=%.2fs",
msg.channel_type, msg.account_id, acquire_result.retry_after_sec,
)
await self._send_fallback(msg, _FALLBACK_MESSAGES["rate_limited"])
return
if not await claim(msg.msg_id, ttl=_IDEMPOTENCY_TTL):
logger.debug("Duplicate/inflight message ignored: %s", msg.msg_id)
return
try:
async with self._active_count_lock:
self._active_message_count += 1
set_inflight(self._active_message_count)
await self._ensure_security_loaded()
await self._process_impl(msg, limiter)
finally:
await release(msg.msg_id)
async with self._active_count_lock:
self._active_message_count = max(0, self._active_message_count - 1)
set_inflight(self._active_message_count)
async def _process_impl(self, msg: UnifiedMessage, limiter) -> None:
trace = await create_channel_trace(msg)
await trace.add_span("idempotency_check", metadata={"claimed": True})
conversation_key = self._fence.key_for(msg)
version, stop_event = self._fence.enter(conversation_key)
lock = self._fence.lock_for(conversation_key)
logger.debug(
"processor: phase=enter msg_id=%s channel=%s conv_key=%s version=%d",
msg.msg_id, msg.channel_type, conversation_key, version,
)
async with lock:
if version != self._fence.current_version(conversation_key):
logger.debug("Foreground fence: message superseded for %s", conversation_key)
record_message(msg.channel_type, "superseded")
await trace.finish(error="superseded")
return
record_message(msg.channel_type, "received")
plugin = ChannelPluginRegistry.get(msg.channel_type)
if isinstance(plugin, InboundMessageHook):
try:
result = await plugin.on_message_received(msg)
if result is None:
logger.info("Message dropped by inbound hook: %s", msg.msg_id)
await trace.finish(error="dropped_by_hook")
return
except Exception:
logger.exception("Inbound hook failed, proceeding: %s", msg.msg_id)
try:
await self._hooks.fire(HookEvent.MESSAGE_RECEIVED, msg)
except Exception:
logger.exception("MESSAGE_RECEIVED hook failed: %s", msg.msg_id)
chat_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
chat_type = "group" if msg.sender.kind != PeerKind.DIRECT else "direct"
record_data = {
"channel_id": msg.account_id,
"channel_type": msg.channel_type,
"message_id": msg.msg_id,
"chat_id": chat_id,
"chat_type": chat_type,
"content_type": msg.message_type.value if msg.message_type else "text",
"sender_user_id": msg.sender.id,
"content_preview": (msg.content or "")[:500],
"reply_to_message_id": msg.metadata.get("reply_to_message_id") if msg.metadata else None,
"extra_data": msg.raw_payload,
}
record = None
start_time = time.monotonic()
try:
record = await self._msg_repo.create_record(record_data)
if stop_event.is_set():
logger.debug("Aborted before dispatch for %s", conversation_key)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
await self._msg_repo.mark_error(record.id, "aborted_by_fence", elapsed_ms)
await trace.finish(error="aborted_by_fence")
return
bindings = await self._get_bindings()
dispatcher = MessageDispatcher(
bindings,
self._allowlist,
self._pairing,
matcher=self._matcher,
session_key_builder=self._session_key_builder,
user_mapping_repo=self._user_mapping_repo,
thread_mapping_repo=self._thread_mapping_repo,
)
try:
await self._hooks.fire(HookEvent.BEFORE_AGENT_RUN, msg)
except Exception:
logger.exception("BEFORE_AGENT_RUN hook failed: %s", msg.msg_id)
result = await dispatcher.dispatch(msg, trace=trace)
record_dispatch_duration_ms(
msg.channel_type,
(time.monotonic() - start_time) * 1000,
)
logger.debug(
"processor: phase=dispatched msg_id=%s handled_by=%s success=%s agent_config_id=%s",
msg.msg_id, result.handled_by, result.success, result.agent_config_id,
)
if result.handled_by == "command":
if result.command_response:
await self._send_reply(msg, result.command_response)
try:
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, result, result.command_response)
except Exception:
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
await self._msg_repo.mark_success(
record.id,
"",
(result.command_response or "")[:500],
elapsed_ms,
)
await trace.finish()
return
if not result.success:
try:
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, result, None)
except Exception:
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
error_msg = result.error or "dispatch_failed"
fallback = _FALLBACK_MESSAGES.get(error_msg)
if fallback is None and result.error and result.error.startswith("approval_denied:"):
fallback = _FALLBACK_MESSAGES["approval_denied"]
if fallback:
await self._send_fallback(msg, fallback)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
await self._msg_repo.mark_error(record.id, error_msg, elapsed_ms)
await trace.finish(error=error_msg)
return
if stop_event.is_set():
logger.debug("Aborted after dispatch for %s", conversation_key)
try:
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, result, None)
except Exception:
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
await self._msg_repo.mark_error(record.id, "aborted_by_fence", elapsed_ms)
await trace.finish(error="aborted_by_fence")
return
logger.debug(
"processor: phase=reply_start msg_id=%s channel=%s agent_config_id=%s",
msg.msg_id, msg.channel_type, result.agent_config_id,
)
await self._handle_agent_reply(msg, result, plugin, stop_event, record, start_time, conversation_key, trace, limiter)
except Exception as e:
logger.exception("Message processing failed: %s", msg.msg_id)
await trace.finish(error=f"{type(e).__name__}: {e}")
try:
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, None, None)
except Exception:
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
error_text = f"{type(e).__name__}: {e}"
if record is not None:
await self._msg_repo.mark_error(record.id, error_text[:500], elapsed_ms)
record_message(msg.channel_type, "error")
async def _handle_agent_reply(
self,
msg: UnifiedMessage,
result,
plugin,
stop_event: asyncio.Event | None,
record,
start_time: float,
conversation_key: str,
trace: ChannelTrace,
limiter,
) -> None:
user = await self._get_user_for_channel(result.internal_user_id)
if user is None:
logger.error(
"channel_turn skip: msg_id=%s channel=%s reason=no_user_for_internal_id:%s",
msg.msg_id,
msg.channel_type,
result.internal_user_id,
)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
await self._msg_repo.mark_error(record.id, "no_user_for_internal_id", elapsed_ms)
await trace.finish(error="no_user_for_internal_id")
return
agent_config_id = result.agent_config_id
chat_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
try:
await self._hooks.fire(HookEvent.AGENT_BOOTSTRAP, msg, result)
except Exception:
logger.exception("AGENT_BOOTSTRAP hook failed: %s", msg.msg_id)
stream_status: StreamStatus | None = None
if plugin is not None and isinstance(plugin, OutboundProtocol):
try:
await self._hooks.fire(HookEvent.MESSAGE_SENDING, msg, "")
except Exception:
logger.exception("MESSAGE_SENDING hook failed: %s", msg.msg_id)
reply_text, stream_status = await self._process_with_pipeline(
msg,
result,
agent_config_id,
user,
plugin,
stop_event,
limiter=limiter,
trace=trace,
)
else:
async with pg_manager.get_async_session_context() as db:
stream_result = await self._bridge.invoke_with_result(
msg,
result,
agent_config_id,
user,
db,
)
reply_text = stream_result.accumulated_text or None
stream_status = stream_result.status
if reply_text:
if plugin is not None:
reply_text = await self._apply_sending_hooks(msg, plugin, chat_id, reply_text)
if reply_text is None:
elapsed_ms = int((time.monotonic() - start_time) * 1000)
await self._msg_repo.mark_error(record.id, "blocked_by_sending_hook", elapsed_ms)
await trace.finish(error="blocked_by_sending_hook")
return
success = await self._send_with_retry(plugin, chat_id, reply_text, reply_to_id=msg.msg_id)
if not success:
logger.exception("Failed to send reply via %s after retries", msg.channel_type)
else:
logger.warning("No outbound plugin for channel: %s", msg.channel_type)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
await self._msg_repo.mark_error(record.id, "no_outbound_plugin", elapsed_ms)
await trace.finish(error="no_outbound_plugin")
return
if not reply_text and stream_status in (StreamStatus.TIMEOUT, StreamStatus.ERROR, StreamStatus.CANCELLED):
fallback_map = {
StreamStatus.TIMEOUT: "处理超时,请稍后重试。",
StreamStatus.ERROR: "处理异常,已通知管理员。",
StreamStatus.CANCELLED: "处理已取消。",
}
fallback = fallback_map.get(stream_status)
if fallback:
await self._send_fallback(msg, fallback)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
await self._msg_repo.mark_error(record.id, stream_status.value, elapsed_ms)
await trace.finish(error=stream_status.value)
return
if isinstance(plugin, SendLifecycleHook):
try:
await plugin.after_send_success(
chat_id,
{"content": reply_text, "msg_id": msg.msg_id},
)
except Exception:
logger.exception("Send lifecycle hook failed: %s", msg.msg_id)
try:
await self._hooks.fire(HookEvent.MESSAGE_SENT, msg, reply_text)
except Exception:
logger.exception("MESSAGE_SENT hook failed: %s", msg.msg_id)
try:
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, result, reply_text)
except Exception:
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
await self._msg_repo.mark_success(
record.id,
"",
(reply_text or "")[:500],
elapsed_ms,
)
record_message(msg.channel_type, "success")
await trace.finish()
async def _apply_sending_hooks(
self,
msg: UnifiedMessage,
plugin,
chat_id: str,
reply_text: str,
) -> str | None:
if isinstance(plugin, MessageSendingHook):
try:
modified = await plugin.on_message_sending(msg, reply_text)
if modified is None:
logger.info("Message blocked by MessageSendingHook: %s", msg.msg_id)
return None
reply_text = modified
except Exception:
logger.exception("MessageSendingHook failed: %s", msg.msg_id)
if isinstance(plugin, SendLifecycleHook):
try:
await plugin.before_send_attempt(chat_id, reply_text)
except Exception:
logger.exception("before_send_attempt failed: %s", msg.msg_id)
try:
result = await self._hooks.fire_sequential(
HookEvent.MESSAGE_SENDING,
initial=reply_text,
msg=msg,
)
reply_text = result if result is not None else reply_text
except Exception:
logger.exception("MESSAGE_SENDING hook failed: %s", msg.msg_id)
return reply_text
async def _process_with_pipeline(
self,
msg: UnifiedMessage,
result,
agent_config_id: int,
user,
plugin,
stop_event: asyncio.Event | None = None,
*,
limiter=None,
trace: "ChannelTrace | None" = None,
) -> tuple[str | None, StreamStatus | None]:
target_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
async def send_fn(content: str) -> str | None:
if stop_event and stop_event.is_set():
return None
if not content:
return None
send_content = content
if isinstance(plugin, MessageSendingHook):
try:
modified = await plugin.on_message_sending(msg, send_content)
if modified is None:
logger.info("Pipeline send blocked by MessageSendingHook: %s", msg.msg_id)
return None
send_content = modified
except Exception:
logger.exception("Pipeline MessageSendingHook failed: %s", msg.msg_id)
if isinstance(plugin, SendLifecycleHook):
try:
await plugin.before_send_attempt(target_id, send_content)
except Exception:
logger.exception("Pipeline before_send_attempt failed: %s", msg.msg_id)
success = await self._send_with_retry(plugin, target_id, send_content, reply_to_id=msg.msg_id)
if not success:
logger.exception("Pipeline send failed via %s after retries", msg.channel_type)
return None
pipeline = create_block_reply_pipeline(
send_fn=send_fn,
msg=msg,
response_prefix=self._response_prefix,
human_delay=self._human_delay,
)
accumulated: list[str] = []
stream_error: StreamStatus | None = None
exec_span_id = None
if trace:
exec_span_id = await trace.add_span(
"agent_execution",
metadata={
"agent_config_id": agent_config_id,
"thread_id": result.thread_id,
},
)
async with pg_manager.get_async_session_context() as db:
try:
stream_iter = self._bridge.invoke_stream(
msg,
result,
agent_config_id,
user,
db,
)
if limiter is not None:
stream_iter = limiter.run_with_limit(stream_iter)
async for chunk in stream_iter:
if stop_event and stop_event.is_set():
await pipeline.abort()
logger.debug("Pipeline aborted by foreground fence for %s", self._fence.key_for(msg))
break
await pipeline.enqueue(chunk)
if chunk.content:
accumulated.append(chunk.content)
except StreamTimeoutError:
stream_error = StreamStatus.TIMEOUT
logger.warning("Agent stream timeout for channel=%s peer=%s", msg.channel_type, msg.sender.id)
except StreamCancelledError:
stream_error = StreamStatus.CANCELLED
logger.debug("Agent stream cancelled for channel=%s peer=%s", msg.channel_type, msg.sender.id)
await pipeline.wait_idle()
if exec_span_id:
status_msg = stream_error.value if stream_error else "ok"
await trace.end_span(
exec_span_id,
level="ERROR" if stream_error else "DEFAULT",
status_message=status_msg,
metadata={"stream_status": status_msg, "chunks": len(accumulated)},
)
if stop_event and stop_event.is_set():
return None, None
return ("".join(accumulated) if accumulated else None, stream_error)
async def _send_fallback(self, msg: UnifiedMessage, fallback_text: str) -> None:
plugin = ChannelPluginRegistry.get(msg.channel_type)
if plugin is None or not isinstance(plugin, OutboundProtocol):
return
try:
target_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
await plugin.send_text(target_id, fallback_text, reply_to_id=msg.msg_id)
except Exception:
logger.exception("Failed to send fallback reply via %s", msg.channel_type)
async def _send_with_retry(
self,
plugin,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
max_retries: int = 3,
circuit_breaker: "CircuitBreaker | None" = None,
) -> bool:
last_error = None
total_attempts = max_retries + 1
for attempt in range(total_attempts):
if circuit_breaker is not None and not circuit_breaker.allow_request():
logger.warning(
"Circuit breaker OPEN for %s, skipping send",
plugin.id if hasattr(plugin, "id") else "unknown",
)
return False
try:
await plugin.send_text(target_id, content, reply_to_id=reply_to_id)
if circuit_breaker is not None:
circuit_breaker.record_success()
return True
except Exception as e:
last_error = e
if circuit_breaker is not None:
circuit_breaker.record_failure()
logger.warning(
"Send attempt %d/%d failed (target=%s): %s",
attempt + 1, total_attempts, target_id, e,
)
if attempt < max_retries:
delay = min(0.3 * (2 ** attempt), 30.0)
await asyncio.sleep(delay)
logger.error("All %d send attempts failed (target=%s): %s", total_attempts, target_id, last_error)
if isinstance(plugin, SendLifecycleHook):
try:
await plugin.after_send_failure(target_id, str(last_error))
except Exception:
pass
return False
async def _send_reply(self, msg: UnifiedMessage, content: str) -> str | None:
plugin = ChannelPluginRegistry.get(msg.channel_type)
if plugin is None or not isinstance(plugin, OutboundProtocol):
logger.warning("No outbound plugin for channel: %s", msg.channel_type)
return None
target_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
reply_to_id = msg.msg_id
send_content = await self._apply_sending_hooks(msg, plugin, target_id, content)
if send_content is None:
return None
success = await self._send_with_retry(plugin, target_id, send_content, reply_to_id=reply_to_id)
if not success:
logger.exception("Failed to send reply via %s after retries", msg.channel_type)
return None