"""Synology Chat channel adapter. Integrates Synology Chat via the DSM API (SYNO.Chat.External) using polling-based message retrieval. Supports text and media messages with circuit breaker protection, automatic SID refresh, block streaming (simulated via sequential messages), reply-to, security access control, Markdown-to-Chat-format conversion, exec approval, and message deduplication. Experimental message actions (edit/delete/reaction) are gated behind the enable_experimental_message_actions config flag and use undocumented DSM APIs; they are NOT declared as platform capabilities. """ from __future__ import annotations import asyncio import re from collections.abc import AsyncIterator from typing import Any import httpx from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities from yuxi.channels.meta import ChannelMeta from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelConnectionError, ChannelNotConnectedError, ) from yuxi.channels.models import ( ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, HealthStatus, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.channels.adapters.synologychat.client import DSMClient, DSMClientError from yuxi.channels.adapters.synologychat.monitor import polling_loop from yuxi.channels.adapters.synologychat.normalize import normalize_event from yuxi.channels.adapters.synologychat.probe import probe_dsm from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy from yuxi.channels.adapters.synologychat.send import send_media, send_stream_block, send_with_retry from yuxi.channels.adapters.synologychat.session import resolve_session_route from yuxi.channels.adapters.synologychat.auth import apply_env_defaults from yuxi.channels.adapters.synologychat.accounts import list_account_ids, resolve_account, DEFAULT_ACCOUNT_ID from yuxi.channels.adapters.synologychat.lease import PollingLease, with_polling_lease from yuxi.channels.adapters.synologychat.approval import ApprovalManager from yuxi.channels.adapters.synologychat.dedup import MessageDeduplicator from yuxi.channels.adapters.synologychat.directory import list_peers as _directory_list_peers from yuxi.channels.adapters.synologychat.directory import list_groups as _directory_list_groups from yuxi.channels.adapters.synologychat.prompt import get_format_hints from yuxi.channels.adapters.synologychat.webhook_send import send_via_incoming_webhook from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger _MARKDOWN_BOLD_RE = re.compile(r"\*\*(.+?)\*\*") _MARKDOWN_ITALIC_RE = re.compile(r"(? str: """Convert common markdown to Synology Chat supported formatting. Synology Chat supports: *bold* (asterisks), _italic_ (underscores), ~strikethrough~ (tildes), `code` (backticks), > blockquote, * bullet lists, link format """ if not text: return text text = _MARKDOWN_LINK_RE.sub(r"<\2|\1>", text) text = _MARKDOWN_ITALIC_RE.sub(r"_\1_", text) text = _MARKDOWN_BOLD_RE.sub(r"*\1*", text) text = _MARKDOWN_CODE_RE.sub(r"`\1`", text) text = _MARKDOWN_STRIKE_RE.sub(r"~\1~", text) return text @register_builtin_adapter class SynologyChatAdapter(BaseChannelAdapter): channel_id = "synologychat" channel_type = ChannelType.SYNOLOGYCHAT text_chunk_limit = 2000 supports_markdown = True supports_chat_formatting = True supports_streaming = True streaming_modes = ["block"] max_media_size_mb = 32 capabilities = ChannelCapabilities( chat_types=["direct", "group"], media=True, reply=True, block_streaming=True, supports_markdown=True, supports_streaming=True, streaming_modes=["block"], text_chunk_limit=2000, max_media_size_mb=32, unsend=False, edit=False, reactions=False, ) meta = ChannelMeta(id="synologychat", label="Synology Chat") dm_scope = "per-account-channel-peer" def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._http_client: httpx.AsyncClient | None = None self._dsm_client: DSMClient | None = None self._api_info: dict[str, Any] | None = None self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60) self._poll_task: asyncio.Task | None = None self._security: SynologyChatSecurityPolicy | None = None self._approval: ApprovalManager | None = None self._dedup = MessageDeduplicator() self._agent_timeout = config.get("agent_timeout_seconds", 120) self._experimental_actions = config.get("enable_experimental_message_actions", False) self._send_mode = config.get("send_mode", "dsm_api") self._account_id = config.get("account_id", DEFAULT_ACCOUNT_ID) self._dangerously_allow_name_matching = config.get("dangerously_allow_name_matching", False) self._dangerously_allow_inherited_webhook_path = config.get("dangerously_allow_inherited_webhook_path", False) self._webhook_path_source: str = "default" async def connect(self) -> None: if self._status == ChannelStatus.CONNECTED: return apply_env_defaults(self.config) self._status = ChannelStatus.CONNECTING logger.info(f"[SynologyChat] Starting channel '{self.config.get('name', self.channel_id)}'") connect_mode = self.config.get("connect_mode", "polling") if connect_mode == "webhook": await self._connect_webhook_mode() return await self._connect_polling_mode() async def _connect_polling_mode(self) -> None: dsm_url = self.config.get("dsm_url", "") if not dsm_url: raise ChannelAuthenticationError("dsm_url not configured") try: self._http_client = httpx.AsyncClient( base_url=dsm_url, timeout=httpx.Timeout(10.0, read=30.0), verify=self.config.get("verify_ssl", True), ) self._api_info = await probe_dsm(self._http_client, dsm_url) if not self._api_info: raise ChannelNotConnectedError() self._dsm_client = DSMClient( self._http_client, dsm_url, self.config, self._api_info, ) await self._dsm_client.login() self._security = SynologyChatSecurityPolicy(self.config, account_id=self._account_id) self._approval = ApprovalManager(self.config) logger.info( f"[SynologyChat] Security: dm_policy={self._security.dm_policy}, " f"group_policy={self._security.group_policy}, account={self._account_id}" ) self._status = ChannelStatus.CONNECTED logger.info(f"[SynologyChat] Channel '{self.config.get('name', self.channel_id)}' started successfully") async def _run_polling(): await polling_loop( self._dsm_client, self.config, self._normalize, self._handle_message, lambda: self._status, self._circuit_breaker, security_filter=self._security.check, account_id=self._account_id, ) lease_type = self.config.get("polling_lease_type", "memory") if lease_type == "redis": from yuxi.channels.adapters.synologychat.lease import RedisPollingLease redis_client = self.config.get("polling_lease_redis_client") if redis_client: logger.info("[SynologyChat] Using Redis-based polling lease") lease = RedisPollingLease(redis_client) else: logger.warning("[SynologyChat] Redis lease configured but no redis_client, falling back to memory") lease = PollingLease() else: lease = PollingLease() self._poll_task = asyncio.create_task(with_polling_lease(lease, _run_polling)) except ChannelAuthenticationError: self._status = ChannelStatus.ERROR raise except ChannelNotConnectedError: self._status = ChannelStatus.ERROR raise except httpx.HTTPError as e: self._status = ChannelStatus.ERROR logger.error(f"[SynologyChat] HTTP error during connect: {e}") raise ChannelConnectionError(str(e)) from e except Exception as e: self._status = ChannelStatus.ERROR logger.error(f"[SynologyChat] Failed to start channel: {e}") raise async def _connect_webhook_mode(self) -> None: webhook_token = self.config.get("webhook_token", "") if not webhook_token: raise ChannelAuthenticationError("webhook_token not configured for webhook mode") webhook_path = self.config.get("webhook_path", "/webhook/synology") explicit_path = self.config.get("webhook_path") is not None if self._account_id and self._account_id != DEFAULT_ACCOUNT_ID: if explicit_path: self._webhook_path_source = "explicit" webhook_path = f"{webhook_path.rstrip('/')}/{self._account_id}" elif self._dangerously_allow_inherited_webhook_path: self._webhook_path_source = "inherited-base" webhook_path = f"{webhook_path.rstrip('/')}/{self._account_id}" else: raise ChannelAuthenticationError( f"Named account '{self._account_id}' requires explicit webhook_path. " f"Set 'dangerously_allow_inherited_webhook_path=true' to inherit from default account." ) else: self._webhook_path_source = "default" self._security = SynologyChatSecurityPolicy(self.config, account_id=self._account_id) self._approval = ApprovalManager(self.config) self._status = ChannelStatus.CONNECTED logger.info( f"[SynologyChat] Webhook mode connected (account={self._account_id}, " f"webhook_path_source={self._webhook_path_source}). " f"Expecting webhook events at path: {webhook_path}" ) async def disconnect(self) -> None: if self._status == ChannelStatus.DISCONNECTED: return logger.info(f"[SynologyChat] Stopping channel '{self.config.get('name', self.channel_id)}'") try: if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() try: await self._poll_task except asyncio.CancelledError: pass self._poll_task = None if self._dsm_client: await self._dsm_client.logout() self._dsm_client = None if self._http_client: await self._http_client.aclose() self._http_client = None self._api_info = None self._security = None self._approval = None self._dedup.clear() self._status = ChannelStatus.DISCONNECTED logger.info(f"[SynologyChat] Channel '{self.config.get('name', self.channel_id)}' stopped") except Exception as e: logger.error(f"[SynologyChat] Error stopping channel: {e}") self._status = ChannelStatus.ERROR # ---- Send ---- async def send(self, response: ChannelResponse) -> DeliveryResult: if self._send_mode == "webhook": return await self._send_via_webhook(response) return await self._send_via_dsm(response) async def _send_via_dsm(self, response: ChannelResponse) -> DeliveryResult: if not self._dsm_client: return DeliveryResult(success=False, error="DSM client not initialized") try: return await send_with_retry( self._dsm_client, response, self.config, self._circuit_breaker, ) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") async def _send_via_webhook(self, response: ChannelResponse) -> DeliveryResult: webhook_url = self.config.get("incoming_webhook_url", "") if not webhook_url: return DeliveryResult(success=False, error="Incoming webhook URL not configured") content = _format_markdown_to_chat(response.content) file_url = None if response.attachments: primary = response.attachments[0] if primary.type in ("image", "file") and primary.url: file_url = primary.url return await send_via_incoming_webhook(webhook_url, content, file_url=file_url) async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: if not self._dsm_client: return DeliveryResult(success=False, error="DSM client not initialized") return await send_media( self._dsm_client, chat_id, media_type, data, config=self.config, circuit_breaker=self._circuit_breaker, ) async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: if not self._dsm_client: return DeliveryResult(success=False, error="DSM client not initialized") text = _format_markdown_to_chunk(chunk, finished) return await send_stream_block( self._dsm_client, chat_id, text, self.config, self._circuit_breaker, ) # ---- Experimental: edit / delete / reaction ---- async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: if not self._experimental_actions: return DeliveryResult(success=False, error="Experimental message actions are disabled") if not self._dsm_client: return DeliveryResult(success=False, error="DSM client not initialized") try: result = await self._dsm_client.edit_message(chat_id, msg_id, content) if result.get("success"): return DeliveryResult(success=True, message_id=msg_id) err = result.get("error", {}) return DeliveryResult( success=False, error=f"Edit not supported by DSM API (code: {err.get('code', 'unknown')})", ) except DSMClientError as e: return DeliveryResult(success=False, error=str(e)) async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._experimental_actions: return DeliveryResult(success=False, error="Experimental message actions are disabled") if not self._dsm_client: return DeliveryResult(success=False, error="DSM client not initialized") try: result = await self._dsm_client.delete_message(chat_id, msg_id) if result.get("success"): return DeliveryResult(success=True, message_id=msg_id) err = result.get("error", {}) return DeliveryResult( success=False, error=f"Delete not supported by DSM API (code: {err.get('code', 'unknown')})", ) except DSMClientError as e: return DeliveryResult(success=False, error=str(e)) async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._experimental_actions: return DeliveryResult(success=False, error="Experimental message actions are disabled") if not self._dsm_client: return DeliveryResult(success=False, error="DSM client not initialized") try: result = await self._dsm_client.send_reaction(chat_id, msg_id, emoji) if result.get("success"): return DeliveryResult(success=True, message_id=msg_id) err = result.get("error", {}) return DeliveryResult( success=False, error=f"Reaction not supported by DSM API (code: {err.get('code', 'unknown')})", ) except DSMClientError as e: return DeliveryResult(success=False, error=str(e)) # ---- Receive ---- async def receive(self) -> AsyncIterator[ChannelMessage]: if False: yield def normalize_inbound(self, raw: dict) -> ChannelMessage: return normalize_event( raw, self.channel_id, self.channel_type, self.config.get("username"), self.config.get("trigger_word") ) def _normalize(self, raw: dict) -> ChannelMessage: return normalize_event( raw, self.channel_id, self.channel_type, self.config.get("username"), self.config.get("trigger_word") ) def format_outbound(self, response: ChannelResponse) -> dict[str, Any]: content = _format_markdown_to_chat(response.content) payload: dict[str, Any] = { "channel_id": response.identity.channel_chat_id, "text": content, } if response.reply_to_message_id: payload["reply_to"] = response.reply_to_message_id if response.attachments: primary = response.attachments[0] if primary.type in ("image", "file") and primary.url: payload["file_url"] = primary.url return payload # ---- Health & Probe ---- async def health_check(self) -> HealthStatus: if not self._http_client: return HealthStatus(status="unhealthy", last_error="HTTP client not initialized") try: api_info = await probe_dsm(self._http_client, self.config.get("dsm_url", "")) if not api_info: return HealthStatus( status="degraded", last_error="DSM API probe returned empty result", ) if self._dsm_client: channels = await self._dsm_client.list_channels() if channels.get("success"): return HealthStatus( status="healthy", metadata={ "dsm_url": self.config.get("dsm_url", ""), "adapter_status": self._status.value, "channel_count": len(channels.get("data", {}).get("channels", [])), "streaming": "block", "dm_policy": self._security.dm_policy if self._security else "open", }, last_connected_at=utc_now_naive(), ) return HealthStatus(status="degraded", last_error="DSM client not fully initialized") except Exception as e: return HealthStatus(status="unhealthy", last_error=str(e)) async def pre_connect(self) -> dict: dsm_url = self.config.get("dsm_url", "") if not dsm_url: return {"status": "error", "message": "Missing dsm_url"} try: async with httpx.AsyncClient( timeout=httpx.Timeout(5.0, read=10.0), verify=self.config.get("verify_ssl", True), ) as client: api_info = await probe_dsm(client, dsm_url) if api_info: return { "status": "ok", "dsm_url": dsm_url, "available_apis": list(api_info.keys()), } return {"status": "error", "message": "DSM API probe failed"} except Exception as e: return {"status": "error", "message": str(e)} def startup_validation(self) -> list[dict[str, Any]]: issues: list[dict[str, Any]] = [] if not self.config.get("dsm_url"): issues.append( { "code": "missing_dsm_url", "severity": "error", "message": "dsm_url is not configured", } ) if not self.config.get("username"): issues.append( { "code": "missing_username", "severity": "error", "message": "DSM username is not configured", } ) pwd = self.config.get("password", "") pwd_file = self.config.get("password_file", "") if not pwd and not pwd_file: issues.append( { "code": "missing_password", "severity": "error", "message": "Neither password nor password_file is configured", } ) if self.config.get("dm_policy") == "allowlist" and not self.config.get("security", {}).get("allow_from"): issues.append( { "code": "empty_allowlist", "severity": "warning", "message": "dm_policy is 'allowlist' but allow_from is empty", } ) if not self.config.get("verify_ssl", True): issues.append( { "code": "ssl_disabled", "severity": "warning", "message": "SSL verification is disabled", } ) if self._dangerously_allow_name_matching: issues.append( { "code": "dangerous_name_matching", "severity": "warning", "message": "dangerously_allow_name_matching is enabled — " "username-based ID resolution may allow impersonation", } ) if self._dangerously_allow_inherited_webhook_path and self._webhook_path_source == "inherited-base": issues.append( { "code": "inherited_webhook_path", "severity": "warning", "message": "Named account inheriting webhook_path from default account — " "ensure paths do not conflict with other named accounts", } ) if self.config.get("connect_mode") == "webhook": accounts = self.config.get("accounts", {}) if isinstance(accounts, dict) and len(accounts) > 1: paths_seen: set[str] = set() default_path = self.config.get("webhook_path", "/webhook/synology").rstrip("/") paths_seen.add(default_path) for acct_id, acct_cfg in accounts.items(): acct_path = acct_cfg.get("webhook_path", default_path).rstrip("/") if acct_path in paths_seen: issues.append( { "code": "webhook_path_conflict", "severity": "warning", "message": f"Account '{acct_id}' shares webhook_path " f"'{acct_path}' with another account", } ) paths_seen.add(acct_path) return issues def get_account_ids(self) -> list[str]: return list_account_ids(self.config) def get_account_config(self, account_id: str = DEFAULT_ACCOUNT_ID): return resolve_account(self.config, account_id) # ---- Session Routing ---- def resolve_session_route_str(self, msg: ChannelMessage) -> str: default_agent_id = self.config.get("default_agent_id", "default") return resolve_session_route(msg, default_agent_id, self._account_id) # ---- Directory ---- async def list_peers(self) -> list: if not self._dsm_client: return [] return await _directory_list_peers(self._dsm_client) async def list_groups(self) -> list: if not self._dsm_client: return [] return await _directory_list_groups(self._dsm_client) # ---- Agent Format Hints ---- @staticmethod def get_format_hints() -> str: return get_format_hints() # ---- User Info ---- async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: if not self._dsm_client: return {} try: result = await self._dsm_client.user_list() if result.get("success"): users = result.get("data", {}).get("users", []) for user in users: if str(user.get("user_id", "")) == channel_user_id: return { "id": channel_user_id, "username": user.get("username", ""), "name": user.get("name", ""), } return {} except Exception as e: logger.warning(f"[SynologyChat] get_user_info failed for {channel_user_id}: {e}") return {} async def download_media(self, file_id: str) -> bytes: if not self._dsm_client: raise ChannelNotConnectedError() try: result = await self._dsm_client.call( "SYNO.Chat.External", "DownloadFile", {"file_id": file_id}, ) if not result.get("success"): raise ChannelNotConnectedError(f"Download failed: {result.get('error', {})}") return result.get("data", b"") except Exception as e: logger.error(f"[SynologyChat] download_media failed: {e}") raise ChannelNotConnectedError(str(e)) from e async def send_chat_action(self, chat_id: str, action: str = "typing") -> DeliveryResult: if not self._dsm_client: return DeliveryResult(success=False, error="DSM client not initialized") try: await self._dsm_client.call( "SYNO.Chat.External", "ChatAction", {"channel_id": chat_id, "action": action}, ) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def _handle_message(self, message: ChannelMessage) -> None: if not self._message_handler: return if message.identity.channel_message_id: if self._dedup.check_and_mark(message.identity.channel_message_id): logger.debug(f"[SynologyChat] Duplicate event skipped: msg_id={message.identity.channel_message_id}") return try: await asyncio.wait_for( self._message_handler(message), timeout=self._agent_timeout, ) except TimeoutError: logger.error( f"[SynologyChat] Agent timeout ({self._agent_timeout}s) for user {message.identity.channel_user_id}" ) await self._send_error_reply( message.identity.channel_chat_id, "Sorry, the agent took too long to respond. Please try again later.", ) except Exception as e: logger.error(f"[SynologyChat] Agent error for user {message.identity.channel_user_id}: {e}") await self._send_error_reply( message.identity.channel_chat_id, "Sorry, an error occurred while processing your message.", ) async def _send_error_reply(self, chat_id: str, error_text: str) -> None: if not self._dsm_client: return try: result = await self._dsm_client.send_message(chat_id, error_text) if not result.get("success"): logger.warning(f"[SynologyChat] Failed to send error reply to {chat_id}") except Exception as e: logger.warning(f"[SynologyChat] Error sending error reply: {e}") def _format_markdown_to_chunk(text: str, finished: bool) -> str: """For streaming: only format markdown on the final chunk, intermediate chunks stay as-is to avoid broken formatting during streaming.""" if finished and text: return _format_markdown_to_chat(text) return text