ForcePilot/backend/package/yuxi/channel/application/service/delivery_service.py
Kris c61d5f0163 feat: 完成通道服务多轮功能迭代
本次提交完成了一系列核心功能迭代与优化:
1.  新增并完善了多个领域模型与端口定义,补充了`__all__`导出规范
2.  优化了会话、绑定、出箱等模块的数据模型,修复了时间字段类型不一致问题
3.  新增了代理ID解析、缓存发布等接口,扩展了系统能力
4.  重构了去重中间件逻辑,优化了空内容校验规则
5.  新增了认证中间件的匿名访问支持,完善了鉴权流程
6.  优化了SSE连接管理,增加了单会话连接上限限制
7.  重构了消息日志与仓储相关代码,将数据类迁移至对应模型目录
8.  新增了重复绑定校验、绑定更新接口,完善了绑定服务逻辑
9.  优化了健康检查逻辑,新增了环境变量控制启动时间线展示
10. 重构了出箱重试工作线程,使用缓存端口替代直接redis操作,新增了消息处理标记逻辑
11. 完善了飞书、Web、钩子等通道的翻译器逻辑,补充了账户ID传递
12. 新增了多种自定义异常类型,优化了异常映射与错误处理流程
13. 完善了配置热重载逻辑,同步认证凭证与校验器配置
14. 重构了Redis缓存实现,增加了异常捕获与包装
2026-05-31 21:42:03 +08:00

239 lines
9.0 KiB
Python

from __future__ import annotations
import asyncio
import logging
import time
from yuxi.channel.domain.event.agent_error import AgentError
from yuxi.channel.domain.event.message_replied import MessageReplied
from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError
from yuxi.channel.domain.model.message.dispatch_result import DispatchResult, SendResult
from yuxi.channel.domain.model.message.stream_chat_request import StreamChatRequest
from yuxi.channel.domain.model.session.channel_session import ChannelSession
from yuxi.channel.domain.model.shared.channel_type import ChannelType
from yuxi.channel.domain.port.agent_port import AgentPort
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.domain.port.event_publisher_port import EventPublisherPort
from yuxi.channel.domain.port.metrics_port import MetricsPort
from yuxi.channel.domain.repository.message_log_repository import MessageLogRepositoryPort
from yuxi.channel.domain.repository.message_repository import MessageRepositoryPort
from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort
logger = logging.getLogger(__name__)
class DeliveryService:
def __init__(
self,
adapters: dict[str, ChannelAdapterPort],
message_repo: MessageRepositoryPort,
outbox_repo: OutboxRepositoryPort,
event_publisher: EventPublisherPort,
agent_port: AgentPort,
*,
message_log_repo: MessageLogRepositoryPort | None = None,
metrics: MetricsPort | None = None,
agent_timeout: float = 120.0,
typing_interval: float = 3.0,
) -> None:
self._adapters = adapters
self._message_repo = message_repo
self._outbox = outbox_repo
self._events = event_publisher
self._agent = agent_port
self._message_log_repo = message_log_repo
self._metrics = metrics
self._agent_timeout = agent_timeout
self._typing_interval = typing_interval
async def deliver(
self,
payload: dict,
session: ChannelSession,
) -> DispatchResult:
message_id = payload["message_id"]
channel_type = payload["channel_type"]
session_id = session.thread_id
trace_id = payload.get("trace_id", "")
content = payload["content"]
start = time.monotonic()
try:
full_response = await self._call_agent(payload, session, content, channel_type, message_id, trace_id)
except AgentCrashError:
await self._events.publish(
AgentError(
message_id=message_id,
channel_type=channel_type,
session_id=session_id,
error_message="agent_crash",
trace_id=trace_id,
)
)
await self._update_log(
trace_id,
message_id,
worker_result="agent_crash",
status="failed",
error_message="agent_crash",
)
if self._metrics:
await self._metrics.record_worker_dispatch_total(channel_type, "agent_crash")
return DispatchResult(success=False, message_id=message_id, error="agent_crash")
deliver = payload.get("metadata", {}).get("deliver", True)
if not deliver:
await self._message_repo.save_assistant_message(thread_id=session_id, content=full_response)
await self._update_log(trace_id, message_id, worker_result="delivered", status="completed")
if self._metrics:
await self._metrics.record_worker_dispatch_duration(channel_type, time.monotonic() - start)
return DispatchResult(success=True, message_id=message_id)
adapter = self._adapters.get(channel_type)
if not adapter:
await self._outbox.enqueue(
message_id=message_id,
session_id=session_id,
channel_type=channel_type,
content=full_response,
trace_id=trace_id,
)
if self._metrics:
await self._metrics.record_worker_dispatch_total(channel_type, "no_adapter")
await self._metrics.record_outbox_enqueued(channel_type)
return DispatchResult(success=False, message_id=message_id, error=f"no adapter for {channel_type}")
sent = await self._try_send(adapter, session_id, full_response, channel_type, trace_id, message_id)
if not sent.success:
await self._outbox.enqueue(
message_id=message_id,
session_id=session_id,
channel_type=channel_type,
content=full_response,
trace_id=trace_id,
)
if self._metrics:
await self._metrics.record_outbox_enqueued(channel_type)
if adapter.capabilities.media:
for attachment in payload.get("attachments", []):
url = attachment.get("url", "")
if url:
await adapter.send_media(
session_id,
url=url,
media_type=attachment.get("media_type", "image"),
metadata={"trace_id": trace_id},
)
await self._message_repo.save_assistant_message(thread_id=session_id, content=full_response)
await self._events.publish(
MessageReplied(
message_id=message_id,
channel_type=channel_type,
session_id=session_id,
trace_id=trace_id,
)
)
await self._update_log(trace_id, message_id, worker_result="delivered", status="completed")
if self._metrics:
await self._metrics.record_worker_dispatch_duration(channel_type, time.monotonic() - start)
await self._metrics.record_worker_dispatch_total(channel_type, "success")
return DispatchResult(success=True, message_id=message_id)
async def _call_agent(
self,
payload: dict,
session: ChannelSession,
content: str,
channel_type: str,
message_id: str,
trace_id: str,
) -> str:
agent_config_id = payload.get("agent_config_id", session.agent_id)
request = StreamChatRequest(
message_id=message_id,
session_id=session.thread_id,
agent_config_id=int(agent_config_id) if agent_config_id else 1,
content=content,
channel_type=ChannelType(channel_type),
metadata=payload.get("metadata", {}),
)
adapter = self._adapters.get(channel_type)
typing_task = await self._start_typing(adapter, session.thread_id)
try:
response = await asyncio.wait_for(
self._agent.stream_chat(request),
timeout=self._agent_timeout,
)
except TimeoutError:
raise AgentCrashError(f"agent timeout after {self._agent_timeout}s")
except Exception as exc:
raise AgentCrashError(str(exc)) from exc
finally:
if typing_task:
typing_task.cancel()
return response
async def _start_typing(self, adapter: ChannelAdapterPort | None, session_id: str) -> asyncio.Task | None:
if not adapter or not adapter.capabilities.typing:
return None
async def typing_loop():
while True:
try:
await adapter.send_typing(session_id)
await asyncio.sleep(self._typing_interval)
except asyncio.CancelledError:
break
except Exception:
break
return asyncio.create_task(typing_loop())
async def _try_send(
self,
adapter: ChannelAdapterPort,
session_id: str,
content: str,
channel_type: str,
trace_id: str,
message_id: str,
) -> SendResult:
try:
return await adapter.send_message(
session_id,
content,
channel_type=channel_type,
metadata={"trace_id": trace_id, "message_id": message_id},
)
except Exception as exc:
logger.error("send failed: %s [%s]", exc, trace_id)
return SendResult(success=False, error=str(exc))
async def _update_log(
self,
trace_id: str,
message_id: str,
*,
worker_result: str,
status: str,
error_message: str | None = None,
) -> None:
if not self._message_log_repo:
return
try:
await self._message_log_repo.update_worker_result(
trace_id=trace_id,
message_id=message_id,
worker_result=worker_result,
status=status,
error_message=error_message,
)
except Exception:
logger.debug("message log update failed for %s", trace_id)