ForcePilot/backend/package/yuxi/channels/adapters/synologychat/send.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

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")