"""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