feat(synologychat): 完成Synology Chat适配器的多维度优化

此提交对Synology Chat适配器进行了全面改进:
1. 新增消息去重、分布式轮询租约、bot名称配置等功能
2. 优化URL提取逻辑,自动清理尾部标点符号
3. 重构发送逻辑,提取通用重试工具函数并优化SID缓存
4. 完善文档提示与配置项,新增轮询租约类型支持
5. 修复认证API路径硬编码问题,调整交互组件提示文案
6. 增加Webhook模式下的DSM客户端兜底初始化
7. 优化导入顺序与代码结构,清理冗余空行
This commit is contained in:
Kris 2026-05-13 16:15:22 +08:00
parent 21d07ece21
commit 69f4319023
12 changed files with 224 additions and 151 deletions

View File

@ -1,10 +1,11 @@
from yuxi.channels.adapters.synologychat.adapter import SynologyChatAdapter
from yuxi.channels.adapters.synologychat.webhook_handler import (
clear_invalid_token_limiter_for_test,
InvalidTokenRateLimiter,
)
from yuxi.channels.adapters.synologychat.setup_wizard import patch_config
from yuxi.channels.adapters.synologychat.dedup import MessageDeduplicator, should_process_event
from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy
from yuxi.channels.adapters.synologychat.setup_wizard import patch_config
from yuxi.channels.adapters.synologychat.webhook_handler import (
InvalidTokenRateLimiter,
clear_invalid_token_limiter_for_test,
)
__all__ = [
"SynologyChatAdapter",
@ -12,4 +13,6 @@ __all__ = [
"InvalidTokenRateLimiter",
"clear_invalid_token_limiter_for_test",
"patch_config",
"MessageDeduplicator",
"should_process_event",
]

View File

@ -20,15 +20,36 @@ 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.meta import ChannelMeta
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
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,
@ -38,27 +59,6 @@ from yuxi.channels.models import (
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
@ -139,6 +139,7 @@ class SynologyChatAdapter(BaseChannelAdapter):
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:
@ -201,6 +202,7 @@ class SynologyChatAdapter(BaseChannelAdapter):
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")
@ -267,6 +269,23 @@ class SynologyChatAdapter(BaseChannelAdapter):
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
@ -282,8 +301,15 @@ class SynologyChatAdapter(BaseChannelAdapter):
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()
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:
@ -367,6 +393,12 @@ class SynologyChatAdapter(BaseChannelAdapter):
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(
@ -375,6 +407,8 @@ class SynologyChatAdapter(BaseChannelAdapter):
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,
@ -439,9 +473,8 @@ class SynologyChatAdapter(BaseChannelAdapter):
# ---- 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")
)
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)
@ -776,15 +809,13 @@ class SynologyChatAdapter(BaseChannelAdapter):
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
)
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")
logger.warning("[SynologyChat] Cannot send error reply: no DSM client and no webhook URL")
def _format_markdown_to_chunk(text: str, finished: bool) -> str:

View File

@ -29,6 +29,10 @@ from yuxi.channels.adapters.synologychat.probe import resolve_api_version
from yuxi.channels.exceptions import ChannelAuthenticationError
from yuxi.utils.logging_config import logger
_DSM_AUTH_PATH = "/webapi/auth.cgi"
_DSM_QUERY_PATH = "/webapi/query.cgi"
_DSM_ENTRY_PATH = "/webapi/entry.cgi"
_ENV_MAP = {
"DSM_URL": "dsm_url",
@ -129,7 +133,7 @@ async def dsm_login(
try:
response = await http_client.get(
f"{base_url}/webapi/auth.cgi",
f"{base_url}{_DSM_AUTH_PATH}",
params=params,
)
response.raise_for_status()
@ -159,7 +163,7 @@ async def dsm_logout(
try:
response = await http_client.get(
f"{base_url}/webapi/auth.cgi",
f"{base_url}{_DSM_AUTH_PATH}",
params={
"api": "SYNO.API.Auth",
"version": str(auth_version),
@ -189,7 +193,7 @@ async def ensure_valid_sid(
for attempt in range(max_retries):
try:
response = await http_client.get(
f"{base_url}/webapi/query.cgi",
f"{base_url}{_DSM_QUERY_PATH}",
params={
"api": "SYNO.Chat.External",
"version": str(chat_version),

View File

@ -72,6 +72,11 @@ class SynologyChatConfig(BaseModel):
description="Channel instance name",
json_schema_extra={"label": "Channel Name", "category": "general"},
)
bot_name: str = Field(
default="",
description="Bot display name in Synology Chat (used for @mention detection)",
json_schema_extra={"label": "Bot Name", "category": "general"},
)
default_agent_id: str = Field(
default="default",
description="Default agent ID for routing incoming messages",
@ -101,6 +106,12 @@ class SynologyChatConfig(BaseModel):
description="Maximum backoff seconds when polling encounters errors",
json_schema_extra={"label": "Max Polling Backoff (s)", "category": "polling"},
)
polling_lease_type: str = Field(
default="memory",
pattern=r"^(memory|redis)$",
description="Polling lease type for distributed coordination: 'memory' or 'redis'",
json_schema_extra={"label": "Polling Lease Type", "category": "polling"},
)
text_chunk_limit: int = Field(
default=4000,
ge=100,

View File

@ -57,11 +57,13 @@ class RedisPollingLease:
self._lease_key = lease_key
self._ttl = ttl_seconds
self._instance_id = f"{id(self)}:{time.time()}"
self._last_known_held: bool = False
async def acquire(self) -> bool:
try:
result = await self._redis.set(self._lease_key, self._instance_id, nx=True, ex=self._ttl)
return bool(result)
self._last_known_held = bool(result)
return self._last_known_held
except Exception as e:
logger.warning(f"[SynologyChat] Redis lease acquire failed: {e}")
return False
@ -76,6 +78,7 @@ class RedisPollingLease:
end
"""
await self._redis.eval(script, 1, self._lease_key, self._instance_id)
self._last_known_held = False
except Exception as e:
logger.warning(f"[SynologyChat] Redis lease release failed: {e}")
@ -89,19 +92,21 @@ class RedisPollingLease:
end
"""
result = await self._redis.eval(script, 1, self._lease_key, self._instance_id, self._ttl)
return bool(result)
self._last_known_held = bool(result)
return self._last_known_held
except Exception as e:
logger.warning(f"[SynologyChat] Redis lease renew failed: {e}")
return False
@property
def is_held(self) -> bool:
return True
return self._last_known_held
async def check_held(self) -> bool:
try:
current = await self._redis.get(self._lease_key)
return current is not None and current.decode() == self._instance_id
self._last_known_held = current is not None and current.decode() == self._instance_id
return self._last_known_held
except Exception:
return False

View File

@ -13,6 +13,7 @@ from collections.abc import Awaitable, Callable
from typing import Any
from yuxi.channels.adapters.synologychat.client import DSMClient
from yuxi.channels.adapters.synologychat.dedup import MessageDeduplicator
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.models import ChannelMessage, ChannelStatus
from yuxi.utils.logging_config import logger
@ -28,6 +29,7 @@ async def polling_loop(
security_filter: Callable[[ChannelMessage], bool] | None = None,
max_inflight: int = 10,
account_id: str = "",
dedup: MessageDeduplicator | None = None,
) -> None:
poll_interval = config.get("polling_interval_seconds", 3)
max_backoff = config.get("polling_max_backoff_seconds", 60)
@ -69,6 +71,9 @@ async def polling_loop(
events = data.get("events", [])
for event in events:
try:
message_id = str(event.get("message_id", ""))
if dedup and message_id and dedup.check_and_mark(message_id):
continue
message = normalize_fn(event)
if security_filter and not security_filter(message):
continue

View File

@ -25,6 +25,7 @@ from yuxi.utils.datetime_utils import utc_now_naive
_MENTION_RE = re.compile(r"@(\w[\w.-]{0,31})")
_URL_RE = re.compile(r"https?://[^\s]+")
_URL_CLEAN_RE = re.compile(r'[.,;:!?。,;:!?)"\'】」』]+$')
_FILE_IMAGE_MIMES = frozenset(
{
@ -87,7 +88,8 @@ def extract_mentions(text: str, bot_name: str | None = None) -> MentionsInfo:
def extract_urls(text: str) -> list[str]:
return _URL_RE.findall(text)
urls = _URL_RE.findall(text)
return [_URL_CLEAN_RE.sub("", url) for url in urls]
_INJECTION_PATTERNS = [

View File

@ -18,7 +18,7 @@ def get_format_hints() -> str:
"- 链接:使用 <URL|标签> 格式,例如 <https://example.com|示例链接>\n"
"- 不支持 Markdown 表格、代码块、图片嵌入等高级语法\n"
"- 单条消息限制 4000 字符,长回复请分段发送\n"
"- 不支持按钮、卡片等交互组件\n"
"- 按钮、卡片等交互组件暂未启用\n"
)
@ -33,5 +33,5 @@ def get_format_hints_english() -> str:
"- Links: use <URL|label> format, e.g. <https://example.com|Example Link>\n"
"- Markdown tables, code blocks, inline images are NOT supported\n"
"- Single message limit: 4000 characters, split long replies\n"
"- Interactive components (buttons, cards) are NOT supported\n"
"- Interactive components (buttons, cards) are not yet enabled\n"
)

View File

@ -12,6 +12,7 @@ import ipaddress
import random
import socket
import time
from collections.abc import Awaitable, Callable
from typing import Any
from urllib.parse import urlparse
@ -26,6 +27,18 @@ from yuxi.channels.models import ChannelResponse, DeliveryResult
from yuxi.utils.logging_config import logger
_DEFAULT_MIN_SEND_INTERVAL_MS = 500
_SID_CACHE_TTL = 300
_last_sid_refresh: dict[str, float] = {}
async def _ensure_fresh_sid(client: DSMClient, account_id: str = "default") -> None:
now = time.monotonic()
last = _last_sid_refresh.get(account_id, 0)
if now - last > _SID_CACHE_TTL:
await client.refresh_sid()
_last_sid_refresh[account_id] = now
_PRIVATE_IP_RANGES = [
ipaddress.IPv4Network("10.0.0.0/8"),
@ -134,6 +147,49 @@ def _build_text(response: ChannelResponse, config: dict[str, Any]) -> str:
return text[:text_chunk_limit]
async def _execute_with_retry(
send_fn: Callable[[], Awaitable[dict[str, Any]]],
max_attempts: int,
min_delay_ms: int,
max_delay_ms: int,
jitter: float,
circuit_breaker: CircuitBreaker | None = None,
) -> DeliveryResult:
last_error = None
for attempt in range(max_attempts):
try:
if circuit_breaker:
result = await circuit_breaker.call(send_fn)
else:
result = await send_fn()
if result.get("success"):
message_id = result.get("data", {}).get("message_id")
return DeliveryResult(success=True, message_id=message_id)
err_code = result.get("error", {}).get("code", 0)
last_error = f"DSM error code: {err_code}"
if err_code in (105, 101):
return DeliveryResult(success=False, error=last_error)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
except DSMNonRetryableError as e:
logger.error(f"[SynologyChat] Non-retryable send error: {e}")
return DeliveryResult(success=False, error=str(e))
except DSMClientError as e:
last_error = str(e)
except Exception as e:
last_error = str(e)
if attempt < max_attempts - 1:
delay = _calc_retry_delay(attempt, min_delay_ms, max_delay_ms, jitter)
logger.warning(f"Send retry {attempt + 1}/{max_attempts} after {delay:.1f}s: {last_error}")
await asyncio.sleep(delay)
return DeliveryResult(success=False, error=last_error or "Send failed after retries")
async def send_with_retry(
client: DSMClient,
response: ChannelResponse,
@ -151,36 +207,17 @@ async def send_with_retry(
await _wait_send_interval(last_send_ts, send_lock, config, account_id)
async def _attempt_send() -> dict[str, Any]:
await client.refresh_sid()
await _ensure_fresh_sid(client, account_id)
return await client.send_message(chat_id, text)
last_error = None
for attempt in range(max_retries):
try:
result = await circuit_breaker.call(_attempt_send)
if result.get("success"):
message_id = result.get("data", {}).get("message_id")
return DeliveryResult(success=True, message_id=message_id)
else:
err_code = result.get("error", {}).get("code", 0)
last_error = f"DSM error code: {err_code}"
if err_code in (105, 101):
return DeliveryResult(success=False, error=last_error)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
except DSMNonRetryableError as e:
logger.error(f"[SynologyChat] Non-retryable send error: {e}")
return DeliveryResult(success=False, error=str(e))
except DSMClientError as e:
last_error = str(e)
except Exception as e:
last_error = str(e)
delay = _calc_retry_delay(attempt, min_delay_ms, max_delay_ms, jitter)
logger.warning(f"Send retry {attempt + 1}/{max_retries} after {delay:.1f}s: {last_error}")
await asyncio.sleep(delay)
return DeliveryResult(success=False, error=last_error or "Send failed after retries")
return await _execute_with_retry(
_attempt_send,
max_retries,
min_delay_ms,
max_delay_ms,
jitter,
circuit_breaker,
)
async def send_stream_block(
@ -206,35 +243,17 @@ async def send_stream_block(
await _wait_send_interval(last_send_ts, send_lock, config, account_id)
async def _attempt_send() -> dict[str, Any]:
await client.refresh_sid()
await _ensure_fresh_sid(client, account_id)
return await client.send_message(chat_id, text)
last_error = None
for attempt in range(min(2, max_retries)):
try:
result = await circuit_breaker.call(_attempt_send)
if result.get("success"):
message_id = result.get("data", {}).get("message_id")
return DeliveryResult(success=True, message_id=message_id)
else:
err_code = result.get("error", {}).get("code", 0)
last_error = f"DSM error code: {err_code}"
if err_code in (105, 101):
return DeliveryResult(success=False, error=last_error)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
except DSMNonRetryableError as e:
return DeliveryResult(success=False, error=str(e))
except DSMClientError as e:
last_error = str(e)
except Exception as e:
last_error = str(e)
if attempt < 1:
delay = _calc_retry_delay(attempt, min_delay_ms, max_delay_ms, jitter)
await asyncio.sleep(delay)
return DeliveryResult(success=False, error=last_error or "Stream block send failed")
return await _execute_with_retry(
_attempt_send,
min(max_retries, 3),
min_delay_ms,
max_delay_ms,
jitter,
circuit_breaker,
)
async def send_media(
@ -273,38 +292,14 @@ async def send_media(
await _wait_send_interval(last_send_ts, send_lock, config, account_id)
async def _attempt_send() -> dict[str, Any]:
await client.refresh_sid()
await _ensure_fresh_sid(client, account_id)
return await client.send_message(chat_id, text, file_url=media_url)
last_error = None
for attempt in range(max_retries):
try:
if circuit_breaker:
result = await circuit_breaker.call(_attempt_send)
else:
result = await _attempt_send()
if result.get("success"):
message_id = result.get("data", {}).get("message_id")
return DeliveryResult(success=True, message_id=message_id)
err_code = result.get("error", {}).get("code", 0)
last_error = f"DSM error code: {err_code}"
if err_code in (105, 101):
return DeliveryResult(success=False, error=last_error)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
except DSMNonRetryableError as e:
logger.error(f"[SynologyChat] Non-retryable send media error: {e}")
return DeliveryResult(success=False, error=str(e))
except DSMClientError as e:
last_error = str(e)
except Exception as e:
last_error = str(e)
delay = _calc_retry_delay(attempt, min_delay_ms, max_delay_ms, jitter)
logger.warning(f"Send media retry {attempt + 1}/{max_retries} after {delay:.1f}s: {last_error}")
await asyncio.sleep(delay)
return DeliveryResult(success=False, error=last_error or "Send media failed after retries")
return await _execute_with_retry(
_attempt_send,
max_retries,
min_delay_ms,
max_delay_ms,
jitter,
circuit_breaker,
)

View File

@ -192,8 +192,13 @@ def build_setup_prompt(
}
def get_setup_instructions(account_id: str = DEFAULT_ACCOUNT_ID) -> str:
def get_setup_instructions(account_id: str = DEFAULT_ACCOUNT_ID, connect_mode: str = "polling") -> str:
prefix = f"[Named Account: {account_id}] " if account_id != DEFAULT_ACCOUNT_ID else ""
mode_note = (
"Note: This adapter uses DSM API Polling mode, no incoming/outgoing webhook URLs needed."
if connect_mode == "polling"
else "Note: This adapter uses Webhook mode. Ensure Synology Chat Outgoing Webhook is configured."
)
return (
f"{prefix}Synology Chat Setup Guide:\n"
"1. Install 'Synology Chat' package on your DSM via Package Center\n"
@ -201,7 +206,7 @@ def get_setup_instructions(account_id: str = DEFAULT_ACCOUNT_ID) -> str:
"3. Copy the Bot's credentials (DSM URL, username, password)\n"
"4. Configure the adapter with DSM URL, username, and password\n"
"5. Optionally set dm_policy to 'allowlist' or 'pairing' for access control\n"
"Note: This adapter uses DSM API Polling mode, no incoming/outgoing webhook URLs needed."
f"{mode_note}"
+ (
"\nEnvironment variables (DSM_URL, DSM_USERNAME, etc.) are only available "
"for the default account. Named accounts must use explicit configuration."

View File

@ -5,7 +5,6 @@ ID normalization, validation, and format hints for user/group targeting.
from __future__ import annotations
_CHANNEL_PREFIX = "synologychat:"

View File

@ -6,13 +6,13 @@ as a lighter-weight alternative to the DSM API direct connection.
from __future__ import annotations
import asyncio
from typing import Any
import httpx
from yuxi.channels.adapters.synologychat.send import assert_safe_media_url
from yuxi.channels.models import DeliveryResult
from yuxi.utils.logging_config import logger
async def send_via_incoming_webhook(
@ -21,6 +21,7 @@ async def send_via_incoming_webhook(
file_url: str | None = None,
timeout: float = 30.0,
client: httpx.AsyncClient | None = None,
max_retries: int = 3,
) -> DeliveryResult:
if not webhook_url:
return DeliveryResult(success=False, error="Incoming webhook URL not configured")
@ -45,18 +46,30 @@ async def send_via_incoming_webhook(
error=f"Webhook send failed: {result.get('error', {})}",
)
try:
if client:
return await _do_send(client)
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as new_client:
return await _do_send(new_client)
except httpx.TimeoutException:
return DeliveryResult(success=False, error="Incoming webhook request timed out")
except httpx.HTTPStatusError as e:
return DeliveryResult(success=False, error=f"Incoming webhook HTTP {e.response.status_code}")
except Exception as e:
logger.error(f"[SynologyChat] Incoming webhook send failed: {e}")
return DeliveryResult(success=False, error=str(e))
last_error = None
for attempt in range(max_retries):
try:
if client:
result = await _do_send(client)
else:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as new_client:
result = await _do_send(new_client)
if result.success:
return result
last_error = result.error
except httpx.TimeoutException:
last_error = "Incoming webhook request timed out"
except httpx.HTTPStatusError as e:
if e.response.status_code < 500:
return DeliveryResult(success=False, error=f"Incoming webhook HTTP {e.response.status_code}")
last_error = f"Incoming webhook HTTP {e.response.status_code}"
except Exception as e:
last_error = str(e)
if attempt < max_retries - 1:
await asyncio.sleep(1.0 * (2**attempt))
return DeliveryResult(success=False, error=last_error or "Webhook send failed after retries")
def build_webhook_url(dsm_url: str, webhook_token: str) -> str: