from __future__ import annotations import asyncio import os import tempfile import time from typing import Any import aiohttp from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.models import DeliveryResult, ChannelResponse from yuxi.utils.logging_config import logger from .format import format_outbound as _format_outbound from .markdown import markdown_to_whatsapp, text_sanitizer from .media import _MEDIA_SUFFIX_MAP, cleanup_temp_file from .media import download_media as _download_media from .network_errors import ErrorCategory, classify_error from .send import chunk_message class SendManager: def __init__( self, bridge, circuit_breaker: CircuitBreaker, sent_message_cache, echo_filter, debouncer, reaction_level, error_policy, lane_delivery, config: dict[str, Any], stream_buffers: dict[str, str], stream_chunk_counters: dict[str, int], stream_status_msg_ids: dict[str, str], recent_messages: dict[str, dict[str, Any]], last_message_tracker: list[float | None], ): self._bridge = bridge self._circuit_breaker = circuit_breaker self._sent_message_cache = sent_message_cache self._echo_filter = echo_filter self._debouncer = debouncer self._reaction_level = reaction_level self._error_policy = error_policy self._lane_delivery = lane_delivery self._config = config self._stream_buffers = stream_buffers self._stream_chunk_counters = stream_chunk_counters self._stream_status_msg_ids = stream_status_msg_ids self._recent_messages = recent_messages self._last_message_tracker = last_message_tracker self._prefix = config.get("messagePrefix", "") self._chunk_mode = config.get("chunkMode", "length") self._reply_to_mode = config.get("replyToMode", "first") self._block_streaming = config.get("blockStreaming", False) self._default_to = config.get("defaultTo") self.text_chunk_limit = 4000 self.supports_markdown = True @property def last_message_at(self) -> float | None: return self._last_message_tracker[0] if self._last_message_tracker else None @last_message_at.setter def last_message_at(self, value: float | None) -> None: if self._last_message_tracker: self._last_message_tracker[0] = value async def send(self, response: ChannelResponse, silent: bool = False) -> DeliveryResult: jid = response.identity.channel_chat_id if not jid and self._default_to: jid = self._default_to reply_to = response.reply_to_message_id if self._reply_to_mode == "off": reply_to = None if self._debouncer.should_throttle(jid): remaining = self._debouncer.window_remaining(jid) logger.warning(f"[WhatsApp] Throttled message to {jid} (retry in {remaining:.1f}s)") return DeliveryResult( success=False, error=f"Rate limited, retry in {remaining:.1f}s", ) payload = _format_outbound(response) content = payload.get("content", response.content) content = text_sanitizer(content) if self.supports_markdown and getattr(response, "content_format", "") == "markdown": try: content = markdown_to_whatsapp(content) except Exception as e: logger.warning(f"[WhatsApp] Markdown parse failed, using plain text: {e}") if self._prefix: content = f"{self._prefix} {content}" self._echo_filter.record_outbound(jid, content) self._debouncer.record_send(jid) self.last_message_at = time.monotonic() async def _do_send() -> DeliveryResult: send_content = content if len(send_content) > self.text_chunk_limit and self._reply_to_mode != "batched": chunks = chunk_message(send_content, self.text_chunk_limit, mode=self._chunk_mode) results = [] for chunk in chunks: use_reply = reply_to if self._reply_to_mode == "all" else (reply_to if len(results) == 0 else None) result = await self._bridge.send_message( jid=jid, content=chunk, reply_to=use_reply, silent=silent, ) results.append(result) return results[0] if results else DeliveryResult(success=False, error="No chunks") if self._reply_to_mode == "batched" and len(send_content) > self.text_chunk_limit: send_content = send_content[: self.text_chunk_limit - 3] + "..." return await self._send_with_retry(jid, send_content, reply_to, silent) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: suffix = _MEDIA_SUFFIX_MAP.get(media_type, "") if isinstance(data, bytes): with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f: f.write(data) tmp_path = f.name elif isinstance(data, str) and os.path.exists(data): tmp_path = data else: return DeliveryResult(success=False, error=f"Unsupported media data type: {type(data)}") try: return await self._bridge.send_media( jid=chat_id, media_type=media_type, media_path=tmp_path, ) finally: if isinstance(data, bytes) and os.path.exists(tmp_path): cleanup_temp_file(tmp_path) async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not emoji: return await self._bridge.send_reaction( jid=chat_id, message_id=msg_id, emoji="", ) if not self._reaction_level.can_send_reaction(): return DeliveryResult(success=False, error="Reactions disabled by reactionLevel config") return await self._bridge.send_reaction( jid=chat_id, message_id=msg_id, emoji=emoji, ) async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult: return await self.send_reaction(chat_id, msg_id, "") async def send_reaction_from_action(self, action: dict[str, Any]) -> DeliveryResult: chat_jid = action.get("chatJid") or action.get("to") or action.get("chat_id", "") msg_id = action.get("messageId") or action.get("msg_id", "") emoji = action.get("emoji", "") remove = action.get("remove", False) participant = action.get("participant", "") if participant and "@g.us" not in chat_jid: chat_jid = participant if remove or not emoji: return await self.send_reaction(chat_jid, msg_id, "") return await self.send_reaction(chat_jid, msg_id, emoji) async def download_media(self, file_id: str) -> bytes: raw = self._recent_messages.get(file_id) if not raw: raise ValueError(f"Message context not found for file_id: {file_id}") remote_jid = raw.get("key", {}).get("remoteJid", "") message = raw.get("message", {}) return await _download_media(self._bridge, remote_jid, file_id, message) async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: stream_cfg = self._config.get("streaming", {}) if not isinstance(stream_cfg, dict): stream_cfg = {} if finished: status_msg_id = self._stream_status_msg_ids.pop(chat_id, None) if status_msg_id: try: await self._bridge.delete_message(jid=chat_id, message_id=status_msg_id) except Exception: logger.debug(f"[WhatsApp] Failed to delete stream status message {status_msg_id}", exc_info=True) self._stream_chunk_counters.pop(chat_id, None) content = text_sanitizer(chunk) if self.supports_markdown: content = markdown_to_whatsapp(content) if self._lane_delivery.reasoning_enabled: lane_chunks = self._lane_delivery.split_lane_aware(content) messages = self._lane_delivery.format_for_whatsapp(lane_chunks) results = [] for msg in messages: result = await self._bridge.send_message(jid=chat_id, content=msg) results.append(result) return results[0] if results else DeliveryResult(success=False, error="No messages") return await self._bridge.send_message( jid=chat_id, content=content, ) self._stream_chunk_counters[chat_id] = self._stream_chunk_counters.get(chat_id, 0) + 1 await self._bridge.send_presence( jid=chat_id, presence="composing", ) stream_status_updates = stream_cfg.get("streamStatusUpdates", False) status_interval = stream_cfg.get("streamStatusInterval", 5) if stream_status_updates and self._stream_chunk_counters[chat_id] % status_interval == 0: try: status_text = stream_cfg.get("streamStatusText", "Processing...") status_result = await self._bridge.send_message(jid=chat_id, content=status_text) if status_result.success and status_result.message_id: old_status = self._stream_status_msg_ids.get(chat_id) if old_status: try: await self._bridge.delete_message(jid=chat_id, message_id=old_status) except Exception: pass self._stream_status_msg_ids[chat_id] = status_result.message_id except Exception: logger.debug(f"[WhatsApp] Failed to send stream status message to {chat_id}", exc_info=True) block_cfg = stream_cfg.get("block", {}) if not isinstance(block_cfg, dict): block_cfg = {} block_enabled = block_cfg.get("enabled", True) block_coalesce = block_cfg.get("coalesce", False) min_chars = block_cfg.get("coalesce_min_chars", 1500) max_chars = block_cfg.get("coalesce_min_chars", 4096) if not block_enabled: return DeliveryResult(success=True) if block_coalesce: self._stream_buffers.setdefault(chat_id, "") self._stream_buffers[chat_id] += chunk if len(self._stream_buffers[chat_id]) >= min_chars: buffered = self._stream_buffers.pop(chat_id, "") content = text_sanitizer(buffered[:max_chars]) return await self._bridge.send_message(jid=chat_id, content=content) return DeliveryResult(success=True) if len(chunk) >= min_chars: content = text_sanitizer(chunk[:max_chars]) return await self._bridge.send_message(jid=chat_id, content=content) return DeliveryResult(success=True) async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: return DeliveryResult(success=False, error="WhatsApp does not support editing messages") async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: return await self._bridge.delete_message( jid=chat_id, message_id=msg_id, ) async def send_poll(self, chat_id: str, name: str, options: list[str], selectable_count: int = 1) -> DeliveryResult: if len(options) < 2: return DeliveryResult(success=False, error="Poll requires at least 2 options") if len(options) > 12: return DeliveryResult(success=False, error=f"Poll supports max 12 options, got {len(options)}") return await self._bridge.create_poll( jid=chat_id, name=name, options=options, selectable_count=selectable_count, ) async def send_location( self, chat_id: str, latitude: float, longitude: float, name: str = "", address: str = "", ) -> DeliveryResult: return await self._bridge.send_location( jid=chat_id, latitude=latitude, longitude=longitude, name=name, address=address, ) async def send_contact( self, chat_id: str, contacts: list[dict[str, str]], ) -> DeliveryResult: return await self._bridge.send_contact( jid=chat_id, contacts=contacts, ) async def send_sticker( self, chat_id: str, sticker_path: str, reply_to: str | None = None, ) -> DeliveryResult: return await self._bridge.send_sticker( jid=chat_id, sticker_path=sticker_path, reply_to=reply_to, ) async def send_buttons( self, chat_id: str, text: str, buttons: list[dict[str, str]], title: str = "", footer: str = "", ) -> DeliveryResult: return await self._bridge.send_buttons( jid=chat_id, text=text, buttons=buttons, title=title, footer=footer, ) async def send_list_message( self, chat_id: str, text: str, sections: list[dict[str, Any]], title: str = "", footer: str = "", button_text: str = "Select", ) -> DeliveryResult: return await self._bridge.send_list_message( jid=chat_id, text=text, sections=sections, title=title, footer=footer, button_text=button_text, ) async def _send_with_retry( self, jid: str, content: str, reply_to: str | None = None, silent: bool = False, max_attempts: int = 3, ) -> DeliveryResult: last_error: str | None = None for attempt in range(1, max_attempts + 1): try: result = await self._bridge.send_message( jid=jid, content=content, reply_to=reply_to, silent=silent, ) if result.success and result.message_id: self._sent_message_cache.put( result.message_id, jid, {"content": content[:200], "reply_to": reply_to}, ) if result.success: return result last_error = result.error or "unknown" except aiohttp.ClientConnectorError as e: last_error = f"Bridge connection failed: {e}" logger.warning( f"[WhatsApp] send attempt {attempt}/{max_attempts} failed - bridge connection error for {jid}: {e}" ) except TimeoutError as e: last_error = str(e) logger.warning(f"[WhatsApp] send attempt {attempt}/{max_attempts} failed - timeout for {jid}: {e}") except Exception as e: last_error = str(e) error_cat = classify_error(str(e)) if error_cat in (ErrorCategory.PERMANENT, ErrorCategory.AUTH): logger.warning( f"[WhatsApp] send failed for {jid} - {error_cat.value} error (not retrying): {e}" ) break logger.warning(f"[WhatsApp] send attempt {attempt}/{max_attempts} failed for {jid}: {e}") if attempt < max_attempts: error_cat = classify_error(last_error or "") if error_cat == ErrorCategory.RATE_LIMIT: delay_ms = 2000 * attempt else: delay_ms = 500 * attempt logger.info(f"[WhatsApp] Retrying send to {jid} in {delay_ms}ms (attempt {attempt})") await asyncio.sleep(delay_ms / 1000) logger.error(f"[WhatsApp] send failed after {max_attempts} attempts for {jid}") if self._error_policy.should_notify(jid, last_error or "send_failed"): last_error = self._error_policy.format_error_message(last_error or "send failed") return DeliveryResult(success=False, error=last_error or "send failed")