added community voting and fixed bugs
This commit is contained in:
+277
-12
@@ -1,31 +1,70 @@
|
||||
"""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
|
||||
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."""
|
||||
_ensure_data_dir()
|
||||
if not CONFIG_FILE.exists():
|
||||
save_config({})
|
||||
return {}
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
"""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:
|
||||
"""Save the config dict."""
|
||||
_ensure_data_dir()
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
"""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]:
|
||||
@@ -100,3 +139,229 @@ 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", {})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user