"""Message delivery with retry and circuit breaker for Synology Chat. Supports text messages with chunking, reply-to, media attachment delivery, and streaming (block mode: multiple sequential sends), with configurable exponential backoff retry and minimum send interval control. """ from __future__ import annotations import asyncio import ipaddress import random import socket import time from typing import Any from urllib.parse import urlparse from yuxi.channels.adapters.synologychat.client import ( DSMClient, DSMClientError, DSMNonRetryableError, ) from yuxi.channels.exceptions import DeliveryFailedError from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.models import ChannelResponse, DeliveryResult from yuxi.utils.logging_config import logger _DEFAULT_MIN_SEND_INTERVAL_MS = 500 _PRIVATE_IP_RANGES = [ ipaddress.IPv4Network("10.0.0.0/8"), ipaddress.IPv4Network("172.16.0.0/12"), ipaddress.IPv4Network("192.168.0.0/16"), ipaddress.IPv4Network("127.0.0.0/8"), ipaddress.IPv4Network("169.254.0.0/16"), ipaddress.IPv4Network("0.0.0.0/8"), ipaddress.IPv6Network("::1/128"), ipaddress.IPv6Network("fc00::/7"), ] def _is_private_ip(addr: str) -> bool: try: ip = ipaddress.ip_address(addr) except ValueError: return False return any(ip in network for network in _PRIVATE_IP_RANGES) def _resolve_host_sync(hostname: str) -> set[str]: ips: set[str] = set() try: for info in socket.getaddrinfo(hostname, None): ip = info[4][0] ips.add(ip) except socket.gaierror: pass return ips async def assert_safe_media_url(media_url: str) -> None: if not media_url: raise DeliveryFailedError("SSRF check failed: empty URL") parsed = urlparse(media_url) if parsed.scheme not in ("https", "http"): raise DeliveryFailedError(f"SSRF check failed: unsupported scheme '{parsed.scheme}'") hostname = parsed.hostname if not hostname: raise DeliveryFailedError("SSRF check failed: no hostname in URL") if _is_private_ip(hostname): raise DeliveryFailedError(f"SSRF check failed: private IP address '{hostname}'") loop = asyncio.get_running_loop() ips = await loop.run_in_executor(None, _resolve_host_sync, hostname) if any(_is_private_ip(ip) for ip in ips): raise DeliveryFailedError(f"SSRF check failed: hostname '{hostname}' resolves to private IP") def _parse_retry_config(config: dict[str, Any]) -> tuple[int, int, int, float]: retry_cfg = config.get("retry", {}) if not isinstance(retry_cfg, dict): retry_cfg = {} return ( retry_cfg.get("attempts", 3), retry_cfg.get("min_delay_ms", 1000), retry_cfg.get("max_delay_ms", 30000), retry_cfg.get("jitter", 0.1), ) def _calc_retry_delay(attempt: int, min_delay_ms: int, max_delay_ms: int, jitter: float) -> float: delay = min(max_delay_ms / 1000, (min_delay_ms / 1000) * (2**attempt)) delay += random.uniform(0, delay * jitter) return delay async def _wait_send_interval( last_send_ts: dict[str, float], send_lock: asyncio.Lock, config: dict[str, Any], account_id: str = "default", ) -> None: interval_ms = config.get("min_send_interval_ms", _DEFAULT_MIN_SEND_INTERVAL_MS) key = account_id or "default" async with send_lock: now = time.monotonic() elapsed = (now - last_send_ts.get(key, 0)) * 1000 if elapsed < interval_ms: await asyncio.sleep((interval_ms - elapsed) / 1000) last_send_ts[key] = time.monotonic() def _build_text(response: ChannelResponse, config: dict[str, Any]) -> str: text = response.content text_chunk_limit = config.get("text_chunk_limit", 4000) reply_to = response.reply_to_message_id reply_mode = config.get("reply_to_mode", "off") if reply_mode != "off" and reply_to: quote_text = response.metadata.get("quote_text", "") or f"(reply to message {reply_to})" prefix = f"> {quote_text}\n" if len(prefix) + len(text) > text_chunk_limit: logger.debug( f"[SynologyChat] Reply prefix dropped due to chunk limit " f"({len(prefix)}+{len(text)} > {text_chunk_limit})" ) prefix = "" text = prefix + text return text[:text_chunk_limit] async def send_with_retry( client: DSMClient, response: ChannelResponse, config: dict[str, Any], circuit_breaker: CircuitBreaker, send_lock: asyncio.Lock | None = None, last_send_ts: dict[str, float] | None = None, account_id: str = "default", ) -> DeliveryResult: max_retries, min_delay_ms, max_delay_ms, jitter = _parse_retry_config(config) chat_id = response.identity.channel_chat_id text = _build_text(response, config) if send_lock and last_send_ts is not None: await _wait_send_interval(last_send_ts, send_lock, config, account_id) async def _attempt_send() -> dict[str, Any]: await client.refresh_sid() return await client.send_message(chat_id, text) last_error = None for attempt in range(max_retries): try: result = await circuit_breaker.call(_attempt_send) if result.get("success"): message_id = result.get("data", {}).get("message_id") return DeliveryResult(success=True, message_id=message_id) else: err_code = result.get("error", {}).get("code", 0) last_error = f"DSM error code: {err_code}" if err_code in (105, 101): return DeliveryResult(success=False, error=last_error) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except DSMNonRetryableError as e: logger.error(f"[SynologyChat] Non-retryable send error: {e}") return DeliveryResult(success=False, error=str(e)) except DSMClientError as e: last_error = str(e) except Exception as e: last_error = str(e) delay = _calc_retry_delay(attempt, min_delay_ms, max_delay_ms, jitter) logger.warning(f"Send retry {attempt + 1}/{max_retries} after {delay:.1f}s: {last_error}") await asyncio.sleep(delay) return DeliveryResult(success=False, error=last_error or "Send failed after retries") async def send_stream_block( client: DSMClient, chat_id: str, chunk: str, config: dict[str, Any], circuit_breaker: CircuitBreaker, chunk_index: int = 0, chunk_total: int = 0, send_lock: asyncio.Lock | None = None, last_send_ts: dict[str, float] | None = None, account_id: str = "default", ) -> DeliveryResult: """Send a text chunk as a new message (block streaming mode).""" max_retries, min_delay_ms, max_delay_ms, jitter = _parse_retry_config(config) text = chunk[: config.get("text_chunk_limit", 4000)] if chunk_total > 1 and chunk_index > 0: text = f"[{chunk_index}/{chunk_total}] {text}" if send_lock and last_send_ts is not None: await _wait_send_interval(last_send_ts, send_lock, config, account_id) async def _attempt_send() -> dict[str, Any]: await client.refresh_sid() return await client.send_message(chat_id, text) last_error = None for attempt in range(min(2, max_retries)): try: result = await circuit_breaker.call(_attempt_send) if result.get("success"): message_id = result.get("data", {}).get("message_id") return DeliveryResult(success=True, message_id=message_id) else: err_code = result.get("error", {}).get("code", 0) last_error = f"DSM error code: {err_code}" if err_code in (105, 101): return DeliveryResult(success=False, error=last_error) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except DSMNonRetryableError as e: return DeliveryResult(success=False, error=str(e)) except DSMClientError as e: last_error = str(e) except Exception as e: last_error = str(e) if attempt < 1: delay = _calc_retry_delay(attempt, min_delay_ms, max_delay_ms, jitter) await asyncio.sleep(delay) return DeliveryResult(success=False, error=last_error or "Stream block send failed") async def send_media( client: DSMClient, chat_id: str, media_type: str, media_data: Any, caption: str = "", config: dict[str, Any] | None = None, circuit_breaker: CircuitBreaker | None = None, send_lock: asyncio.Lock | None = None, last_send_ts: dict[str, float] | None = None, account_id: str = "default", ) -> DeliveryResult: config = config or {} max_retries, min_delay_ms, max_delay_ms, jitter = _parse_retry_config(config) media_url = "" if isinstance(media_data, str): media_url = media_data elif isinstance(media_data, bytes): return DeliveryResult( success=False, error="Synology Chat API requires a public URL (file_url), binary upload not supported", ) elif hasattr(media_data, "url"): media_url = str(getattr(media_data, "url", "")) if not media_url: return DeliveryResult(success=False, error="No valid media URL provided") await assert_safe_media_url(media_url) text = caption or f"[{media_type}]" if send_lock and last_send_ts is not None: await _wait_send_interval(last_send_ts, send_lock, config, account_id) async def _attempt_send() -> dict[str, Any]: await client.refresh_sid() return await client.send_message(chat_id, text, file_url=media_url) last_error = None for attempt in range(max_retries): try: if circuit_breaker: result = await circuit_breaker.call(_attempt_send) else: result = await _attempt_send() if result.get("success"): message_id = result.get("data", {}).get("message_id") return DeliveryResult(success=True, message_id=message_id) err_code = result.get("error", {}).get("code", 0) last_error = f"DSM error code: {err_code}" if err_code in (105, 101): return DeliveryResult(success=False, error=last_error) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except DSMNonRetryableError as e: logger.error(f"[SynologyChat] Non-retryable send media error: {e}") return DeliveryResult(success=False, error=str(e)) except DSMClientError as e: last_error = str(e) except Exception as e: last_error = str(e) delay = _calc_retry_delay(attempt, min_delay_ms, max_delay_ms, jitter) logger.warning(f"Send media retry {attempt + 1}/{max_retries} after {delay:.1f}s: {last_error}") await asyncio.sleep(delay) return DeliveryResult(success=False, error=last_error or "Send media failed after retries")