新增群晖Chat渠道适配器的全套实现,包括: 1. 基础适配器与导出接口定义 2. DSM API认证、探测与会话管理 3. 轮询与Webhook两种消息接收方式 4. 消息去重、格式化与规范化处理 5. 多账号支持与权限安全策略 6. 目录用户/群组发现功能 7. 审批配对与流量控制机制 8. 安全审计与配置检查功能
295 lines
10 KiB
Python
295 lines
10 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
|
|
_last_send_ts: dict[str, float] = {}
|
|
_send_lock = asyncio.Lock()
|
|
|
|
_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(config: dict[str, Any]) -> None:
|
|
interval_ms = config.get("min_send_interval_ms", _DEFAULT_MIN_SEND_INTERVAL_MS)
|
|
async with _send_lock:
|
|
now = time.monotonic()
|
|
elapsed = (now - _last_send_ts.get("default", 0)) * 1000
|
|
if elapsed < interval_ms:
|
|
await asyncio.sleep((interval_ms - elapsed) / 1000)
|
|
_last_send_ts["default"] = 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,
|
|
) -> 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)
|
|
await _wait_send_interval(config)
|
|
|
|
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,
|
|
) -> 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", 2000)]
|
|
|
|
if chunk_total > 1 and chunk_index > 0:
|
|
text = f"[{chunk_index}/{chunk_total}] {text}"
|
|
|
|
await _wait_send_interval(config)
|
|
|
|
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,
|
|
) -> 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}]"
|
|
await _wait_send_interval(config)
|
|
|
|
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")
|