414 lines
15 KiB
Python
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", {})
|
|
|