Auto merge dev → main (#1332)

* feat(tssbot): build_match_logs + match_logs persistence

* feat(tssbot): create match_logs table and write logs at ingest

* feat(tssbot): one-time match_logs backfill script

* feat(srebot): persist chat/battle logs to match_logs (parity, no backfill)

* feat(tssbot): Battle/Chat Log buttons on Discord scoreboards
This commit is contained in:
NotSoToothless
2026-06-18 01:02:59 -07:00
committed by GitHub
parent 53e3db9159
commit 32e747212f
6 changed files with 370 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
"""Chat + battle log builder and persistence for TSS matches.
Ports SREBOT/BOT/utils.py log formatting so both sites render identically.
Logs are stored in match_logs (tss_battles.db) as JSON string arrays.
"""
from __future__ import annotations
import base64
import json
import logging
from typing import Any
import aiosqlite
try:
from data_parser import LangTableReader, apply_vehicle_name_filters # type: ignore
except Exception: # pragma: no cover
LangTableReader = None # type: ignore
def apply_vehicle_name_filters(name, strip_decorations=True): # type: ignore
return name
log = logging.getLogger("tssbot.match_logs")
MATCH_LOGS_SQL = """
CREATE TABLE IF NOT EXISTS match_logs (
session_id TEXT PRIMARY KEY,
chat_log_json TEXT,
battle_log_json TEXT,
built_unix INTEGER
)
"""
def _fmt_time(ms: int) -> str:
total_s = int(ms) // 1000
return f"{total_s // 60:02d}:{total_s % 60:02d}"
def _decompress_events(raw_events: Any) -> dict:
if isinstance(raw_events, str):
try:
import zstandard
compressed = base64.b85decode(raw_events)
return json.loads(zstandard.decompress(compressed).decode("utf-8"))
except Exception as exc: # pragma: no cover - corrupt payloads
log.error("Failed to decompress events: %s", exc)
return {}
return raw_events or {}
def _strip_tag(tag: str) -> str:
s = (tag or "").strip()
if len(s) >= 3 and not s[0].isalnum() and not s[-1].isalnum():
return s[1:-1]
return s
def build_match_logs(game: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Return (chat_log, battle_log) as pre-formatted string lists."""
players = game.get("players", {}) or {}
winner_winged = str(game.get("winner") or "")
loser_winged = str(game.get("loser") or "")
winner_sq = _strip_tag(winner_winged)
loser_sq = _strip_tag(loser_winged)
uid_lookup: dict[str, dict[str, str]] = {}
for uid_str, p in players.items():
tag = p.get("tag", "") or ""
uid_lookup[str(uid_str)] = {
"name": p.get("name", "") or "",
"tag_stripped": tag[1:-1] if tag else "",
}
translate = None
if LangTableReader is not None:
try:
translate = LangTableReader("English")
except Exception: # pragma: no cover
translate = None
def _veh(cdk: str | None) -> str:
if not cdk:
return "Unknown"
if translate is not None:
t = translate.get_translate(cdk)
if t:
return apply_vehicle_name_filters(t)
return cdk
def _player(uid: str | None) -> tuple[str, str]:
if uid is None:
return "Unknown", ""
info = uid_lookup.get(str(uid))
if info:
return info["name"], info["tag_stripped"]
return f"Player#{uid}", ""
def _prefix(sq: str) -> str:
if sq == winner_sq:
return "+"
if sq == loser_sq:
return "-"
return " "
chat_log: list[str] = []
for c in game.get("chat", []) or []:
info = uid_lookup.get(
str(c.get("uid", "")), {"name": "Unknown", "tag_stripped": "???"}
)
chat_log.append(
f"[{_fmt_time(c.get('time', 0))}] [{c.get('type', 'ALL')}] "
f"[{info['tag_stripped']}] `{info['name']}`: {c.get('message', '')}"
)
events = _decompress_events(game.get("events", {}))
merged: list[dict[str, Any]] = []
for k in events.get("kills", []) or []:
merged.append({
"kind": "kill", "time": k.get("time", 0),
"off_uid": str(k["offender_uid"]) if k.get("offender_uid") is not None else None,
"off_unit": k.get("offender_unit"),
"vic_uid": str(k["offended_uid"]) if k.get("offended_uid") is not None else None,
"vic_unit": k.get("offended_unit"),
"crashed": k.get("crashed", False), "afire": False,
})
for d in events.get("damage", []) or []:
merged.append({
"kind": "damage", "time": d.get("time", 0),
"off_uid": str(d["offender_uid"]) if d.get("offender_uid") is not None else None,
"off_unit": d.get("offender_unit"),
"vic_uid": str(d["offended_uid"]) if d.get("offended_uid") is not None else None,
"vic_unit": d.get("offended_unit"),
"crashed": False, "afire": d.get("afire", False),
})
merged.sort(key=lambda e: e.get("time", 0))
battle_log: list[str] = []
for ev in merged:
ts = _fmt_time(ev["time"])
vic_name, vic_sq = _player(ev["vic_uid"])
vic_veh = _veh(ev["vic_unit"])
if ev["kind"] == "kill":
if ev["off_uid"] is None or ev["crashed"]:
battle_log.append(
f"{_prefix(vic_sq)}[{ts}] {f'[{vic_sq}]':<7} "
f"{vic_name} ({vic_veh}) crashed"
)
else:
name, sq = _player(ev["off_uid"])
battle_log.append(
f"{_prefix(sq)}[{ts}] {f'[{sq}]':<7} {name} ({_veh(ev['off_unit'])}) "
f"destroyed {vic_name} ({vic_veh})"
)
else:
if ev["off_uid"] is None:
continue
name, sq = _player(ev["off_uid"])
afire = "(FIRE) " if ev["afire"] else ""
battle_log.append(
f"{_prefix(sq)}[{ts}] {f'[{sq}]':<7} {name} ({_veh(ev['off_unit'])}) "
f"damaged {afire}{vic_name} ({vic_veh})"
)
return chat_log, battle_log
async def upsert_match_logs(
db_path, session_id: str, chat_log: list[str], battle_log: list[str]
) -> None:
import time
async with aiosqlite.connect(db_path) as conn:
await conn.execute(MATCH_LOGS_SQL)
await conn.execute(
"""INSERT INTO match_logs (session_id, chat_log_json, battle_log_json, built_unix)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
chat_log_json=excluded.chat_log_json,
battle_log_json=excluded.battle_log_json,
built_unix=excluded.built_unix""",
(str(session_id), json.dumps(chat_log, ensure_ascii=False),
json.dumps(battle_log, ensure_ascii=False), int(time.time())),
)
await conn.commit()