ForcePilot/backend/package/yuxi/channels/adapters/nextcloudtalk/dedup.py
Kris b018ad25da feat(backend): add Nextcloud Talk channel adapter implementation
实现了完整的Nextcloud Talk聊天适配器,包含以下核心功能:
1. 基础客户端通信与认证
2. 会话路由与聊天类型解析
3. 消息分块与格式转换
4. 用户配对与权限校验
5. 轮询式消息监听
6. 配置验证与诊断工具
7. 安全策略与去重缓存
8. 指标统计与状态快照

新增配套的配对用户缓存文件与模块导出入口。
2026-05-12 00:47:06 +08:00

128 lines
4.2 KiB
Python

from __future__ import annotations
import json
import logging
import os
import time
from collections import OrderedDict
logger = logging.getLogger(__name__)
_DEDUP_TTL = 86400
_MAX_ENTRIES = 10000
_PERSIST_PATH = os.getenv(
"NEXTCLOUDTALK_DEDUP_FILE",
os.path.join(os.path.dirname(__file__), ".dedup_cache.json"),
)
class NextcloudTalkDedupGuard:
def __init__(self, ttl: int = _DEDUP_TTL, max_entries: int = _MAX_ENTRIES):
self._ttl = ttl
self._max_entries = max_entries
self._pending: dict[str, float] = {}
self._committed: OrderedDict[str, float] = OrderedDict()
self._invalid: set[str] = set()
self._loaded = False
def _load_persisted(self) -> None:
if self._loaded:
return
self._loaded = True
try:
if os.path.exists(_PERSIST_PATH):
with open(_PERSIST_PATH, encoding="utf-8") as f:
data = json.load(f)
now = time.time()
count = 0
for key, ts in data.items():
if now - ts < self._ttl:
self._committed[key] = ts
count += 1
if count:
logger.debug(f"[NextcloudTalk] Dedup: loaded {count} entries from persist")
except Exception as e:
logger.warning(f"[NextcloudTalk] Dedup: failed to load persist: {e}")
def _save_persisted(self) -> None:
try:
data = dict(self._committed)
with open(_PERSIST_PATH, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception as e:
logger.warning(f"[NextcloudTalk] Dedup: failed to persist: {e}")
def _make_key(self, token: str, message_id: str) -> str:
return f"{token}:{message_id}"
def _gc(self) -> None:
now = time.time()
stale = [k for k, ts in self._committed.items() if now - ts > self._ttl]
for k in stale:
del self._committed[k]
while len(self._committed) > self._max_entries:
self._committed.popitem(last=False)
def claim(self, token: str, message_id: str) -> bool:
if not token or not message_id:
return True
self._load_persisted()
key = self._make_key(token, message_id)
if key in self._committed:
logger.debug(f"[NextcloudTalk] Dedup: duplicate claim rejected for {key}")
return False
if key in self._pending:
logger.debug(f"[NextcloudTalk] Dedup: already pending {key}")
return False
self._pending[key] = time.time()
return True
def commit(self, token: str, message_id: str) -> None:
if not token or not message_id:
return
key = self._make_key(token, message_id)
self._pending.pop(key, None)
self._committed[key] = time.time()
self._gc()
def release(self, token: str, message_id: str) -> None:
if not token or not message_id:
return
key = self._make_key(token, message_id)
self._pending.pop(key, None)
def mark_invalid(self, token: str, message_id: str) -> None:
if not token or not message_id:
return
key = self._make_key(token, message_id)
self._pending.pop(key, None)
self._invalid.add(key)
def is_invalid(self, token: str, message_id: str) -> bool:
if not token or not message_id:
return False
key = self._make_key(token, message_id)
return key in self._invalid
def is_duplicate(self, token: str, message_id: str) -> bool:
if not token or not message_id:
return False
self._load_persisted()
key = self._make_key(token, message_id)
if key in self._committed:
return True
self._gc()
return key in self._committed
def stats(self) -> dict[str, int]:
self._gc()
return {
"committed": len(self._committed),
"pending": len(self._pending),
"invalid": len(self._invalid),
}
def flush(self) -> None:
self._gc()
self._save_persisted()