Files
2026-06-09 07:22:08 -04:00

414 lines
15 KiB
Python

"""Configuration and persistence for the Excommunicado bot."""
import json
import logging
import os
import tempfile
import threading
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger("excommunicado.config")
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 check_writable() -> Tuple[bool, str]:
"""Verify the data directory is writable. Returns (ok, message).
Useful at startup to fail loudly when a Docker volume is owned by the
wrong user (the classic non-root + bind-mount permission problem).
"""
try:
_ensure_data_dir()
probe = DATA_DIR / ".write_test"
with open(probe, "w", encoding="utf-8") as f:
f.write("ok")
probe.unlink(missing_ok=True)
return True, f"data dir writable: {DATA_DIR} (uid={os.getuid() if hasattr(os, 'getuid') else 'n/a'})"
except OSError as err:
uid = os.getuid() if hasattr(os, "getuid") else "n/a"
return False, (
f"DATA DIR NOT WRITABLE: {DATA_DIR} (process uid={uid}). "
f"Fix volume ownership or run: chown -R 1000:1000 ./data ({err})"
)
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.
# NOTE: temp file must be on the SAME filesystem as CONFIG_FILE for
# os.replace to be atomic, so it lives in DATA_DIR (not /tmp).
try:
fd, tmp_path = tempfile.mkstemp(dir=str(DATA_DIR), suffix=".tmp")
except OSError as err:
uid = os.getuid() if hasattr(os, "getuid") else "n/a"
logger.error(
"Cannot create temp file in %s (uid=%s): %s. "
"The data volume is likely owned by another user. "
"Run: chown -R 1000:1000 ./data (or switch to a named volume).",
DATA_DIR, uid, err,
)
raise
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)
logger.debug("Config saved (%d guild entries) to %s", len(data), 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)
score = _record_score(rec)
logger.info(
"Vote recorded: guild=%s target=%s voter=%s dir=%+d weight=%d -> score=%d voters=%d",
gid, tid, vid, direction, int(weight), score, len(voters),
)
return {
"ok": True,
"reason": None,
"score": score,
"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)
logger.info(
"Keyword points added: guild=%s target=%s amount=%+d -> keyword_points=%d score=%d",
gid, tid, int(amount), rec["keyword_points"], _record_score(rec),
)
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", {})