from __future__ import annotations import asyncio import os import time from typing import Any, TYPE_CHECKING from yuxi.channels.models import ( ChannelResponse, DeliveryResult, MessageType, ) from yuxi.utils.logging_config import logger from .bridge import BridgeClient from .chunking import chunk_text from .rate_limiter import RateLimiter if TYPE_CHECKING: from yuxi.channels.infra.circuit_breaker import CircuitBreaker class OutboundSequencer: def __init__(self): self._seq = 0 self._last_seq_per_chat: dict[str, int] = {} def next(self, chat_id: str | None = None) -> int: self._seq += 1 seq = self._seq if chat_id: self._last_seq_per_chat[chat_id] = seq return seq def last_for_chat(self, chat_id: str) -> int: return self._last_seq_per_chat.get(chat_id, 0) def reset(self) -> None: self._seq = 0 self._last_seq_per_chat.clear() async def send_with_retry( bridge: BridgeClient, response: ChannelResponse, config: dict[str, Any], rate_limiter: RateLimiter | None = None, ) -> DeliveryResult: chunk_limit = config.get("text_chunk_limit", 2000) chunks = _chunk_response_content(response, chunk_limit) if not chunks: chunks = [_build_send_payload(response, config)] last_result = DeliveryResult(success=True) for chunk_payload in chunks: last_result = await _send_one(bridge, chunk_payload, config, rate_limiter) if not last_result.success: return last_result return last_result async def _send_one( bridge: BridgeClient, payload: dict[str, Any], config: dict[str, Any], rate_limiter: RateLimiter | None = None, ) -> DeliveryResult: retry_config = config.get("retry", {}) max_attempts = retry_config.get("attempts", 3) min_delay = retry_config.get("min_delay_ms", 500) / 1000 max_delay = retry_config.get("max_delay_ms", 10000) / 1000 last_error = None for attempt in range(max_attempts): try: if rate_limiter: await rate_limiter.check_and_wait() result = await bridge.send_message(payload) if result.success: return result last_error = result.error except Exception as e: last_error = str(e) logger.warning(f"[ZaloUser] Send attempt {attempt + 1}/{max_attempts} failed: {e}") if attempt < max_attempts - 1: delay = min(min_delay * (2**attempt), max_delay) await asyncio.sleep(delay) return DeliveryResult(success=False, error=last_error or "Max retries exceeded") def _chunk_response_content(response: ChannelResponse, chunk_limit: int) -> list[dict[str, Any]]: if response.message_type != MessageType.TEXT: return [] if len(response.content) <= chunk_limit: return [] from .text_styles import chunk_styled_message, has_markdown_syntax, markdown_to_zalo_styles if has_markdown_syntax(response.content): styled = markdown_to_zalo_styles(response.content) styled_chunks = chunk_styled_message(styled, chunk_limit) results: list[dict[str, Any]] = [] for i, chunk in enumerate(styled_chunks): payload = { "conversation_id": response.identity.channel_chat_id, "message_type": "text", "text": chunk.plain_text, "styled_paragraphs": chunk.to_dict(), } if i == 0 and response.reply_to_message_id: payload["quote_message_id"] = response.reply_to_message_id results.append(payload) return results text_chunks = chunk_text(response.content, chunk_limit) results: list[dict[str, Any]] = [] for i, chunk_text_val in enumerate(text_chunks): chunk_response = ChannelResponse( identity=response.identity, message_type=MessageType.TEXT, content=chunk_text_val, ) if i == 0 and response.reply_to_message_id: chunk_response.reply_to_message_id = response.reply_to_message_id if response.metadata: chunk_response.metadata = response.metadata payload = _build_send_payload(chunk_response, {}) results.append(payload) return results def _build_send_payload(response: ChannelResponse, config: dict[str, Any]) -> dict[str, Any]: payload: dict[str, Any] = { "conversation_id": response.identity.channel_chat_id, "message_type": response.message_type.value, "text": response.content, } reply_mode = config.get("reply_to_mode", "first") if reply_mode != "off" and response.reply_to_message_id: payload["quote_message_id"] = response.reply_to_message_id custom_styles = response.metadata.get("custom_text_styles") if custom_styles and isinstance(custom_styles, list): payload["custom_text_styles"] = custom_styles _ATTACHMENT_TYPES: dict[MessageType, str] = { MessageType.IMAGE: "image", MessageType.VIDEO: "video", MessageType.AUDIO: "audio", MessageType.FILE: "file", } attachment_bridge_type = _ATTACHMENT_TYPES.get(response.message_type) if attachment_bridge_type and response.attachments: att = response.attachments[0] payload["attachments"] = [ { "type": attachment_bridge_type, "url": att.url, } ] if response.message_type == MessageType.FILE and att.filename: payload["attachments"][0]["filename"] = att.filename if response.metadata.get("chat_type") == "group" and response.metadata.get("mention_user_ids"): payload["mention_user_ids"] = response.metadata["mention_user_ids"] if response.metadata.get("disable_notification"): payload["disable_notification"] = True if response.metadata.get("ttl"): payload["ttl"] = response.metadata["ttl"] return payload async def send_typing(bridge: BridgeClient, conversation_id: str) -> DeliveryResult: try: await bridge.send_typing(conversation_id) return DeliveryResult(success=True, metadata={"action": "typing"}) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_delivered_event(bridge: BridgeClient, conversation_id: str, msg_id: str) -> DeliveryResult: try: await bridge.send_delivered(conversation_id, msg_id) return DeliveryResult(success=True, metadata={"action": "delivered"}) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_seen_event(bridge: BridgeClient, conversation_id: str, msg_id: str) -> DeliveryResult: try: await bridge.send_seen(conversation_id, msg_id) return DeliveryResult(success=True, metadata={"action": "seen"}) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_link( bridge: BridgeClient, conversation_id: str, url: str, caption: str = "", ) -> DeliveryResult: try: payload: dict[str, Any] = { "conversation_id": conversation_id, "message_type": "link", "url": url, } if caption: payload["text"] = caption return await bridge.send_message(payload) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_audio( bridge: BridgeClient, conversation_id: str, audio_url: str, caption: str = "", file_name: str | None = None, ) -> DeliveryResult: try: derived_name = file_name or _derive_file_name(audio_url, "audio", "audio.mp3") if caption: cap_result = await bridge.send_message( { "conversation_id": conversation_id, "message_type": "text", "text": caption, } ) if not cap_result.success: return cap_result voice_result = await bridge.send_voice_attachment(conversation_id, audio_url, derived_name) return voice_result except Exception as e: return DeliveryResult(success=False, error=str(e)) def _derive_file_name(url_or_path: str, content_type: str, fallback: str) -> str: name = os.path.basename(url_or_path.split("?")[0]) if name and "." in name: return name ext_map = { "audio": ".mp3", "video": ".mp4", "image": ".jpg", } ext = ext_map.get(content_type, ".bin") return f"{fallback.split('.')[0]}{ext}" async def send_stream_chunks( bridge: BridgeClient, conversation_id: str, stream_generator: Any, rate_limiter: RateLimiter | None = None, sequencer: OutboundSequencer | None = None, reasoning_generator: Any = None, reasoning_prefix: str = "\U0001f9e0 Reasoning:\n", circuit_breaker: CircuitBreaker | None = None, ) -> DeliveryResult: if circuit_breaker is not None: from yuxi.channels.infra.circuit_breaker import CircuitState if circuit_breaker.state == CircuitState.OPEN: return DeliveryResult(success=False, error="Circuit breaker open") first_chunk = None try: first_chunk = await stream_generator.__anext__() except StopAsyncIteration: return DeliveryResult(success=True) if rate_limiter: await rate_limiter.check_and_wait() reasoning_text = "" if reasoning_generator is not None: reasoning_parts: list[str] = [] try: async for r_chunk in reasoning_generator: reasoning_parts.append(r_chunk) except Exception: pass if reasoning_parts: reasoning_text = reasoning_prefix + "".join(reasoning_parts) + "\n\n" try: seq_no = sequencer.next(conversation_id) if sequencer else 0 send_text = reasoning_text + first_chunk send_payload: dict[str, Any] = { "conversation_id": conversation_id, "message_type": "text", "text": send_text, } if sequencer: send_payload["send_seq"] = seq_no initial_result = await _send_one(bridge, send_payload, {}, rate_limiter) except Exception: initial_result = DeliveryResult(success=False, error="Initial stream send failed") if not initial_result.success: return initial_result message_id = initial_result.message_id accumulated: list[str] = [first_chunk] last_send = time.monotonic() consecutive_edit_failures = 0 async for chunk in stream_generator: accumulated.append(chunk) now = time.monotonic() if len(accumulated) % 3 == 0 and (now - last_send) > 0.5: try: if rate_limiter: await rate_limiter.check_and_wait() edit_text = reasoning_text + "".join(accumulated) edit_payload: dict[str, Any] = {"conversation_id": conversation_id, "text": edit_text} if sequencer: edit_payload["send_seq"] = sequencer.next(conversation_id) await bridge.post("/messages/edit", json=edit_payload) last_send = now consecutive_edit_failures = 0 except Exception: consecutive_edit_failures += 1 if consecutive_edit_failures >= 3: logger.warning("[ZaloUser] Stream edit failed 3 consecutive times, stopping updates") break if len(accumulated) > 1 and consecutive_edit_failures < 3: try: if rate_limiter: await rate_limiter.check_and_wait() edit_text = reasoning_text + "".join(accumulated) edit_payload: dict[str, Any] = {"conversation_id": conversation_id, "text": edit_text} if sequencer: edit_payload["send_seq"] = sequencer.next(conversation_id) await bridge.post("/messages/edit", json=edit_payload) except Exception: logger.warning("[ZaloUser] Final stream flush edit failed") return DeliveryResult(success=True, message_id=message_id)