"""TSSBOT autolog matcher + scoreboard dispatch. For each game received from the Spectra TSS feed, match it against every subscribing guild's ``tss-team`` / ``tss-player`` preference entries, render the scoreboard once per session/color/language, and post it to each subscribed channel. Matching is per-entity (a team by id/name, a player by uid); sending is deduped per channel per session so a channel that subscribes both a team and one of its players only receives one scoreboard for a given game. """ from __future__ import annotations import asyncio import json import logging import sqlite3 from pathlib import Path from typing import Any, Optional import discord from . import preferences, scoreboard, transform from .storage import STORAGE_DIR log = logging.getLogger("tssbot.autolog") REPLAYS_TSS_DIR: Path = STORAGE_DIR / "REPLAYS" / "TSS" # Registered by start_bot once the client exists; standalone tss_ws leaves it None. _bot: Optional[discord.Client] = None # session_id -> set of channel_ids already handled (in-memory idempotency). _sent_channels_by_session: dict[str, set[int]] = {} # session_id -> lock guarding the one-time PNG render. _render_locks: dict[str, asyncio.Lock] = {} # Cap concurrent MP4 renders (CPU/memory heavy) across all "Generate Video" clicks. _video_render_sem: asyncio.Semaphore = asyncio.Semaphore(2) def set_bot(bot: discord.Client) -> None: """Register the Discord client so the matcher can post (and run at all).""" global _bot _bot = bot # --------------------------------------------------------------------------- # Present-entity extraction # --------------------------------------------------------------------------- def _present_teams(game: dict[str, Any]) -> tuple[set[str], set[str]]: """Return stable TSS team IDs and lowercased names embedded in a replay.""" tss = game.get("tss") or {} team_ids: set[str] = set() team_names: set[str] = set() for slot in ("1", "2"): team = tss.get(slot) if not isinstance(team, dict): continue if team.get("team_id") is not None: team_ids.add(str(team["team_id"])) name = str(team.get("team_name") or team.get("name") or "").strip() if name: team_names.add(name.lower()) return team_ids, team_names def _present_players(game: dict[str, Any]) -> set[str]: """Return the uids of every player in a replay (players dict + tss rosters).""" uids: set[str] = {str(u) for u in (game.get("players") or {}).keys()} tss = game.get("tss") or {} for slot in ("1", "2"): team = tss.get(slot) if isinstance(team, dict): for entry in team.get("players") or []: if entry.get("uid") is not None: uids.add(str(entry["uid"])) return uids def _bar_color(game: dict[str, Any], guild_id: int) -> str: """Header/separator tint from the guild's own team vs the result. Compared by team *name* (case-insensitive) โ€” TSS team ids are per-tournament. """ if game.get("draw"): return "draw" guild_team = preferences.get_guild_team(guild_id) my_name = str((guild_team or {}).get("TM_Name") or "").casefold() if not my_name: return "not_set" tss = game.get("tss") or {} slot_names = {s: str((tss.get(s) or {}).get("team_name") or "").casefold() for s in ("1", "2")} winner = str(game.get("winner") or "") if slot_names.get(winner) == my_name: return "win" if my_name in slot_names.values(): return "loss" return "not_involved" # --------------------------------------------------------------------------- # Scoreboard view + render/send # --------------------------------------------------------------------------- def _load_match_logs(session_id: str) -> tuple[list[str], list[str]]: """Read (chat_log, battle_log) for a session from match_logs; empty on miss.""" from .storage import TSS_BATTLES_DB_PATH try: conn = sqlite3.connect(TSS_BATTLES_DB_PATH) try: row = conn.execute( "SELECT chat_log_json, battle_log_json FROM match_logs WHERE session_id = ?", (str(session_id),), ).fetchone() finally: conn.close() except Exception: return [], [] if not row: return [], [] chat = json.loads(row[0]) if row[0] else [] battle = json.loads(row[1]) if row[1] else [] return chat, battle async def _send_log(interaction: discord.Interaction, lines: list[str], title: str) -> None: """Send a log as ephemeral diff-formatted message(s), chunked under Discord's limit.""" await interaction.response.defer(thinking=True, ephemeral=True) if not lines: await interaction.followup.send("No log available for this match.", ephemeral=True) return chunks: list[str] = [] chunk: list[str] = [] length = 0 for line in lines: if length + len(line) + 1 > 1800: chunks.append("\n".join(chunk)) chunk = [line] length = len(line) + 1 else: chunk.append(line) length += len(line) + 1 if chunk: chunks.append("\n".join(chunk)) first = True for c in chunks: content = (f"**{title}**\n" if first else "") + f"```diff\n{c}\n```" await interaction.followup.send(content, ephemeral=True) first = False def build_tss_scoreboard_view(session_id: str) -> discord.ui.View: """Link buttons under a scoreboard: in-game replay + the TSS website.""" view = discord.ui.View(timeout=None) try: replay_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}" view.add_item(discord.ui.Button(label="View Replay", style=discord.ButtonStyle.link, url=replay_url)) except (ValueError, TypeError): pass view.add_item(discord.ui.Button( label="View on Website", style=discord.ButtonStyle.link, url=f"https://tss.pawjob.us/games/{session_id}", emoji="๐ŸŒ", )) video_btn = discord.ui.Button(label="Generate Video", style=discord.ButtonStyle.blurple, emoji="๐ŸŽฌ") async def _video_cb(interaction: discord.Interaction) -> None: await handle_view_video(interaction, session_id) video_btn.callback = _video_cb view.add_item(video_btn) chat_log, battle_log = _load_match_logs(session_id) battle_btn = discord.ui.Button(label="Battle Log", style=discord.ButtonStyle.green) async def _battle_cb(interaction: discord.Interaction) -> None: _, b = _load_match_logs(session_id) await _send_log(interaction, b, f"Battle Log ยท {session_id}") battle_btn.callback = _battle_cb view.add_item(battle_btn) if chat_log: chat_btn = discord.ui.Button(label="Chat Log", style=discord.ButtonStyle.green) async def _chat_cb(interaction: discord.Interaction) -> None: c, _ = _load_match_logs(session_id) await _send_log(interaction, c, f"Chat Log ยท {session_id}") chat_btn.callback = _chat_cb view.add_item(chat_btn) return view def _find_replay_data_path(session_id: str) -> Optional[Path]: """Locate a session's stored replay file, preferring the gzipped form.""" session_dir = REPLAYS_TSS_DIR / session_id for name in ("replay_data.json.gz", "replay_data.json"): candidate = session_dir / name if candidate.is_file(): return candidate return None async def handle_view_video(interaction: discord.Interaction, session_id: str) -> None: """Callback for 'Generate Video' โ€” render the replay to MP4 (once, cached) and send it ephemerally, falling back to the website link if it can't be uploaded.""" web_url = f"https://tss.pawjob.us/games/{session_id}" try: try: await interaction.response.defer(thinking=True, ephemeral=True) except discord.HTTPException: return replay_path = _find_replay_data_path(session_id) if replay_path is None: await interaction.followup.send("No replay data is available for this match.", ephemeral=True) return video_path = REPLAYS_TSS_DIR / session_id / "replay_video.mp4" # Render once and cache on disk; serve the cached file on later clicks. if not video_path.exists() or video_path.stat().st_size == 0: if _video_render_sem.locked(): await interaction.followup.send( "Too many videos are rendering right now โ€” try again in a moment.", ephemeral=True, ) return from .render_replay import load_gob_file, render_gob def _generate() -> None: d = load_gob_file(replay_path) render_gob(d, video_path) try: log.info("[TSS-AUTOLOG] video render start %s", session_id) async with _video_render_sem: await asyncio.get_event_loop().run_in_executor(None, _generate) log.info("[TSS-AUTOLOG] video render done %s", session_id) except Exception as e: # noqa: BLE001 - report any render failure to the user log.exception("[TSS-AUTOLOG] video render failed %s", session_id) if video_path.exists(): video_path.unlink(missing_ok=True) # don't cache a broken/partial file await interaction.followup.send(f"Video generation failed: {str(e)[:1800]}", ephemeral=True) return if not video_path.exists() or video_path.stat().st_size == 0: await interaction.followup.send("Video generation produced no output.", ephemeral=True) return guild = interaction.guild max_size = guild.filesize_limit if guild else 25 * 1_048_576 if video_path.stat().st_size > max_size: await interaction.followup.send( f"The rendered video is too large to upload here. Watch it on the website instead: {web_url}", ephemeral=True, ) return try: await interaction.followup.send( content=f"Replay video for `{session_id}`. Interactive replay: {web_url}", file=discord.File(video_path), ephemeral=True, ) except discord.HTTPException: await interaction.followup.send( f"Couldn't upload the video here. Watch it on the website instead: {web_url}", ephemeral=True, ) except Exception: # noqa: BLE001 - never let a button callback raise unhandled log.exception("[TSS-AUTOLOG] unexpected error in handle_view_video %s", session_id) try: await interaction.followup.send("Something went wrong generating the video.", ephemeral=True) except discord.HTTPException: pass async def _render_scoreboard(game: dict[str, Any], session_id: str, bar_color: str, lang_column: str) -> Optional[Path]: """Render (once, cached on disk) the scoreboard for a session/color/language.""" model = transform.build_scoreboard_model(game, lang_column) if not model: log.warning("[TSS-AUTOLOG] could not build model for %s", session_id) return None lang_tag = lang_column.strip("<>").lower() or "english" out_dir = REPLAYS_TSS_DIR / session_id out_path = out_dir / f"scoreboard-{bar_color}-{lang_tag}.png" lock = _render_locks.setdefault(session_id, asyncio.Lock()) async with lock: if not out_path.exists(): out_dir.mkdir(parents=True, exist_ok=True) await scoreboard.create_scoreboard(model, str(out_path), bar_color=bar_color) return out_path if out_path.exists() else None async def _send_scoreboard(game: dict[str, Any], guild_id: int, channel_id: int, session_id: str, sent: set[int]) -> None: """Render and post the scoreboard for one subscribed channel (deduped).""" bot = _bot if bot is None or channel_id in sent: return sent.add(channel_id) # claim the channel before any await so concurrent matches can't double-send bar_color = _bar_color(game, guild_id) lang_column = preferences.guild_language_column(guild_id) out_path = await _render_scoreboard(game, session_id, bar_color, lang_column) if out_path is None: return channel = bot.get_channel(channel_id) if channel is None: try: channel = await bot.fetch_channel(channel_id) except (discord.NotFound, discord.Forbidden) as e: log.warning("[TSS-AUTOLOG] channel %s unavailable (%s)", channel_id, e) return if not isinstance(channel, discord.abc.Messageable): return try: with open(out_path, "rb") as f: await channel.send( file=discord.File(f, filename="scoreboard.png"), view=build_tss_scoreboard_view(session_id), ) log.info("[TSS-AUTOLOG] sent %s -> guild=%s channel=%s", session_id, guild_id, channel_id) except discord.HTTPException as e: log.error("[TSS-AUTOLOG] send failed %s -> %s: %s", session_id, channel_id, e) # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- async def process_game(game: dict[str, Any]) -> None: """Match one received game against guild prefs and post scoreboards. Safe to call from the standalone WS listener (no-ops if no bot registered). """ if _bot is None: return session_id = str(game.get("_id") or "") if not session_id: return _, present_team_names = _present_teams(game) present_uids = _present_players(game) sent = _sent_channels_by_session.setdefault(session_id, set()) for guild_id, prefs in preferences.iter_guild_preferences(): for _pref_key, entry in prefs.items(): if not isinstance(entry, dict): continue type_ = entry.get("Type") if type_ not in ("tss-team", "tss-player", "tss-wildcard"): continue channel_id, enabled = preferences.parse_channel(entry.get("Logs")) if not channel_id or not enabled or channel_id in sent: continue if type_ == "tss-wildcard": matched = True elif type_ == "tss-team": name = (entry.get("Name") or "").lower() matched = bool(name) and name in present_team_names else: # tss-player matched = str(entry.get("UID") or "") in present_uids if not matched: continue try: await _send_scoreboard(game, guild_id, channel_id, session_id, sent) except Exception as e: # never let one channel break the rest log.error("[TSS-AUTOLOG] dispatch error guild=%s channel=%s: %s", guild_id, channel_id, e)