此提交对Synology Chat适配器进行了全面改进: 1. 新增消息去重、分布式轮询租约、bot名称配置等功能 2. 优化URL提取逻辑,自动清理尾部标点符号 3. 重构发送逻辑,提取通用重试工具函数并优化SID缓存 4. 完善文档提示与配置项,新增轮询租约类型支持 5. 修复认证API路径硬编码问题,调整交互组件提示文案 6. 增加Webhook模式下的DSM客户端兜底初始化 7. 优化导入顺序与代码结构,清理冗余空行
141 lines
4.4 KiB
Python
141 lines
4.4 KiB
Python
"""Distributed lease for Synology Chat polling coordination.
|
|
|
|
When multiple instances are deployed, prevents duplicate polling operations
|
|
by acquiring a distributed lock before starting the polling loop.
|
|
|
|
Supports both in-memory (single-instance) and Redis-based (multi-instance) modes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from typing import Any
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
_LEASE_TTL_SECONDS = 30
|
|
_LEASE_RENEW_INTERVAL = 10
|
|
|
|
|
|
class PollingLease:
|
|
"""In-memory polling lease for single-instance deployment."""
|
|
|
|
def __init__(self, ttl_seconds: int = _LEASE_TTL_SECONDS):
|
|
self._ttl = ttl_seconds
|
|
self._acquired: bool = False
|
|
self._expires_at: float = 0.0
|
|
|
|
async def acquire(self) -> bool:
|
|
if self._acquired and time.monotonic() < self._expires_at:
|
|
return False
|
|
self._acquired = True
|
|
self._expires_at = time.monotonic() + self._ttl
|
|
return True
|
|
|
|
async def release(self) -> None:
|
|
self._acquired = False
|
|
|
|
async def renew(self) -> bool:
|
|
if not self._acquired:
|
|
return False
|
|
self._expires_at = time.monotonic() + self._ttl
|
|
return True
|
|
|
|
@property
|
|
def is_held(self) -> bool:
|
|
return self._acquired and time.monotonic() < self._expires_at
|
|
|
|
|
|
class RedisPollingLease:
|
|
"""Redis-based distributed polling lease for multi-instance deployment."""
|
|
|
|
def __init__(
|
|
self, redis_client: Any, lease_key: str = "synologychat:polling:lease", ttl_seconds: int = _LEASE_TTL_SECONDS
|
|
):
|
|
self._redis = redis_client
|
|
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)
|
|
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
|
|
|
|
async def release(self) -> None:
|
|
try:
|
|
script = """
|
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
return redis.call("del", KEYS[1])
|
|
else
|
|
return 0
|
|
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}")
|
|
|
|
async def renew(self) -> bool:
|
|
try:
|
|
script = """
|
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
return redis.call("expire", KEYS[1], ARGV[2])
|
|
else
|
|
return 0
|
|
end
|
|
"""
|
|
result = await self._redis.eval(script, 1, self._lease_key, self._instance_id, self._ttl)
|
|
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 self._last_known_held
|
|
|
|
async def check_held(self) -> bool:
|
|
try:
|
|
current = await self._redis.get(self._lease_key)
|
|
self._last_known_held = current is not None and current.decode() == self._instance_id
|
|
return self._last_known_held
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def with_polling_lease(
|
|
lease: PollingLease | RedisPollingLease,
|
|
poll_fn,
|
|
renew_interval: int = _LEASE_RENEW_INTERVAL,
|
|
) -> None:
|
|
if not await lease.acquire():
|
|
logger.info("[SynologyChat] Polling lease not acquired, another instance may be polling")
|
|
return
|
|
|
|
logger.info("[SynologyChat] Polling lease acquired")
|
|
renew_task: asyncio.Task | None = None
|
|
|
|
try:
|
|
|
|
async def _renew_loop():
|
|
while True:
|
|
await asyncio.sleep(renew_interval)
|
|
if not await lease.renew():
|
|
logger.warning("[SynologyChat] Failed to renew polling lease")
|
|
|
|
renew_task = asyncio.create_task(_renew_loop())
|
|
await poll_fn()
|
|
finally:
|
|
if renew_task:
|
|
renew_task.cancel()
|
|
await lease.release()
|
|
logger.info("[SynologyChat] Polling lease released")
|