"""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 base64 import re 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_handler import ( extract_token_from_request, handle_webhook_event, verify_token, ) 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 = 4000 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=4000, 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" self._last_webhook_headers: dict = {} self._last_source_ip: str = "unknown" self._send_lock = asyncio.Lock() self._last_send_ts: dict[str, float] = {} 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: try: 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._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) 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}" ) except ChannelAuthenticationError: self._status = ChannelStatus.ERROR raise except Exception as e: self._status = ChannelStatus.ERROR logger.error(f"[SynologyChat] Webhook connect failed: {e}") raise 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, send_lock=self._send_lock, last_send_ts=self._last_send_ts, account_id=self._account_id, ) 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, client=self._http_client) 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, send_lock=self._send_lock, last_send_ts=self._last_send_ts, account_id=self._account_id, ) 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, send_lock=self._send_lock, last_send_ts=self._last_send_ts, account_id=self._account_id, ) # ---- 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)) # ---- Normalize ---- 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 self.normalize_inbound(raw) async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool: expected_token = self.config.get("webhook_token", "") if not expected_token: return True token = extract_token_from_request(headers, {}, None) return verify_token(token, expected_token) async def handle_webhook(self, body_data: dict) -> ChannelMessage | int: expected_token = self.config.get("webhook_token", "") source_ip = self._last_source_ip token = extract_token_from_request( headers=self._last_webhook_headers, query=body_data.get("_query", {}), body=body_data, ) result = await handle_webhook_event(body_data, token, expected_token, source_ip) status = result.get("status") if status == "unauthorized": return 403 if status == "rate_limited": return 429 if status != "ok" or "payload" not in result: return 400 parsed = result["payload"] wrapped = { "user_id": parsed.get("user_id", ""), "channel_id": parsed.get("channel_id", ""), "channel_type": parsed.get("channel_type", "user"), "channel_name": parsed.get("channel_name", ""), "message": {"text": parsed.get("text", "")}, "event_type": parsed.get("event_type", "message"), "message_id": parsed.get("message_id", ""), "timestamp": parsed.get("timestamp", 0), } return self.normalize_inbound(wrapped) 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', {})}") data = result.get("data", {}) if isinstance(data, bytes): return data if isinstance(data, dict): file_content = data.get("file_content") or data.get("content") or data.get("data", "") if isinstance(file_content, bytes): return file_content if isinstance(file_content, str) and file_content: return base64.b64decode(file_content) return b"" return b"" except ChannelNotConnectedError: raise 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 self._dsm_client: 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}") return webhook_url = self.config.get("incoming_webhook_url", "") if webhook_url: try: result = await send_via_incoming_webhook( webhook_url, error_text, client=self._http_client ) if not result.success: logger.warning(f"[SynologyChat] Webhook error reply failed: {result.error}") except Exception as e: logger.warning(f"[SynologyChat] Webhook error reply exception: {e}") else: logger.warning(f"[SynologyChat] Cannot send error reply: no DSM client and no webhook URL") 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