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:
+135
-1
@@ -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}")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user