本次提交新增了完整的多渠道消息网关系统,包括: 1. 支持飞书、钉钉、Web、Hook 四种渠道的适配器与配置 2. 领域模型层:消息、会话、绑定、出箱等核心实体 3. 应用服务层:管道、中间件、DTO 与业务逻辑 4. 基础设施层:持久化、过滤器、队列等端口实现 5. 接口层:REST API、SSE、WebSocket 通信端点 6. 前端页面与路由配置,添加渠道管理菜单 7. 新增相关依赖包与 docker-compose 部署配置
231 lines
8.6 KiB
Python
231 lines
8.6 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:
|
|
if self._metrics:
|
|
await self._metrics.record_worker_dispatch_total(channel_type, "no_adapter")
|
|
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)
|