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