此提交对Synology Chat适配器进行了全面改进: 1. 新增消息去重、分布式轮询租约、bot名称配置等功能 2. 优化URL提取逻辑,自动清理尾部标点符号 3. 重构发送逻辑,提取通用重试工具函数并优化SID缓存 4. 完善文档提示与配置项,新增轮询租约类型支持 5. 修复认证API路径硬编码问题,调整交互组件提示文案 6. 增加Webhook模式下的DSM客户端兜底初始化 7. 优化导入顺序与代码结构,清理冗余空行
306 lines
9.8 KiB
Python
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,
|
|
)
|