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
import json
import logging
import re
import time
from dataclasses import dataclass, field, asdict
from typing import Optional
from .utils import t
import discord
from .utils import t, STORAGE_DIR, get_bot, guild_lang
IDLE_TIMEOUT = 3600
RECENT_SESSIONS_MAX = 50
@@ -120,3 +125,132 @@ def format_status(tally: Tally, lang: str) -> str:
opponent=tally.last_opponent,
)
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"
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():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in fns: