ForcePilot/backend/package/yuxi/channels/adapters/synologychat/monitor.py
Kris 3ad218e537 feat(synologychat): 新增群晖Chat适配器完整实现
新增群晖Chat渠道适配器的全套实现,包括:
1. 基础适配器与导出接口定义
2. DSM API认证、探测与会话管理
3. 轮询与Webhook两种消息接收方式
4. 消息去重、格式化与规范化处理
5. 多账号支持与权限安全策略
6. 目录用户/群组发现功能
7. 审批配对与流量控制机制
8. 安全审计与配置检查功能
2026-05-12 00:49:16 +08:00

98 lines
3.6 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.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 = "",
) -> 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 = 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)