Files
SHARED/shared_store.py

224 lines
8.0 KiB
Python

"""Cross-bot shared JSON state: account links + blacklist.
Both SREBOT and TSSBOT put ``BOTS/SHARED`` on ``sys.path`` at startup, so this
module is importable by bare name (``from shared_store import ...``) from either
bot.
Two files are managed here:
* ``BLACKLIST.json`` — admin-curated, version-controlled, lives next to this
module in ``BOTS/SHARED``. Sections: ``discord_users`` (entries may be a plain
id, ``[id, comment, reason]``, or ``{"id": id, "comment": "...", "reason": "..."}``)
``squadrons`` (short tags, SRE-only — TSS teams are intentionally not
blacklistable), and ``guilds`` (Discord server ids, SRE-only — TSS does not
yet honour a guild blacklist). Read by both bots.
* ``PLAYERS.json`` — runtime-written Discord-user → WT-account links. Lives in
``$STORAGE_VOL_PATH`` (the storage volume both bots share, right next to
SQUADRONS.json), so SREBOT and TSSBOT read/write the same links.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any, Optional
log = logging.getLogger("shared_store")
_LOCK = threading.Lock()
# BLACKLIST.json lives alongside this module in BOTS/SHARED (version-controlled).
BLACKLIST_PATH: Path = Path(__file__).resolve().parent / "BLACKLIST.json"
PLAYERS_FILENAME = "PLAYERS.json"
# ---------------------------------------------------------------------------
# Paths / IO
# ---------------------------------------------------------------------------
def storage_dir() -> Path:
"""Return the shared storage volume root (where PLAYERS.json lives).
Both bots point ``STORAGE_VOL_PATH`` at the same volume, so PLAYERS.json
there is read/written by SREBOT and TSSBOT alike, next to SQUADRONS.json.
"""
raw = os.environ.get("STORAGE_VOL_PATH", "").strip()
if not raw:
raise RuntimeError("STORAGE_VOL_PATH must be set")
p = Path(raw).expanduser()
p.mkdir(parents=True, exist_ok=True)
return p
def _players_path() -> Path:
return storage_dir() / PLAYERS_FILENAME
def _read_json(path: Path, default: Any) -> Any:
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return default
except (json.JSONDecodeError, OSError) as e:
log.error("failed reading %s: %s", path, e)
return default
def _write_json_atomic(path: Path, data: Any) -> bool:
tmp = path.with_suffix(path.suffix + ".tmp")
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
os.replace(tmp, path)
return True
except OSError as e:
log.error("failed writing %s: %s", path, e)
return False
# ---------------------------------------------------------------------------
# PLAYERS.json — {discord_user_id: {"uid": str, "linked_unix": int}}
# ---------------------------------------------------------------------------
def load_player_links() -> dict[str, dict[str, Any]]:
"""Return all Discord-user → WT-account links."""
data = _read_json(_players_path(), {})
return data if isinstance(data, dict) else {}
def get_linked_uid(discord_user_id: int | str) -> Optional[str]:
"""Return the WT UID linked to a Discord user, or None."""
entry = load_player_links().get(str(discord_user_id))
if isinstance(entry, dict):
uid = entry.get("uid")
return str(uid) if uid not in (None, "") else None
return None
def save_player_link(discord_user_id: int | str, uid: str) -> bool:
"""Link a Discord user to a WT UID (overwrites any existing link)."""
with _LOCK:
links = load_player_links()
links[str(discord_user_id)] = {"uid": str(uid), "linked_unix": int(time.time())}
return _write_json_atomic(_players_path(), links)
def remove_player_link(discord_user_id: int | str) -> bool:
"""Remove a Discord user's link. Returns True if a link was removed."""
with _LOCK:
links = load_player_links()
if str(discord_user_id) in links:
del links[str(discord_user_id)]
return _write_json_atomic(_players_path(), links)
return False
# ---------------------------------------------------------------------------
# BLACKLIST.json — {"discord_users": [...], "squadrons": [...]}
# ---------------------------------------------------------------------------
_BLACKLIST_CACHE: Optional[dict[str, Any]] = None
_BLACKLIST_CACHE_TS: float = 0.0
_BLACKLIST_TTL = 60.0
def _default_blacklist() -> dict[str, list]:
return {"discord_users": [], "squadrons": [], "guilds": []}
def load_blacklist(force: bool = False) -> dict[str, Any]:
"""Return the blacklist dict, cached for a short TTL (reloadable with force)."""
global _BLACKLIST_CACHE, _BLACKLIST_CACHE_TS
now = time.time()
if not force and _BLACKLIST_CACHE is not None and (now - _BLACKLIST_CACHE_TS) < _BLACKLIST_TTL:
return _BLACKLIST_CACHE
data = _read_json(BLACKLIST_PATH, _default_blacklist())
if not isinstance(data, dict):
data = _default_blacklist()
data.setdefault("discord_users", [])
data.setdefault("squadrons", [])
data.setdefault("guilds", [])
_BLACKLIST_CACHE = data
_BLACKLIST_CACHE_TS = now
return data
def blacklisted_squadrons() -> list[str]:
"""Return blacklisted squadron short tags (SRE autolog matching)."""
return [str(s) for s in load_blacklist().get("squadrons", [])]
def blacklisted_guilds() -> list[int]:
"""Return blacklisted Discord guild (server) IDs.
Entries may be a plain id, ``[id, comment, reason]``, or a dict with
``id``/``guild_id`` plus optional ``comment``/``reason`` (mirrors the
``discord_users`` format). Comment/reason are for readability only and are
ignored here. Unparseable entries are skipped.
"""
out: list[int] = []
for entry in load_blacklist().get("guilds", []):
if isinstance(entry, dict):
raw = entry.get("id", entry.get("guild_id"))
elif isinstance(entry, (list, tuple)):
raw = entry[0] if entry else None
else:
raw = entry
if raw is None:
continue
try:
out.append(int(raw))
except (TypeError, ValueError):
continue
return out
def check_guild_blacklist(guild_id: int | str) -> tuple[bool, Optional[str]]:
"""Return (is_blacklisted, reason) for a Discord guild (server).
Entries follow the same flexible format as ``discord_users`` (plain id,
``[id, comment, reason]``, or a dict with ``id``/``guild_id`` plus optional
``reason``). SRE-only — see the module docstring. An empty/missing reason
comes back as ``None``.
"""
target = str(guild_id)
for entry in load_blacklist().get("guilds", []):
if isinstance(entry, dict):
blocked_id = entry.get("id", entry.get("guild_id"))
reason = entry.get("reason")
elif isinstance(entry, (list, tuple)):
blocked_id = entry[0] if entry else None
reason = entry[2] if len(entry) > 2 else None
else:
blocked_id, reason = entry, None
if str(blocked_id) == target:
return True, (reason or None)
return False, None
def check_user_blacklist(discord_user_id: int | str) -> tuple[bool, Optional[str]]:
"""Return (is_blacklisted, reason) for a Discord user.
Entries may be a plain id, ``[id, comment, reason]``, or a dict with
``id``/``discord_id`` plus optional ``reason``. The comment/name is ignored
by runtime checks and exists only to make BLACKLIST.json readable.
"""
target = str(discord_user_id)
for entry in load_blacklist().get("discord_users", []):
if isinstance(entry, dict):
blocked_id = entry.get("id", entry.get("discord_id"))
reason = entry.get("reason")
elif isinstance(entry, (list, tuple)):
blocked_id = entry[0]
reason = entry[2] if len(entry) > 2 else None
else:
blocked_id, reason = entry, None
if str(blocked_id) == target:
return True, reason
return False, None