这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""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")
|