本次提交包含多项改进: 1. 修复钉钉、WhatsApp、Telegram等适配器的线程动作映射名称 2. 为SynologyChat、iMessage、Urbit等多款适配器新增配置Schema 3. 优化日志输出格式,合并多行日志调用为单行 4. 修复指数退避计算中的空格问题 5. 为QQBot凭证备份模块添加弃用警告 6. 新增多款适配器的凭证持久化存储逻辑 7. 优化Matrix、Nostr、DingDing等适配器的状态存储实现 8. 完善Discord、Slack、Signal等适配器的动作注册逻辑 9. 优化WhatsApp桥接器的QR码获取逻辑 10. 修复IRC适配器的配置比对与重连逻辑
832 lines
34 KiB
Python
832 lines
34 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.adapters.synologychat.accounts import DEFAULT_ACCOUNT_ID, list_account_ids, resolve_account
|
|
from yuxi.channels.adapters.synologychat.approval import ApprovalManager
|
|
from yuxi.channels.adapters.synologychat.auth import apply_env_defaults
|
|
from yuxi.channels.adapters.synologychat.client import DSMClient, DSMClientError
|
|
from yuxi.channels.adapters.synologychat.dedup import MessageDeduplicator
|
|
from yuxi.channels.adapters.synologychat.directory import list_groups as _directory_list_groups
|
|
from yuxi.channels.adapters.synologychat.directory import list_peers as _directory_list_peers
|
|
from yuxi.channels.adapters.synologychat.lease import PollingLease, with_polling_lease
|
|
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.prompt import get_format_hints
|
|
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.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.channels.base import BaseChannelAdapter
|
|
from yuxi.channels.capabilities import ChannelCapabilities
|
|
from yuxi.channels.exceptions import (
|
|
ChannelAuthenticationError,
|
|
ChannelConnectionError,
|
|
ChannelNotConnectedError,
|
|
)
|
|
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
|
from yuxi.channels.meta import ChannelMeta
|
|
from yuxi.channels.models import (
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
DeliveryResult,
|
|
HealthStatus,
|
|
)
|
|
from yuxi.channels.registry import register_builtin_adapter
|
|
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
|
|
|
|
config_schema = {
|
|
"webhook_url": {"type": "str", "required": True, "group": "credentials", "label": "Webhook URL"},
|
|
"verify_token": {"type": "str", "required": False, "group": "credentials", "label": "Verify Token"},
|
|
}
|
|
|
|
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] = {}
|
|
self._stream_chunk_state: dict[str, dict[str, int]] = {}
|
|
|
|
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,
|
|
dedup=self._dedup,
|
|
)
|
|
|
|
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}"
|
|
)
|
|
|
|
dsm_url = self.config.get("dsm_url", "")
|
|
if dsm_url:
|
|
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 self._api_info:
|
|
self._dsm_client = DSMClient(self._http_client, dsm_url, self.config, self._api_info)
|
|
await self._dsm_client.login()
|
|
logger.info("[SynologyChat] DSM client initialized as fallback in webhook mode")
|
|
except Exception as e:
|
|
logger.warning(f"[SynologyChat] DSM fallback init failed (non-fatal): {e}")
|
|
|
|
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:
|
|
try:
|
|
done = self._poll_task.done()
|
|
if not isinstance(done, bool):
|
|
done = False
|
|
except Exception:
|
|
done = False
|
|
if not 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")
|
|
|
|
stream_key = f"{chat_id}:{msg_id}"
|
|
if stream_key not in self._stream_chunk_state:
|
|
self._stream_chunk_state[stream_key] = {"index": 0, "total": 0}
|
|
state = self._stream_chunk_state[stream_key]
|
|
state["index"] += 1
|
|
|
|
text = _format_markdown_to_chunk(chunk, finished)
|
|
|
|
return await send_stream_block(
|
|
self._dsm_client,
|
|
chat_id,
|
|
text,
|
|
self.config,
|
|
self._circuit_breaker,
|
|
chunk_index=state["index"],
|
|
chunk_total=state["total"] or state["index"],
|
|
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:
|
|
bot_name = self.config.get("bot_name") or self.config.get("name", "ForcePilot")
|
|
return normalize_event(raw, self.channel_id, self.channel_type, bot_name, 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("[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
|