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

264 lines
10 KiB
Python

"""DSM API client encapsulating authenticated communication with Synology Chat.
Provides login/logout, SID refresh, and API call methods with automatic
session recovery on auth errors. Includes user/channel listing, experimental
message edit/delete/reaction, and media download.
Exception Classification
- ConnectionError (retryable): network timeout, DNS failure, connection reset
- RequestError (non-retryable): HTTP 4xx client errors (bad request etc)
- DeliveryFailedError (retryable with caution): HTTP 5xx server errors, unexpected
"""
from __future__ import annotations
import time
from typing import Any
import httpx
from yuxi.channels.adapters.synologychat.auth import dsm_login, dsm_logout, ensure_valid_sid
from yuxi.channels.adapters.synologychat.probe import resolve_api_version
from yuxi.channels.exceptions import DeliveryFailedError
from yuxi.utils.logging_config import logger
_HTTP_RETRYABLE_STATUS = frozenset({429, 502, 503, 504})
class DSMClientError(Exception):
"""Base error for DSM client operations."""
class DSMNonRetryableError(DSMClientError):
"""Error that should NOT be retried (e.g. HTTP 4xx, invalid request)."""
class DSMRetryableError(DSMClientError):
"""Error that CAN be retried (e.g. network timeout, server overload)."""
class DSMClient:
def __init__(
self,
http_client: httpx.AsyncClient,
base_url: str,
config: dict[str, Any],
api_info: dict[str, Any] | None,
):
self._http = http_client
self._base_url = base_url
self._config = config
self._api_info = api_info
self._sid: str | None = None
self._synotoken: str | None = None
self._default_timeout = config.get("request_timeout_seconds", 30.0)
self._user_list_cache: dict[str, Any] | None = None
self._user_list_cache_ts: float = 0.0
self._cache_ttl = config.get("user_list_cache_ttl_seconds", 300)
self._sent_messages: dict[str, tuple[str, float]] = {}
self._sent_message_ttl = config.get("sent_message_cache_ttl_seconds", 600)
@property
def sid(self) -> str | None:
return self._sid
@property
def base_url(self) -> str:
return self._base_url
async def login(self) -> str:
self._sid, self._synotoken = await dsm_login(self._http, self._base_url, self._config, self._api_info)
return self._sid
async def logout(self) -> None:
if not self._sid:
return
await dsm_logout(self._http, self._base_url, self._sid, self._api_info)
self._sid = None
async def refresh_sid(self) -> str:
self._sid, self._synotoken = await ensure_valid_sid(
self._http, self._base_url, self._config, self._sid, self._api_info
)
return self._sid
async def call(
self,
api: str,
method: str,
params: dict[str, Any] | None = None,
auth_required: bool = True,
timeout: float | None = None,
) -> dict[str, Any]:
if auth_required and not self._sid:
await self.refresh_sid()
version = resolve_api_version(self._api_info, api)
url_params: dict[str, Any] = {
"api": api,
"version": str(version),
"method": method,
}
if auth_required and self._sid:
url_params["_sid"] = self._sid
if self._synotoken:
url_params["SynoToken"] = self._synotoken
merged_params = url_params | (params or {})
timeout_config = httpx.Timeout(timeout) if timeout else None
try:
response = await self._http.get(
f"{self._base_url}/webapi/query.cgi",
params=merged_params,
timeout=timeout_config,
)
response.raise_for_status()
result = response.json()
error_code = result.get("error", {}).get("code", 0)
if auth_required and error_code in (106, 118, 119):
self._sid = None
await self.refresh_sid()
return await self.call(api, method, params, auth_required, timeout)
return result
except httpx.TimeoutException as e:
raise DSMRetryableError(f"DSM API timeout ({api}/{method}): {e}") from e
except httpx.ConnectError as e:
raise DSMRetryableError(f"DSM connection failed ({api}/{method}): {e}") from e
except httpx.NetworkError as e:
raise DSMRetryableError(f"DSM network error ({api}/{method}): {e}") from e
except httpx.HTTPStatusError as e:
status = e.response.status_code
if status in _HTTP_RETRYABLE_STATUS:
raise DSMRetryableError(f"DSM API server error {status} ({api}/{method})") from e
if 400 <= status < 500:
raise DSMNonRetryableError(f"DSM API request error {status} ({api}/{method})") from e
raise DeliveryFailedError(f"DSM API call failed ({api}/{method}): HTTP {status}") from e
except httpx.HTTPError as e:
raise DeliveryFailedError(f"DSM API call failed ({api}/{method}): {e}") from e
async def send_message(self, channel_id: str, text: str, file_url: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {
"channel_id": channel_id,
"text": text,
}
if file_url:
payload["file_url"] = file_url
result = await self.call("SYNO.Chat.External", "SendMessage", payload, timeout=self._default_timeout)
if result.get("success"):
msg_id = result.get("data", {}).get("message_id")
if msg_id:
cache_key = f"{channel_id}:{text[:100]}"
self._sent_messages[cache_key] = (str(msg_id), time.monotonic())
self._expire_sent_messages()
else:
err_code = result.get("error", {}).get("code", 0)
logger.error(f"DSM send_message failed: error code {err_code}")
return result
def get_sent_message_id(self, channel_id: str, text: str) -> str | None:
entry = self._sent_messages.get(f"{channel_id}:{text[:100]}")
if entry is None:
return None
msg_id, ts = entry
if time.monotonic() - ts > self._sent_message_ttl:
return None
return msg_id
def _expire_sent_messages(self) -> None:
now = time.monotonic()
expired = [k for k, (_, ts) in self._sent_messages.items() if now - ts > self._sent_message_ttl]
for k in expired:
del self._sent_messages[k]
async def poll_messages(self, cursor: str = "") -> dict[str, Any]:
params: dict[str, Any] = {}
if cursor:
params["id"] = cursor
result = await self.call("SYNO.Chat.External", "Polling", params, timeout=self._default_timeout)
if not result.get("success"):
err_code = result.get("error", {}).get("code", 0)
logger.debug(f"DSM poll returned non-success: error code {err_code}")
return result
async def list_channels(self) -> dict[str, Any]:
return await self.call("SYNO.Chat.External", "List", {}, timeout=self._default_timeout)
# ---- User & Channel Discovery ----
async def user_list(self) -> dict[str, Any]:
"""List available users visible to this bot (cached for configurable TTL)."""
now = time.monotonic()
if self._user_list_cache is not None and (now - self._user_list_cache_ts) < self._cache_ttl:
return self._user_list_cache
result = await self.call("SYNO.Chat.External", "UserList", {}, timeout=self._default_timeout)
if result.get("success"):
self._user_list_cache = result
self._user_list_cache_ts = now
return result
async def channel_list(self) -> dict[str, Any]:
"""List available channels visible to this bot."""
return await self.call("SYNO.Chat.External", "ChannelList", {}, timeout=self._default_timeout)
# ---- Experimental: message edit / delete / reaction ----
# These are NOT officially documented for the SYNO.Chat.External API
# and may fail depending on DSM version. They are used on a best-effort basis.
async def edit_message(self, channel_id: str, message_id: str, text: str) -> dict[str, Any]:
result = await self.call(
"SYNO.Chat.External",
"EditMessage",
{"channel_id": channel_id, "message_id": message_id, "text": text},
timeout=self._default_timeout,
)
if not result.get("success"):
err_code = result.get("error", {}).get("code", 0)
logger.debug(f"DSM edit_message failed (may not be supported): error code {err_code}")
return result
async def delete_message(self, channel_id: str, message_id: str) -> dict[str, Any]:
result = await self.call(
"SYNO.Chat.External",
"DeleteMessage",
{"channel_id": channel_id, "message_id": message_id},
timeout=self._default_timeout,
)
if not result.get("success"):
err_code = result.get("error", {}).get("code", 0)
logger.debug(f"DSM delete_message failed (may not be supported): error code {err_code}")
return result
async def send_reaction(self, channel_id: str, message_id: str, emoji: str) -> dict[str, Any]:
result = await self.call(
"SYNO.Chat.External",
"SendReaction",
{"channel_id": channel_id, "message_id": message_id, "emoji": emoji},
timeout=self._default_timeout,
)
if not result.get("success"):
err_code = result.get("error", {}).get("code", 0)
logger.debug(f"DSM send_reaction failed (may not be supported): error code {err_code}")
return result
async def resolve_user_id_by_username(self, username: str) -> str | None:
"""Resolve webhook user_id to DSM API user_id via username matching.
Synology Chat has separate ID spaces for webhook vs Chat API contexts.
This method resolves a username to its API user_id for cross-space
operations like approvals and directory lookups.
"""
result = await self.user_list()
if not result.get("success"):
return None
users = result.get("data", {}).get("users", [])
for user in users:
if user.get("username") == username:
return str(user.get("user_id", ""))
return None