"""Configuration and persistence for the Excommunicado bot.""" import json import os import tempfile import threading from datetime import datetime, timezone, timedelta from pathlib import Path from typing import Any, Dict, Optional, Tuple DATA_DIR = Path(__file__).parent.parent / "data" CONFIG_FILE = DATA_DIR / "xcom_config.json" # In-memory cache so we don't re-read the file on every message. # Protected by a lock because discord.py callbacks can interleave. _lock = threading.RLock() _cache: Optional[Dict[str, Any]] = None def _ensure_data_dir() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) def load_config() -> Dict[str, Any]: """Load the entire config dict (cached after first read).""" global _cache with _lock: if _cache is not None: return _cache _ensure_data_dir() if not CONFIG_FILE.exists(): _cache = {} save_config(_cache) return _cache try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: _cache = json.load(f) except (json.JSONDecodeError, OSError) as err: # Corrupt or unreadable file: back it up and start fresh print(f"[CONFIG] Failed to load config ({err}); starting empty.") try: if CONFIG_FILE.exists(): CONFIG_FILE.replace(CONFIG_FILE.with_suffix(".json.corrupt")) except OSError: pass _cache = {} save_config(_cache) return _cache def save_config(data: Dict[str, Any]) -> None: """Atomically persist the config dict and refresh the cache.""" global _cache with _lock: _ensure_data_dir() # Write to a temp file in the same dir, then atomically replace. fd, tmp_path = tempfile.mkstemp(dir=str(DATA_DIR), suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, CONFIG_FILE) except BaseException: if os.path.exists(tmp_path): os.remove(tmp_path) raise _cache = data def get_guild_data(guild_id: int) -> Dict[str, Any]: """Get or initialize data for a specific guild.""" config = load_config() gid = str(guild_id) if gid not in config: config[gid] = { "xcom_role_id": None, "xcom_channel_id": None, "isolated_users": {} # user_id -> {"reason": str, "timestamp": str} } save_config(config) return config[gid] def update_guild_data(guild_id: int, key: str, value: Any) -> None: """Update a top-level key for the guild.""" config = load_config() gid = str(guild_id) if gid not in config: config[gid] = { "xcom_role_id": None, "xcom_channel_id": None, "isolated_users": {} } config[gid][key] = value save_config(config) def add_isolated_user(guild_id: int, user_id: int, reason: str, timestamp: str) -> None: """Record a newly isolated user (shared xcom-lounge channel).""" config = load_config() gid = str(guild_id) if gid not in config: config[gid] = {"xcom_role_id": None, "xcom_channel_id": None, "isolated_users": {}} config[gid]["isolated_users"][str(user_id)] = { "reason": reason, "timestamp": timestamp, "absolution_points": 0, "marked": True } save_config(config) def increment_absolution_points(guild_id: int, user_id: int, amount: int = 1) -> int: """Increase absolution points for a user and return the new total.""" config = load_config() gid = str(guild_id) uid = str(user_id) if gid in config and uid in config[gid].get("isolated_users", {}): current = config[gid]["isolated_users"][uid].get("absolution_points", 0) new_total = current + amount config[gid]["isolated_users"][uid]["absolution_points"] = new_total save_config(config) return new_total return 0 def remove_isolated_user(guild_id: int, user_id: int) -> Optional[Dict]: """Remove user from isolation record and return previous data or None.""" config = load_config() gid = str(guild_id) if gid in config and str(user_id) in config[gid].get("isolated_users", {}): data = config[gid]["isolated_users"].pop(str(user_id)) save_config(config) return data return None def get_isolated_users(guild_id: int) -> Dict[str, Dict]: """Return all isolated users for guild.""" data = get_guild_data(guild_id) return data.get("isolated_users", {}) # --------------------------------------------------------------------------- # Community vote system # --------------------------------------------------------------------------- # Stored under config[gid]["votes"][target_id] = { # "week_start": iso, # "voters": { voter_id: {"direction": 1|-1, "weight": int, "timestamp": iso} }, # "keyword_points": int # auto-accrued from admin-curated keyword matches # } # Admin-curated keyword list lives under config[gid]["keywords"][phrase] = { # "weight": int, "added_by": int, "added_at": iso, "display": str # } def _parse_iso(value: Optional[str]) -> Optional[datetime]: if not value: return None try: dt = datetime.fromisoformat(value) except (ValueError, TypeError): return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def _vote_score(voters: Dict[str, Dict]) -> int: return sum(int(v.get("direction", 0)) * int(v.get("weight", 0)) for v in voters.values()) def _record_score(rec: Dict[str, Any]) -> int: """Combined net score: human votes + auto-accrued keyword points.""" return _vote_score(rec.get("voters", {})) + int(rec.get("keyword_points", 0)) def cast_vote( guild_id: int, target_id: int, voter_id: int, direction: int, weight: int, cooldown_hours: int = 24, reset_days: int = 7, ) -> Dict[str, Any]: """Record a community vote atomically. direction: +1 (up / excommunicate) or -1 (down / defend). Returns: {ok, reason, score, unique_voters, retry_hours}. A voter may only vote on a given target once per ``cooldown_hours``; the whole tally for a target resets after ``reset_days``. """ direction = 1 if direction >= 0 else -1 with _lock: config = load_config() gid, tid, vid = str(guild_id), str(target_id), str(voter_id) guild = config.setdefault( gid, {"xcom_role_id": None, "xcom_channel_id": None, "isolated_users": {}} ) votes = guild.setdefault("votes", {}) now = datetime.now(timezone.utc) rec = votes.get(tid) # Weekly reset if rec is not None: week_start = _parse_iso(rec.get("week_start")) if week_start is None or now - week_start >= timedelta(days=reset_days): rec = None if rec is None: rec = {"week_start": now.isoformat(), "voters": {}, "keyword_points": 0} votes[tid] = rec voters = rec["voters"] prev = voters.get(vid) if prev: last = _parse_iso(prev.get("timestamp")) if last is not None and now - last < timedelta(hours=cooldown_hours): retry_hours = cooldown_hours - (now - last).total_seconds() / 3600.0 return { "ok": False, "reason": "cooldown", "score": _record_score(rec), "unique_voters": len(voters), "retry_hours": max(retry_hours, 0.0), } voters[vid] = { "direction": direction, "weight": int(weight), "timestamp": now.isoformat(), } save_config(config) return { "ok": True, "reason": None, "score": _record_score(rec), "unique_voters": len(voters), "retry_hours": None, } def get_vote_score(guild_id: int, target_id: int, reset_days: int = 7) -> Tuple[int, int]: """Return (net_score, unique_voters) for a target, applying weekly reset.""" with _lock: config = load_config() gid, tid = str(guild_id), str(target_id) rec = config.get(gid, {}).get("votes", {}).get(tid) if not rec: return 0, 0 now = datetime.now(timezone.utc) week_start = _parse_iso(rec.get("week_start")) if week_start is None or now - week_start >= timedelta(days=reset_days): config[gid]["votes"].pop(tid, None) save_config(config) return 0, 0 return _record_score(rec), len(rec.get("voters", {})) def get_keyword_points(guild_id: int, target_id: int, reset_days: int = 7) -> int: """Return the auto-accrued keyword points for a target (with weekly reset).""" with _lock: config = load_config() gid, tid = str(guild_id), str(target_id) rec = config.get(gid, {}).get("votes", {}).get(tid) if not rec: return 0 week_start = _parse_iso(rec.get("week_start")) if week_start is None or datetime.now(timezone.utc) - week_start >= timedelta(days=reset_days): return 0 return int(rec.get("keyword_points", 0)) def add_keyword_points( guild_id: int, target_id: int, amount: int, reset_days: int = 7 ) -> Dict[str, Any]: """Add auto-accrued keyword points to a target's tally (atomic). Returns {score, unique_voters, keyword_points}. Applies the weekly reset. """ with _lock: config = load_config() gid, tid = str(guild_id), str(target_id) guild = config.setdefault( gid, {"xcom_role_id": None, "xcom_channel_id": None, "isolated_users": {}} ) votes = guild.setdefault("votes", {}) now = datetime.now(timezone.utc) rec = votes.get(tid) if rec is not None: week_start = _parse_iso(rec.get("week_start")) if week_start is None or now - week_start >= timedelta(days=reset_days): rec = None if rec is None: rec = {"week_start": now.isoformat(), "voters": {}, "keyword_points": 0} votes[tid] = rec rec["keyword_points"] = int(rec.get("keyword_points", 0)) + int(amount) save_config(config) return { "score": _record_score(rec), "unique_voters": len(rec.get("voters", {})), "keyword_points": rec["keyword_points"], } def clear_votes(guild_id: int, target_id: int) -> None: """Remove the vote tally for a target (e.g. after an XCOM is triggered).""" with _lock: config = load_config() gid, tid = str(guild_id), str(target_id) if config.get(gid, {}).get("votes", {}).pop(tid, None) is not None: save_config(config) # --------------------------------------------------------------------------- # Admin-curated keyword / key-phrase list # --------------------------------------------------------------------------- def _normalize_phrase(phrase: str) -> str: return " ".join(phrase.lower().split()) def add_keyword(guild_id: int, phrase: str, weight: int, added_by: int) -> Optional[Dict[str, Any]]: """Add or update an auto-score keyword/phrase. Returns the stored entry or None if invalid.""" key = _normalize_phrase(phrase) if not key: return None with _lock: config = load_config() gid = str(guild_id) guild = config.setdefault( gid, {"xcom_role_id": None, "xcom_channel_id": None, "isolated_users": {}} ) keywords = guild.setdefault("keywords", {}) entry = { "weight": int(weight), "added_by": int(added_by), "added_at": datetime.now(timezone.utc).isoformat(), "display": phrase.strip(), } keywords[key] = entry save_config(config) return entry def remove_keyword(guild_id: int, phrase: str) -> bool: """Remove a keyword/phrase. Returns True if something was removed.""" key = _normalize_phrase(phrase) with _lock: config = load_config() gid = str(guild_id) keywords = config.get(gid, {}).get("keywords", {}) if key in keywords: keywords.pop(key, None) save_config(config) return True return False def get_keywords(guild_id: int) -> Dict[str, Dict]: """Return the guild's keyword map: {normalized_phrase: {weight, display, ...}}.""" config = load_config() return config.get(str(guild_id), {}).get("keywords", {})