feat(synologychat): 完成Synology Chat适配器的多维度优化
此提交对Synology Chat适配器进行了全面改进: 1. 新增消息去重、分布式轮询租约、bot名称配置等功能 2. 优化URL提取逻辑,自动清理尾部标点符号 3. 重构发送逻辑,提取通用重试工具函数并优化SID缓存 4. 完善文档提示与配置项,新增轮询租约类型支持 5. 修复认证API路径硬编码问题,调整交互组件提示文案 6. 增加Webhook模式下的DSM客户端兜底初始化 7. 优化导入顺序与代码结构,清理冗余空行
This commit is contained in:
parent
21d07ece21
commit
69f4319023
@ -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",
|
||||
]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -5,7 +5,6 @@ ID normalization, validation, and format hints for user/group targeting.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_CHANNEL_PREFIX = "synologychat:"
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user