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
|
||||
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user