"""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