ForcePilot/backend/package/yuxi/channels/adapters/synologychat/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

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