Initial commit: SHARED library with LFS for binary assets
This commit is contained in:
+223
@@ -0,0 +1,223 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user