这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
796 lines
32 KiB
Python
796 lines
32 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 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"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)")
|
|
_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 = 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
|