新增群晖Chat渠道适配器的全套实现,包括: 1. 基础适配器与导出接口定义 2. DSM API认证、探测与会话管理 3. 轮询与Webhook两种消息接收方式 4. 消息去重、格式化与规范化处理 5. 多账号支持与权限安全策略 6. 目录用户/群组发现功能 7. 审批配对与流量控制机制 8. 安全审计与配置检查功能
98 lines
3.6 KiB
Python
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)
|