ForcePilot/backend/package/yuxi/channels/adapters/synologychat/monitor.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

103 lines
3.9 KiB
Python

"""Polling-based message monitoring loop for Synology Chat.
Continuously polls the DSM Chat API for new messages while the channel is connected,
with circuit breaker integration for fault isolation, optional security filtering,
and per-account inflight limiting to prevent resource exhaustion across accounts.
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Awaitable, Callable
from typing import Any
from yuxi.channels.adapters.synologychat.client import DSMClient
from yuxi.channels.adapters.synologychat.dedup import MessageDeduplicator
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.models import ChannelMessage, ChannelStatus
from yuxi.utils.logging_config import logger
async def polling_loop(
client: DSMClient,
config: dict[str, Any],
normalize_fn: Callable[[dict], ChannelMessage],
handle_message_fn: Callable[[ChannelMessage], Awaitable[None]],
get_status_fn: Callable[[], ChannelStatus],
circuit_breaker: CircuitBreaker,
security_filter: Callable[[ChannelMessage], bool] | None = None,
max_inflight: int = 10,
account_id: str = "",
dedup: MessageDeduplicator | None = None,
) -> None:
poll_interval = config.get("polling_interval_seconds", 3)
max_backoff = config.get("polling_max_backoff_seconds", 60)
last_cursor: str = ""
consecutive_failures = 0
inflight_count: dict[str, int] = defaultdict(int)
inflight_lock = asyncio.Lock()
async def _acquire_inflight() -> None:
while True:
async with inflight_lock:
if inflight_count[account_id] < max_inflight:
inflight_count[account_id] += 1
return
await asyncio.sleep(0.1)
async def _release_inflight() -> None:
async with inflight_lock:
inflight_count[account_id] = max(0, inflight_count[account_id] - 1)
async def _handle_with_limit(msg: ChannelMessage) -> None:
await _acquire_inflight()
try:
await handle_message_fn(msg)
finally:
await _release_inflight()
while get_status_fn() == ChannelStatus.CONNECTED:
try:
async def _poll():
return await client.poll_messages(last_cursor)
result = await circuit_breaker.call(_poll)
if result.get("success") and result.get("data"):
consecutive_failures = 0
data = result["data"]
events = data.get("events", [])
for event in events:
try:
message_id = str(event.get("message_id", ""))
if dedup and message_id and dedup.check_and_mark(message_id):
continue
message = normalize_fn(event)
if security_filter and not security_filter(message):
continue
await _handle_with_limit(message)
except Exception as e:
logger.error(f"Error handling poll event: {e}")
last_cursor = data.get("next_cursor", last_cursor)
except CircuitBreakerOpenError:
backoff = min(poll_interval * (2**consecutive_failures), max_backoff)
consecutive_failures += 1
logger.warning(f"[SynologyChat] Circuit breaker open, backing off {backoff}s")
await asyncio.sleep(backoff)
continue
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Polling error: {e}")
await circuit_breaker.record_failure()
consecutive_failures += 1
backoff = min(poll_interval * (2**consecutive_failures), max_backoff)
await asyncio.sleep(backoff)
continue
await asyncio.sleep(poll_interval)