feat(tally): registry, JSON persistence, voice-status HTTP, session hook (#1338)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
NotSoToothless
2026-06-19 00:20:31 -07:00
committed by GitHub
parent 74c59eb139
commit 732595433a
2 changed files with 150 additions and 1 deletions
+135 -1
View File
@@ -12,11 +12,16 @@ wiped on a 1h idle sweep or when the voice channel empties of human members.
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging
import re import re
import time
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from typing import Optional from typing import Optional
from .utils import t import discord
from .utils import t, STORAGE_DIR, get_bot, guild_lang
IDLE_TIMEOUT = 3600 IDLE_TIMEOUT = 3600
RECENT_SESSIONS_MAX = 50 RECENT_SESSIONS_MAX = 50
@@ -120,3 +125,132 @@ def format_status(tally: Tally, lang: str) -> str:
opponent=tally.last_opponent, opponent=tally.last_opponent,
) )
return base return base
# ---------------------------------------------------------------------------
# Registry and persistence
# ---------------------------------------------------------------------------
TALLY_PATH = STORAGE_DIR / "TALLY.json"
_REGISTRY: dict[tuple[int, int], "Tally"] = {}
def _save() -> None:
"""Persist the registry to disk (best-effort)."""
try:
data = [asdict(tly) for tly in _REGISTRY.values()]
tmp = TALLY_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(TALLY_PATH)
except Exception as e:
logging.error(f"[TALLY] failed to save state: {e}")
def load_from_disk() -> None:
"""Load persisted tallies into the in-memory registry."""
try:
raw = json.loads(TALLY_PATH.read_text(encoding="utf-8"))
except FileNotFoundError:
return
except Exception as e:
logging.error(f"[TALLY] failed to load state: {e}")
return
_REGISTRY.clear()
for d in raw:
try:
tly = Tally(**d)
_REGISTRY[(tly.guild_id, tly.channel_id)] = tly
except Exception as e:
logging.error(f"[TALLY] skipping bad record {d}: {e}")
def get(guild_id: int, channel_id: int) -> Optional["Tally"]:
return _REGISTRY.get((guild_id, channel_id))
def tallies_for_guild(guild_id: int) -> list["Tally"]:
return [tly for (g, _c), tly in _REGISTRY.items() if g == guild_id]
def claim(guild_id: int, channel_id: int, mode: str, target: str,
display_target: str, user_id: int) -> "Tally":
now = time.time()
tly = Tally(
guild_id=guild_id, channel_id=channel_id, mode=mode, target=target,
display_target=display_target, last_update_ts=now, claimed_by=user_id,
created_ts=now,
)
_REGISTRY[(guild_id, channel_id)] = tly
_save()
return tly
def transfer(guild_id: int, channel_id: int, target: str,
display_target: str) -> Optional["Tally"]:
"""Switch an active tally to player mode on a new IGN, carrying the count."""
tly = _REGISTRY.get((guild_id, channel_id))
if tly is None:
return None
tly.mode = "player"
tly.target = target
tly.display_target = display_target
tly.last_update_ts = time.time()
_save()
return tly
def wipe(guild_id: int, channel_id: int) -> bool:
if _REGISTRY.pop((guild_id, channel_id), None) is None:
return False
_save()
return True
# ---------------------------------------------------------------------------
# Voice-status HTTP helper
# ---------------------------------------------------------------------------
async def set_voice_status(channel_id: int, text: str) -> None:
"""Set a voice channel's Discord status via the raw HTTP endpoint.
discord.py 2.7.1 has no native voice-status API, so call the route
directly: PUT /channels/{id}/voice-status {"status": text}.
"""
bot = get_bot()
route = discord.http.Route( # type: ignore[attr-defined]
"PUT", "/channels/{channel_id}/voice-status", channel_id=channel_id
)
try:
await bot.http.request(route, json={"status": text})
except Exception as e:
logging.error(f"[TALLY] set_voice_status failed for {channel_id}: {e}")
async def push_status(tly: "Tally") -> None:
lang = await guild_lang(tly.guild_id)
await set_voice_status(tly.channel_id, format_status(tly, lang))
async def clear_status(channel_id: int) -> None:
await set_voice_status(channel_id, "")
# ---------------------------------------------------------------------------
# Session hook
# ---------------------------------------------------------------------------
async def on_session_processed(guild_id: int, teams: list[dict],
winner_short: Optional[str], is_draw: bool,
session_id: str) -> None:
"""Update every active tally in a guild against one finished session."""
for tly in tallies_for_guild(guild_id):
try:
kind = evaluate(tly, teams, winner_short, is_draw, session_id)
if kind is None:
continue
tly.last_update_ts = time.time()
_save()
await push_status(tly)
except Exception as e:
logging.error(f"[TALLY] on_session_processed error (guild={guild_id}): {e}")
+15
View File
@@ -96,6 +96,21 @@ def test_format_status_after_draw():
assert format_status(t, "en") == "0W-1L: Draw against ENEMY" assert format_status(t, "en") == "0W-1L: Draw against ENEMY"
def test_persistence_roundtrip(tmp_path_env=None):
import tempfile, os, importlib
import BOT.tally as tal
with tempfile.TemporaryDirectory() as d:
tal.TALLY_PATH = Path(d) / "TALLY.json"
tal._REGISTRY.clear()
tal.claim(10, 20, "player", "bravo", "Bravo", user_id=99)
# reload into a clean registry from disk
tal._REGISTRY.clear()
tal.load_from_disk()
got = tal.get(10, 20)
assert got is not None
assert got.mode == "player" and got.target == "bravo" and got.claimed_by == 99
def _run(): def _run():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in fns: for fn in fns: