ForcePilot/backend/package/yuxi/channels/adapters/synologychat/send.py
Kris 69f4319023 feat(synologychat): 完成Synology Chat适配器的多维度优化
此提交对Synology Chat适配器进行了全面改进:
1. 新增消息去重、分布式轮询租约、bot名称配置等功能
2. 优化URL提取逻辑,自动清理尾部标点符号
3. 重构发送逻辑,提取通用重试工具函数并优化SID缓存
4. 完善文档提示与配置项,新增轮询租约类型支持
5. 修复认证API路径硬编码问题,调整交互组件提示文案
6. 增加Webhook模式下的DSM客户端兜底初始化
7. 优化导入顺序与代码结构,清理冗余空行
2026-05-13 16:15:22 +08:00

306 lines
9.8 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 collections.abc import Awaitable, Callable
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
_SID_CACHE_TTL = 300
_last_sid_refresh: dict[str, float] = {}
async def _ensure_fresh_sid(client: DSMClient, account_id: str = "default") -> None:
now = time.monotonic()
last = _last_sid_refresh.get(account_id, 0)
if now - last > _SID_CACHE_TTL:
await client.refresh_sid()
_last_sid_refresh[account_id] = now
_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 _execute_with_retry(
send_fn: Callable[[], Awaitable[dict[str, Any]]],
max_attempts: int,
min_delay_ms: int,
max_delay_ms: int,
jitter: float,
circuit_breaker: CircuitBreaker | None = None,
) -> DeliveryResult:
last_error = None
for attempt in range(max_attempts):
try:
if circuit_breaker:
result = await circuit_breaker.call(send_fn)
else:
result = await send_fn()
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 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)
if attempt < max_attempts - 1:
delay = _calc_retry_delay(attempt, min_delay_ms, max_delay_ms, jitter)
logger.warning(f"Send retry {attempt + 1}/{max_attempts} 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_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 _ensure_fresh_sid(client, account_id)
return await client.send_message(chat_id, text)
return await _execute_with_retry(
_attempt_send,
max_retries,
min_delay_ms,
max_delay_ms,
jitter,
circuit_breaker,
)
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 _ensure_fresh_sid(client, account_id)
return await client.send_message(chat_id, text)
return await _execute_with_retry(
_attempt_send,
min(max_retries, 3),
min_delay_ms,
max_delay_ms,
jitter,
circuit_breaker,
)
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 _ensure_fresh_sid(client, account_id)
return await client.send_message(chat_id, text, file_url=media_url)
return await _execute_with_retry(
_attempt_send,
max_retries,
min_delay_ms,
max_delay_ms,
jitter,
circuit_breaker,
)