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

709 lines
28 KiB
Python

"""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"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)")
_MARKDOWN_CODE_RE = re.compile(r"`([^`]+)`")
_MARKDOWN_STRIKE_RE = re.compile(r"~~(.+?)~~")
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
def _format_markdown_to_chat(text: str) -> str:
"""Convert common markdown to Synology Chat supported formatting.
Synology Chat supports:
*bold* (asterisks), _italic_ (underscores), ~strikethrough~ (tildes),
`code` (backticks), > blockquote, * bullet lists,
<URL|label> 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