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