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()