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)