Files
SREBOT/BOT/autologging.py
T
deploy 28a635438d feat(tally): fix live VC status updates and add permission pre-flight check
- Move tally hook from process_session (per-guild, gated by Logs subs)
  to process_ws_replays (once per game, all guilds) via on_game_finished
- Add set_voice_channel_status permission check at /tally-claim time so
  failures are immediate and visible rather than silent on every game
- Remove entitlement gate from tally_claim and tally_transfer
- Add VC tally permission section to /diagnose-perms when run in a VC
- Add 5 new locale keys to en.json for the permission messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 08:02:53 +00:00

2207 lines
85 KiB
Python

"""
autologging.py
WebSocket auto-logging and session processing. Connects to the replay stream,
detects squadron battle matches, tracks player states across sessions, generates
scoreboards, and posts results to configured Discord channels.
"""
# Standard Library Imports
import asyncio
import gzip
import json
import logging
import os
import re
import time as time_module
import traceback
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Third-Party Library Imports
import aiofiles
import aiohttp
import aiosqlite
import discord
# Local Module Imports
from . import utils
from data_parser import LangTableReader
from shared_store import blacklisted_squadrons
from .game_api import get_point_diff
from .render_replay import load_gob_file, render_gob
from .health import record_game_processed, record_ws_message
from .receiver_bridge import publish_replay_batch
from .utils import t, lang_from_features
from .scoreboard import create_scoreboard
from .utils import (
STORAGE_DIR,
CACHE_DIR,
replay_data_path,
raw_replay_data_path,
replay_session_dir,
STORE_RAW_REPLAY,
SQ_BATTLES_DB_PATH,
SQUADRONS_DB_PATH,
blacklisted_guilds,
DEFAULT_FOOTER_CAT,
compress_json,
decompress_json,
get_bot,
norm,
resolve_clans,
resolve_pref_key,
is_foreign_pref_entry,
load_features,
remove_guild_pref_notification,
PREMIUM_ACTIVATION_TS,
is_guild_entitled,
get_guild_tier,
refresh_entitled_guilds,
tier_cap,
tier_enforcement_active,
tier_allows_wildcard,
allowed_pref_keys_for,
enabled_pref_keys_for,
WILDCARD_KEYS,
)
from .wl import record_result, record_draw, get_standings
from . import tally
# ============================================================================
# MODULE STATE
# ============================================================================
_process_amount = 15
_is_running = False
_process_semaphore: Optional[asyncio.Semaphore] = None # Initialized lazily
# Squadron-name → clan_id resolution cache used when persisting player_games_hist
# rows so each row carries a stable clan_id and survives a future squadron rename.
# Bounded TTL refresh — squadrons_data is the source of truth and renames are rare.
_PGH_CLAN_ID_CACHE: dict[str, int] = {}
_PGH_CLAN_ID_CACHE_AT: float = 0.0
_PGH_CLAN_ID_CACHE_TTL = 300.0
def _resolve_clan_id_for_pgh(short_or_long_name: str) -> Optional[int]:
"""Resolve a clan_id from short_name or long_name. Returns None on miss."""
global _PGH_CLAN_ID_CACHE, _PGH_CLAN_ID_CACHE_AT
import sqlite3 as _sqlite
now = time_module.time()
if now - _PGH_CLAN_ID_CACHE_AT > _PGH_CLAN_ID_CACHE_TTL:
_PGH_CLAN_ID_CACHE = {}
_PGH_CLAN_ID_CACHE_AT = now
key = (short_or_long_name or "").lower()
if not key or key == "unknown":
return None
if key in _PGH_CLAN_ID_CACHE:
cached = _PGH_CLAN_ID_CACHE[key]
return cached if cached >= 0 else None
try:
with _sqlite.connect(SQUADRONS_DB_PATH) as con:
row = con.execute(
"SELECT clan_id FROM squadrons_data "
"WHERE LOWER(short_name) = ? OR LOWER(long_name) = ? LIMIT 1",
(key, key),
).fetchone()
cid = int(row[0]) if row and row[0] is not None else -1
except Exception:
cid = -1
_PGH_CLAN_ID_CACHE[key] = cid
return cid if cid >= 0 else None
_scoreboard_locks: dict[str, asyncio.Lock] = {}
_sent_channels_by_session: dict[str, set[int]] = {}
_scoreboard_cache: dict[tuple[str, str, str], str] = {}
_video_render_sem: asyncio.Semaphore = asyncio.Semaphore(2) # Max 2 concurrent video renders
DEBUG_GUILD_FILTER: int | None = 0 # Set to None when no filter is required or 0 to turn off
def _get_semaphore() -> asyncio.Semaphore:
"""Get or create the process semaphore."""
global _process_semaphore
if _process_semaphore is None:
_process_semaphore = asyncio.Semaphore(_process_amount)
return _process_semaphore
# ============================================================================
# Module Level DB init
# ============================================================================
_DB_INIT_LOCK = asyncio.Lock()
_DB_INITIALIZED = False
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS player_games_hist (
UID TEXT NOT NULL,
nick TEXT NOT NULL,
squadron_name TEXT NOT NULL,
squadron_tagged TEXT NOT NULL,
session_id TEXT NOT NULL,
vehicle TEXT,
vehicle_internal TEXT,
ground_kills INTEGER NOT NULL DEFAULT 0,
air_kills INTEGER NOT NULL DEFAULT 0,
assists INTEGER NOT NULL DEFAULT 0,
captures INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
victor_bool TEXT NOT NULL DEFAULT 'Loss',
endtime_unix INTEGER NOT NULL DEFAULT 0,
clan_id INTEGER,
UNIQUE (UID, session_id, vehicle_internal)
)
"""
INDEXES_SQL = [
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_session ON player_games_hist(session_id)",
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_uid_time ON player_games_hist(UID, endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_squad_time ON player_games_hist(squadron_tagged, endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_squadron_name ON player_games_hist(squadron_name, endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_endtime ON player_games_hist(endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_nick ON player_games_hist(nick COLLATE NOCASE)",
# Composite index for the self-join in gather_player_frequent_teammate
# (session_id, squadron_name) — without this the planner falls back to a
# wide squadron_name scan and the recap call takes >1s.
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_session_squadron ON player_games_hist(session_id, squadron_name)",
]
UPSERT_SQL = """
INSERT INTO player_games_hist
(UID, nick, squadron_name, squadron_tagged, session_id, vehicle, vehicle_internal,
ground_kills, air_kills, assists, captures, deaths,
victor_bool, endtime_unix, clan_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(UID, session_id, vehicle_internal) DO UPDATE SET
nick=excluded.nick,
squadron_name=excluded.squadron_name,
squadron_tagged=excluded.squadron_tagged,
vehicle=excluded.vehicle,
ground_kills=excluded.ground_kills,
air_kills=excluded.air_kills,
assists=excluded.assists,
captures=excluded.captures,
deaths=excluded.deaths,
victor_bool=excluded.victor_bool,
endtime_unix=excluded.endtime_unix,
clan_id=COALESCE(excluded.clan_id, player_games_hist.clan_id)
"""
async def init_players_db(conn: aiosqlite.Connection):
"""Idempotent: safe to call every time; only does real work once."""
global _DB_INITIALIZED
if _DB_INITIALIZED:
return
async with _DB_INIT_LOCK:
if _DB_INITIALIZED:
return
# Pragmas
await conn.execute("PRAGMA journal_mode=WAL;")
await conn.execute("PRAGMA synchronous=NORMAL;")
await conn.execute("PRAGMA busy_timeout=5000;")
await conn.execute("PRAGMA temp_store=MEMORY;")
await conn.execute(CREATE_TABLE_SQL)
# Migrations
cols = {row[1] for row in await conn.execute_fetchall("PRAGMA table_info(player_games_hist)")}
if "victor_bool" not in cols:
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN victor_bool TEXT NOT NULL DEFAULT 'Loss'")
if "endtime_unix" not in cols:
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN endtime_unix INTEGER NOT NULL DEFAULT 0")
if "squadron_name" not in cols:
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN squadron_name TEXT NOT NULL DEFAULT 'UNKNOWN'")
if "squadron_tagged" not in cols:
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN squadron_tagged TEXT NOT NULL DEFAULT 'UNKNOWN'")
if "clan_id" not in cols:
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN clan_id INTEGER")
# Indexes for read performance
for sql in INDEXES_SQL:
await conn.execute(sql)
# clan_id index added by the clan_id migration; ensure it exists for fresh installs.
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pgh_clanid_endtime ON player_games_hist(clan_id, endtime_unix)"
)
await conn.commit()
_DB_INITIALIZED = True
# ===========================
# CACHE for DB
# ===========================
_TRANSLATE = None
def get_translator():
"""Return a cached English LangTableReader singleton.
Returns:
LangTableReader: Shared translator instance for English vehicle names.
"""
global _TRANSLATE
if _TRANSLATE is None:
_TRANSLATE = LangTableReader("English")
return _TRANSLATE
# ============================================================================
# TIME WINDOW
# ============================================================================
# ============================================================================
# APERF HELPERS
# ============================================================================
# ============================================================================
# UTILITY FUNCTIONS
# ============================================================================
def ensure_squadrons_path() -> Path:
"""Ensure the configured HC storage root exists and return the SQUADRONS.json path.
Returns:
Path: Absolute path to SQUADRONS.json (file may not yet exist).
"""
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
return STORAGE_DIR / "SQUADRONS.json"
async def load_json_async(path: Path, default):
"""Async version of load_json for use in autologging."""
try:
async with aiofiles.open(path, "r", encoding="utf-8") as f:
return json.loads(await f.read())
except FileNotFoundError:
return default
async def load_or_init_json(path: Path, default):
"""Load JSON from *path*, falling back to *default* on corruption.
If the file is corrupt (invalid JSON or OS error), the file is deleted
and a copy of *default* is returned.
Args:
path: Path to the JSON file.
default: Fallback value returned when the file is missing or corrupt.
Returns:
Parsed JSON content, or a copy of *default* on failure.
"""
try:
return await load_json_async(path, default)
except (json.JSONDecodeError, OSError):
logging.warning(f"Corrupt JSON at {path!r}, resetting.")
try:
path.unlink()
except OSError:
pass
return default.copy() if isinstance(default, dict) else default
def parse_channel_id(raw: str) -> int | None:
"""
Given a raw prefs string, return an int channel ID or None if invalid/disabled.
"""
if not raw:
return None
# If the word "DISABLED" appears anywhere, treat as explicitly off
if "DISABLED" in raw.upper():
return None
# Search for a 17-19 digit sequence
m = re.search(r"\b(\d{17,19})\b", raw)
if not m:
return None
return int(m.group(1))
# ── Over-cap warning (tier enforcement) ──────────────────────────────────────
async def send_over_cap_warning(
channel: discord.abc.Messageable,
lang: str,
tier: Optional[str],
notif_type: str,
squadron: str,
*,
reason: str = "over_cap",
) -> None:
"""Send the orange upgrade-warning embed in place of a normal notification.
Fires once per dropped game — every over-cap event gets its own embed so the
guild can see exactly which game was not logged due to the tier limit.
`reason='wildcard_blocked'` swaps to the wildcard-specific copy.
"""
guild_id = getattr(getattr(channel, "guild", None), "id", 0)
cap = tier_cap(tier, notif_type)
cap_str = "∞" if cap is None else str(cap)
tier_label = (tier or "none").title()
if reason == "wildcard_blocked":
title = t(lang, "autolog.wildcard_blocked_title")
desc = t(lang, "autolog.wildcard_blocked_desc", tier=tier_label, notif=notif_type)
else:
title = t(lang, "autolog.over_cap_title")
desc = t(
lang,
"autolog.over_cap_desc",
tier=tier_label,
notif=notif_type,
cap=cap_str,
squadron=squadron,
)
embed = discord.Embed(title=title, description=desc, color=discord.Color.orange())
embed.set_footer(text=t(lang, "autolog.over_cap_footer"))
try:
await channel.send(embed=embed)
except Exception as e:
logging.warning(f"[TIER] Failed to send over-cap warning to {guild_id}: {e}")
def minutes_ago(ts, now=None):
"""Format a Unix timestamp as a human-readable relative time string.
Args:
ts: Unix timestamp (seconds) of the past event.
now: Current Unix timestamp. Defaults to ``time.time()``.
Returns:
str: e.g. ``"3 minutes ago"`` or ``"1 minute ago"`` (minimum 1 minute).
"""
now = now if now is not None else int(time_module.time())
mins = max(1, (now - ts) // 60)
return f"{mins} minute{'s' if mins != 1 else ''} ago"
def load_replay_data_from_disk(session_id: str):
"""Load replay_data.json.gz from disk for a session."""
path = replay_data_path(session_id)
if path.is_file():
with gzip.open(path, "rt", encoding="utf-8") as f:
return json.load(f)
return None
def load_raw_replay_data_from_disk(session_id: str):
"""Load the unmodified Spectra payload (RAW_replay_data.json.gz), pre-transform."""
path = raw_replay_data_path(session_id)
if path.is_file():
with gzip.open(path, "rt", encoding="utf-8") as f:
return json.load(f)
return None
# ============================================================================
# WEBSOCKET HANDLERS
# ============================================================================
async def handle_ws_replays(replays: list[dict]):
"""
Process incoming WebSocket replay data.
Called sequentially by ws_replay_listener's queue processor.
No _is_running guard needed - queue ensures sequential processing.
"""
try:
await process_ws_replays(replays)
await record_ws_message("sqb_autolog")
except Exception as e:
logging.error(f"Error in process_ws_replays: {e}")
logging.error(traceback.format_exc())
async def process_ws_replays(replays: list[dict]):
"""
Process replays received via WebSocket.
Validates in-memory before writing to disk, uses file existence for dedup.
"""
bot = get_bot()
now_ts = int(time_module.time())
# Load squadrons data
squadrons_path = ensure_squadrons_path()
squadrons_json = await load_or_init_json(squadrons_path, {})
if not isinstance(squadrons_json, dict):
squadrons_json = {}
# Transform, validate in-memory, and save only valid replays
validated_games = []
forwarded_replays: list[dict[str, Any]] = []
for replay in replays:
# Only process squadron battle games (new format may omit type since WS is SQB-specific)
replay_type = replay.get("type")
if replay_type is not None and replay_type != "sqb":
logging.info(f"[WS] Skipping non-sqb replay (type={replay_type!r})")
continue
# Store the unmodified Spectra payload before transforming, so the raw
# data can be pulled and re-processed later. Keyed off the raw _id (the
# same value transform_to_local_format converts to the hex session id),
# so it lands in the canonical replay dir even if the transform fails.
if STORE_RAW_REPLAY:
try:
raw_id = replay.get("_id") if replay.get("_id") is not None else replay.get("id")
raw_hex_id = hex(int(raw_id)).replace("0x", "") if raw_id is not None else ""
except (ValueError, TypeError):
raw_hex_id = ""
if raw_hex_id:
raw_path = raw_replay_data_path(raw_hex_id)
if not raw_path.exists():
try:
raw_path.parent.mkdir(parents=True, exist_ok=True)
raw_bytes = json.dumps(replay, ensure_ascii=False).encode("utf-8")
compressed_raw = await asyncio.to_thread(gzip.compress, raw_bytes)
async with aiofiles.open(raw_path, "wb") as f:
await f.write(compressed_raw)
logging.info(f"[WSS] Saved RAW {raw_hex_id} ({len(compressed_raw)} bytes compressed)")
except Exception as e:
logging.error(f"[WSS] Failed to save RAW replay {raw_hex_id}: {e}")
# Transform to local format first - it handles _id -> hex conversion
wrapped = {'completed': [replay]}
local_data = utils.transform_to_local_format(wrapped)
if not local_data:
logging.warning(f"[WS] Failed to transform replay")
continue
hex_id = local_data.get("session_id_hex", "")
if not hex_id:
continue
# Skip if already processed
replay_dir = replay_session_dir(hex_id)
if replay_data_path(hex_id).exists():
continue
# Validate in-memory (fail fast - don't write invalid data to disk)
teams = local_data.get("teams", [])
squadrons = local_data.get("squadrons", [])
winning_sq = local_data.get("winning_team_squadron", "")
losing_sq = local_data.get("losing_team_squadron", "")
if not teams or len(teams) < 2:
logging.warning(f"[WS] Invalid replay {hex_id}: Missing teams")
continue
if not all(team.get("players") for team in teams):
logging.warning(f"[WS] Invalid replay {hex_id}: Teams have no players")
continue
if not squadrons or all(sq == "" for sq in squadrons):
logging.warning(f"[WS] Invalid replay {hex_id}: Empty squadron names")
continue
if not winning_sq or not losing_sq:
logging.warning(f"[WS] Invalid replay {hex_id}: Missing winner/loser")
continue
# Replay is valid - save to disk
replay_dir = replay_session_dir(hex_id)
replay_dir.mkdir(parents=True, exist_ok=True)
replay_file = replay_dir / "replay_data.json.gz"
try:
raw = json.dumps(local_data, ensure_ascii=False).encode("utf-8")
compressed = await asyncio.to_thread(gzip.compress, raw)
async with aiofiles.open(replay_file, "wb") as f:
await f.write(compressed)
logging.info(f"[WSS] Saved {hex_id} ({len(compressed)} bytes compressed)")
except Exception as e:
logging.error(f"[WSS] Failed to save replay {hex_id}: {e}")
continue
scoreboard_context = await build_scoreboard_context(
hex_id,
local_data,
received_time=now_ts,
end_time=int(replay.get("end_ts") or now_ts),
)
local_data["scoreboard_context"] = scoreboard_context
# Notify all active tallies immediately after the game is saved.
# Done here rather than inside process_session so it fires for all
# guilds regardless of their Logs channel subscriptions.
try:
await tally.on_game_finished(
local_data.get("teams", []),
local_data.get("winning_team_squadron") or None,
bool(local_data.get("draw", False)),
hex_id,
)
except Exception as e:
logging.error(f"[TALLY] on_game_finished failed for {hex_id}: {e}")
forwarded_replays.append(local_data)
validated_games.append({
"sessionIdHex": hex_id,
"endTime": int(replay.get("end_ts") or now_ts),
"missionName": local_data.get("map", ""),
"receivedTime": now_ts,
"scoreboard_context": scoreboard_context,
})
if not validated_games:
return
# Record game count for health dashboard
for _ in validated_games:
record_game_processed()
# Update comps/stats first so /comp and stats queries see new data immediately
await process_comps(validated_games)
await process_stats(validated_games)
# Build guild mappings and dispatch scoreboards to Discord
EVERYTHING_LOGS = True
hex_plus = await build_hex_plus_guild(validated_games, EVERYTHING_LOGS)
await refresh_entitled_guilds()
await prune_invalid_channels(bot, hex_plus)
await dispatch_processing(hex_plus, squadrons_json)
await process_match_summaries(validated_games)
try:
await publish_replay_batch(forwarded_replays)
except Exception as e:
logging.warning(f"[BRIDGE] Failed to forward replay batch: {e}")
# ============================================================================
# GUILD MAPPING AND DISPATCH
# ============================================================================
async def build_hex_plus_guild(
games: List[Dict[str, Any]],
WILDCARD_BOOL: bool
) -> Dict[str, Tuple[Dict[str, Any], List[Tuple[Any, Any]]]]:
"""
For each game, read its replay_data.json, resolve long_clan names, then
match those against each guild's prefs to decide where to send logs.
Optimized: Pre-builds lookup tables from guild prefs, then matches games
against them. O(guilds + games) instead of O(guilds * games).
"""
bot = get_bot()
mapping: Dict[str, Tuple[Dict[str, Any], List[Tuple[Any, Any]]]] = {}
# Build lookups for the prefs "Short"/"Long" refresh below.
long2short: Dict[str, str] = {}
clanid2short: Dict[str, str] = {}
clanid2long: Dict[str, str] = {}
try:
async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
async with db.execute(
"SELECT clan_id, long_name, short_name FROM squadrons_data WHERE long_name IS NOT NULL AND short_name IS NOT NULL"
) as cursor:
async for row in cursor:
cid, ln, sn = row
if ln and sn:
long2short[ln.strip().lower()] = sn
if cid is not None and sn:
clanid2short[str(cid)] = sn
if cid is not None and ln:
clanid2long[str(cid)] = ln
except Exception:
pass
# PHASE 1: Load all guild prefs and build lookup tables (async, with yields)
# Each indexed entry carries (guild, chan, key, flag) where flag is:
# "ok" → in-cap, dispatch as normal
# "over_cap" → enabled but exceeds tier cap, send over-cap warning
# "wildcard_blocked" → wildcard enabled on tier that doesn't allow wildcards
squadron_to_guilds: Dict[str, List[Tuple[Any, str, str, str]]] = {}
wildcard_guilds: List[Tuple[Any, str, str, str]] = []
prefs_to_save: List[Tuple[int, dict]] = []
for i, guild in enumerate(bot.guilds):
# Yield every 50 guilds to keep event loop responsive
if i > 0 and i % 50 == 0:
await asyncio.sleep(0)
prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild.id}-preferences.json"
try:
async with aiofiles.open(prefs_path, "r", encoding="utf-8") as fp:
prefs = json.loads(await fp.read())
except FileNotFoundError:
continue
except Exception:
continue
if not prefs:
continue
# Refresh display fields (Short, Long) on each entry. Keys may be a
# clan_id (post-migration) or a long_name (legacy), so check both maps.
updated = False
for key in prefs.keys():
entry = prefs.get(key)
if not isinstance(entry, dict):
continue
key_str = str(key)
short_val: Optional[str] = None
long_val: Optional[str] = None
if key_str.isdigit() and key_str in clanid2short:
short_val = clanid2short[key_str]
long_val = clanid2long.get(key_str)
else:
key_lc = key_str.strip().lower()
if key_lc in long2short:
short_val = long2short[key_lc]
long_val = key_str # the key itself is the long_name
if short_val and entry.get("Short") != short_val:
entry["Short"] = short_val
updated = True
if long_val and entry.get("Long") != long_val:
entry["Long"] = long_val
updated = True
if updated:
prefs_to_save.append((guild.id, prefs))
# Resolve tier + enabled/allowed sets for Logs
tier = await get_guild_tier(guild.id)
enabled_logs = set(enabled_pref_keys_for(prefs, "Logs"))
allowed_logs = allowed_pref_keys_for(prefs, tier, "Logs")
over_cap_logs = enabled_logs - allowed_logs
enforcement = tier_enforcement_active()
# Index wildcard prefs
if WILDCARD_BOOL:
wildcard = next((k for k in prefs if k.lower() in WILDCARD_KEYS), None)
if wildcard:
chan = prefs[wildcard].get("Logs")
if chan and "DISABLED" not in str(chan).upper():
if wildcard in allowed_logs:
wildcard_guilds.append((guild, chan, wildcard, "ok"))
elif enforcement and wildcard in over_cap_logs:
# Dropped by wildcard gate specifically (standard tier)
flag = "wildcard_blocked" if not tier_allows_wildcard(tier) else "over_cap"
wildcard_guilds.append((guild, chan, wildcard, flag))
# Index squadron prefs.
# Keys may be a numeric clan_id (post-clan_id-migration), a long_name
# (legacy / orphan), or a short_name. The replay matcher below works
# against normalized short/long names, so each pref entry is registered
# under all of its known names so it can be found regardless of which
# form ends up in the replay JSON.
for key, cfg in prefs.items():
if not isinstance(cfg, dict) or is_foreign_pref_entry(cfg):
continue
chan = cfg.get("Logs")
if not chan or "DISABLED" in str(chan).upper():
continue
key_norm = norm(key)
if not key_norm or key_norm in {"*", "all", "everything", "global"}:
continue
match_norms: set[str] = set()
resolved = await resolve_pref_key(key, cfg)
if resolved:
if resolved.get("long_name"):
match_norms.add(norm(resolved["long_name"]))
if resolved.get("short_name"):
match_norms.add(norm(resolved["short_name"]))
if resolved.get("tag_name"):
match_norms.add(norm(resolved["tag_name"]))
if not match_norms:
# Couldn't resolve — fall back to raw key (covers orphaned
# historical prefs where the squadron isn't in squadrons_data).
match_norms.add(key_norm)
for mn in match_norms:
if not mn:
continue
if key in allowed_logs:
squadron_to_guilds.setdefault(mn, []).append((guild, chan, key, "ok"))
elif enforcement and key in over_cap_logs:
squadron_to_guilds.setdefault(mn, []).append((guild, chan, key, "over_cap"))
# Batch save updated prefs (async)
for guild_id, prefs in prefs_to_save:
prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild_id}-preferences.json"
async with aiofiles.open(prefs_path, "w", encoding="utf-8") as fp:
await fp.write(json.dumps(prefs))
# PHASE 2: Match games against pre-built lookup tables
blacklisted_squad_norms = {norm(bl) for bl in blacklisted_squadrons()}
for g in games:
sid = g.get("sessionIdHex", "")
replay_path = replay_data_path(sid)
try:
raw = await asyncio.to_thread(replay_path.read_bytes)
replay_data = json.loads(gzip.decompress(raw))
except Exception:
logging.error(f"SESSION HEX {sid} FAILED TO GET REPLAY DATA")
mapping[sid] = (g, [])
continue
squads = replay_data.get("squadrons", [])
tags = replay_data.get("squadrons_tagged", [])
if not squads:
logging.error(f"SESSION HEX {sid} HAS NO SQUADRONS")
mapping[sid] = (g, [])
continue
# Short names come directly from the replay
squad_shorts = {norm(s) for s in squads}
# Resolve to get long names for matching prefs keyed by long name
resolved = await resolve_clans(shorts=squads, tags=tags)
squad_longs = {norm(c["long_name"]) for c in resolved if c["long_name"] != "<unresolved>"}
all_squad_names = squad_shorts | squad_longs
# Match against indexed guilds
# Dedupe by (guild.id, channel_id). If the same (guild, channel) matches via both
# an allowed ("ok") key and an over-cap key, the "ok" entry wins — we only want to
# fire the warning when NO in-cap squadron for this game matches this channel.
targets_by_key: dict[tuple[int, int], tuple] = {}
def _merge(guild, chan, flag, squadron_key):
chan_id = parse_channel_id(str(chan))
if not chan_id:
return
k = (guild.id, chan_id)
existing = targets_by_key.get(k)
if existing is None:
targets_by_key[k] = (guild, chan, flag, squadron_key)
return
# "ok" wins over any warning flag
if existing[2] != "ok" and flag == "ok":
targets_by_key[k] = (guild, chan, flag, squadron_key)
# Check wildcard guilds
for guild, chan, wkey, flag in wildcard_guilds:
_merge(guild, chan, flag, wkey)
# Check squadron matches — skip blacklisted squadron keys
for squad_norm in all_squad_names:
if squad_norm in blacklisted_squad_norms:
continue
for guild, chan, key, flag in squadron_to_guilds.get(squad_norm, []):
_merge(guild, chan, flag, key)
mapping[sid] = (g, list(targets_by_key.values()))
return mapping
async def prune_invalid_channels(bot, mapping: dict[str, tuple[dict, list[tuple]]]):
"""
Drop any guild-target whose prefs string doesn't parse to a valid channel ID, or whose channel no longer exists.
Drop any guild-target whose prefs string parse to a channel ID inside a blacklisted server.
Remove whole sessions that lose all targets.
Targets are (guild, raw_chan, flag, squadron_key) — flag is preserved through
pruning so the dispatch step can decide between a real scoreboard and an
over-cap/wildcard-blocked warning.
"""
blacklist = blacklisted_guilds()
for sid in list(mapping):
game, targets = mapping[sid]
valid_targets: list[tuple] = []
for entry in targets:
# Backward-compat: older callers may pass (guild, raw_chan) 2-tuples.
if len(entry) == 4:
guild, raw_chan, flag, squadron_key = entry
else:
guild, raw_chan = entry[0], entry[1]
flag, squadron_key = "ok", ""
chan_id = parse_channel_id(raw_chan)
if chan_id is None:
if squadron_key:
await remove_guild_pref_notification(guild.id, squadron_key, "Logs")
continue
# Drop any target from a blacklisted server
if guild.id in blacklist:
continue
# Check channel exists in Discord
channel = bot.get_channel(chan_id)
if channel is None:
try:
channel = await bot.fetch_channel(chan_id)
except discord.NotFound:
if squadron_key:
await remove_guild_pref_notification(guild.id, squadron_key, "Logs")
continue
except discord.Forbidden:
continue
# Premium gate — notify and skip guilds without an active subscription
if not await is_guild_entitled(guild.id):
logging.info(f"[PREMIUM] Skipping guild {guild.id} — no active entitlement")
try:
_gf = await load_features(guild.id)
_lang = lang_from_features(_gf)
embed = discord.Embed(
title=t(_lang, "autolog.game_not_logged_title"),
description=t(_lang, "autolog.game_not_logged_desc"),
color=discord.Color.red(),
)
await channel.send(embed=embed)
except Exception:
pass
continue
valid_targets.append((guild, chan_id, flag, squadron_key))
if valid_targets:
mapping[sid] = (game, valid_targets)
else:
mapping.pop(sid)
async def dispatch_processing(
mapping: dict[str, tuple[dict, list[tuple]]],
squadrons_json: dict
):
"""
Process each (game, guild) session concurrently.
Concurrency is governed by the process semaphore.
"""
bot = get_bot()
semaphore = _get_semaphore()
# Flatten out all (game, guild, channel_id, flag, squadron_key) tuples
sessions = []
for _, (game, targets) in mapping.items():
for entry in targets:
if len(entry) == 4:
guild, channel_id, flag, squadron_key = entry
else:
guild, channel_id = entry[0], entry[1]
flag, squadron_key = "ok", ""
sessions.append((game, guild, channel_id, flag, squadron_key))
total = len(sessions)
if total == 0:
return
async def _worker(game, guild, channel_id, flag, squadron_key):
sid = game.get("sessionIdHex", "")
mission_name = game.get("missionName", "")
end_time = game.get("endTime", "")
received_time = game.get("receivedTime")
# Tier over-cap paths: send the orange warning in place of the scoreboard.
if flag in ("over_cap", "wildcard_blocked"):
async with semaphore:
try:
channel = bot.get_channel(channel_id)
if channel is None:
channel = await bot.fetch_channel(channel_id)
if not isinstance(channel, discord.abc.Messageable):
return
_gf = await load_features(guild.id)
_lang = lang_from_features(_gf)
tier = await get_guild_tier(guild.id)
# Resolve the pref key to a readable squadron name. Post-clan_id
# migration the key is the numeric clan_id, which would otherwise
# show as a bare ID in the embed.
display_squadron = squadron_key
try:
prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild.id}-preferences.json"
async with aiofiles.open(prefs_path, "r", encoding="utf-8") as fp:
_prefs = json.loads(await fp.read())
_entry = _prefs.get(squadron_key) if isinstance(_prefs, dict) else None
if isinstance(_entry, dict):
display_squadron = _entry.get("Long") or _entry.get("Short") or squadron_key
except Exception:
pass
await send_over_cap_warning(
channel, _lang, tier, "Logs", display_squadron, reason=flag
)
except Exception as err:
logging.warning(f"[TIER] Over-cap warn failed {guild.id}/{channel_id}: {err}")
return
# Normal scoreboard path
prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild.id}-preferences.json"
try:
async with aiofiles.open(prefs_path, "r", encoding="utf-8") as fp:
guild_prefs = json.loads(await fp.read())
except Exception:
guild_prefs = {}
async with semaphore:
try:
await process_session(
bot,
guild.id,
guild.name,
channel_id,
sid,
mission_name,
end_time,
squadrons_json,
guild_prefs,
received_time=received_time,
session_context=game.get("scoreboard_context"),
)
except asyncio.CancelledError:
raise
except Exception as err:
logging.error(f"Error processing {sid} for {guild.id}: {err}", exc_info=True)
tasks = [
asyncio.create_task(_worker(game, guild, channel_id, flag, squadron_key))
for game, guild, channel_id, flag, squadron_key in sessions
]
await asyncio.gather(*tasks, return_exceptions=True)
# ============================================================================
# SESSION PROCESSING
# ============================================================================
async def process_session(
bot,
guild_id: int,
guild_name: str,
squadron_pref,
session_id: str,
map_name: str,
timestamp: int,
squadrons_data: dict,
guild_prefs: dict,
received_time: Optional[int] = None,
session_context: Optional[dict[str, Any]] = None,
):
"""Process a single game session: build scoreboard and send it to Discord.
Loads replay data from disk, translates vehicle names, resolves clan names,
records W/L results, generates a scoreboard image (cached per session/color/
language), deduplicates per channel, and sends the image with interactive
buttons. Appends a premium upsell embed for non-entitled guilds.
Args:
bot: The Discord bot instance.
guild_id: Discord guild ID receiving the scoreboard.
guild_name: Human-readable guild name (for logging).
squadron_pref: Raw channel-ID preference string for the guild.
session_id: Hex session ID of the replay.
map_name: Display name of the map played.
timestamp: UTC Unix timestamp of the match.
squadrons_data: Mapping of guild IDs to squadron metadata.
guild_prefs: Full guild preferences dict (passed to points diffs).
"""
# Load replay JSON
base_dir = replay_session_dir(session_id)
replay_path = replay_data_path(session_id)
try:
raw = await asyncio.to_thread(replay_path.read_bytes)
replay_data = json.loads(gzip.decompress(raw))
except FileNotFoundError:
logging.error(f"Replay file not found for session ID {session_id}")
return
except (OSError, json.JSONDecodeError) as e:
logging.error(f"Replay file for session ID {session_id} is invalid: {e}")
return
# Extract winner/loser/draw — strip tag decorators so bare short names
# (e.g. "DSPLA" stored in SQUADRONS.json) match raw replay values like "-DSPLA-".
def _strip_tag(s: Optional[str]) -> Optional[str]:
if s is None:
return None
return re.sub(r'^[^A-Za-z0-9]+|[^A-Za-z0-9]+$', '', s) or s
squadrons = replay_data.get("squadrons", [])
winner = _strip_tag(replay_data.get("winning_team_squadron"))
is_draw = replay_data.get("draw", False)
loser_candidates = [_strip_tag(sq) for sq in squadrons if _strip_tag(sq) != winner]
loser = loser_candidates[0] if loser_candidates else None
# Guild-specific squadron (short name)
guild_data = squadrons_data.get(str(guild_id), {})
guild_squadron = guild_data.get("SQ_ShortHand_Name")
# Translation setup. lang/units.csv columns are stored with literal
# angle brackets (e.g. ``<Russian>``) — pass the column name through
# unmodified so LangTableReader actually selects the right column.
# The stripped form is kept only for filesystem-safe filenames below.
guild_features = await load_features(guild_id)
lang = lang_from_features(guild_features)
language_column = guild_features.get("Language", "<English>")
language = language_column.strip("<>")
translate = LangTableReader(language_column)
# Translate vehicles in memory
for team in replay_data.get("teams", []):
for player in team.get("players", []):
vehicle = player.get("vehicle")
if vehicle:
translated = translate.get_translate(vehicle)
player["vehicle_new"] = translated if translated else vehicle
else:
player["vehicle"] = "DISCONNECTED"
player["vehicle_new"] = "DISCONNECTED"
# Clan resolution — resolve each team independently to preserve index order.
# Batching all teams into one resolve_clans call inverts results when one
# team resolves via the short-name pass and another via the tag pass.
for team in replay_data.get("teams", []):
if not team:
continue
sq = str(team.get("squadron") or "")
tag = str(team.get("squadron_tagged") or "")
if not sq and not tag:
continue
_results = await resolve_clans(shorts=[sq] if sq else [], tags=[tag] if tag else [])
if _results:
r = _results[0]
if r.get("long_name") and r["long_name"] != "<unresolved>":
team["squadron_long"] = r["long_name"]
if r.get("short_name"):
team["squadron_short"] = r["short_name"]
# Prep scoreboard params
if session_context and session_context.get("match_details"):
match_details: Dict[str, Any] = dict(session_context["match_details"])
else:
match_details = {"utc_timestamp": str(timestamp), "session_id": session_id}
if received_time is not None:
match_details["received_unix"] = int(received_time)
else:
try:
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as _conn:
async with _conn.execute(
"SELECT received_unix FROM match_summary WHERE session_id = ?",
(session_id,),
) as _cur:
_row = await _cur.fetchone()
if _row and _row[0] is not None:
match_details["received_unix"] = int(_row[0])
except Exception:
pass
if is_draw:
bar_color = "draw"
elif guild_squadron == winner:
bar_color = "win"
elif guild_squadron == loser:
bar_color = "loss"
elif guild_squadron is None:
bar_color = "not_set"
else:
bar_color = "not_involved"
os.makedirs(base_dir, exist_ok=True)
output_path = base_dir / f"game_result-{bar_color}-{language}.png"
cache_key = (session_id, bar_color, language)
teams: List[Dict[str, Any]] = replay_data.get("teams", [])
new_wl = session_context.get("wl", {}) if session_context else {}
diffs = session_context.get("points_diffs", {}) if session_context else {}
winner_tag: Optional[str] = session_context.get("winner") if session_context else replay_data.get("winning_team_squadron")
if not winner_tag:
w_idx = replay_data.get("winner")
home_tag: Optional[str] = (teams[0] or {}).get("squadron") if len(teams) > 0 else None
away_tag: Optional[str] = (teams[1] or {}).get("squadron") if len(teams) > 1 else None
if w_idx in (0, 1):
winner_tag = (home_tag, away_tag)[w_idx]
if not new_wl:
squadrons_clean: List[str] = [
t for t in (
(teams[0] or {}).get("squadron") if len(teams) > 0 else None,
(teams[1] or {}).get("squadron") if len(teams) > 1 else None,
)
if t and t.strip() and t.upper() != "UNKNOWN"
]
if is_draw and len(squadrons_clean) >= 2:
try:
new_wl = await record_draw(squadrons_clean, session_id)
except Exception as e:
logging.error(f"[W/L] record_draw failed ({session_id}): {e}")
new_wl = get_standings(squadrons_clean)
elif winner_tag and winner_tag in squadrons_clean and len(squadrons_clean) >= 2:
try:
new_wl = await record_result(winner_tag, squadrons_clean, session_id)
except Exception as e:
logging.error(f"[W/L] record_result failed ({session_id}): {e}")
new_wl = get_standings(squadrons_clean)
else:
new_wl = get_standings(squadrons_clean)
# Scoreboard Build
lock = _scoreboard_locks.setdefault(session_id, asyncio.Lock())
async with lock:
if not output_path.exists():
try:
if not diffs:
diffs = await get_points_diffs(
session_id,
guild_id,
guild_name,
guild_prefs,
replay_data
)
await create_scoreboard(
match_details,
winner,
teams[0] or {},
teams[1] or {},
map_name,
str(output_path),
bar_color,
diffs,
WL=new_wl,
is_draw=is_draw,
)
except Exception as e:
logging.error(f"create_scoreboard failed: {e}")
logging.error(traceback.format_exc())
return
if not output_path.exists():
logging.error(f"Scoreboard image still missing: {output_path}")
return
# DE-DUPING: skip if already sent to this channel
channel_id = parse_channel_id(str(squadron_pref))
if channel_id is None:
logging.warning(f"[PROCESS] Invalid channel ID format: {squadron_pref} for {guild_name} ({guild_id})")
return
sent_set = _sent_channels_by_session.setdefault(session_id, set())
if channel_id in sent_set:
logging.info(f"[SEND] Skipped {session_id}{guild_name} ({channel_id}): already sent")
return
sent_set.add(channel_id)
# SEND EMBED + IMAGE
try:
# FILE VALIDATION
if not output_path.exists():
logging.error(f"[SEND] Scoreboard file missing for {session_id}: {output_path}")
return
if output_path.stat().st_size == 0:
logging.error(f"[SEND] Scoreboard file empty for {session_id}: {output_path}")
return
# CHANNEL VALIDATION
channel = bot.get_channel(channel_id)
if channel is None:
try:
channel = await bot.fetch_channel(channel_id)
except discord.NotFound:
logging.warning(f"[SEND] Channel not found for {session_id}: {channel_id} in {guild_name}")
return
except discord.Forbidden:
logging.warning(f"[SEND] No access to channel for {session_id}: {channel_id} in {guild_name}")
return
USE_SCOREBOARD_CACHE = False
# SEND WITH RETRY LOGIC
max_retries = 3
base_delay = 1
# Check cache *only if allowed*
if USE_SCOREBOARD_CACHE:
cached_url = _scoreboard_cache.get(cache_key)
if cached_url:
view = build_scoreboard_view(guild_id, session_id, lang=lang)
await channel.send(content=cached_url, view=view)
return
# Otherwise, first upload - normal send loop
for attempt in range(max_retries):
try:
view = build_scoreboard_view(guild_id, session_id, lang=lang)
with open(output_path, 'rb') as f:
msg = await channel.send(
file=discord.File(f, filename="game_result.png"),
view=view
)
# Cache the file *only if caching is enabled*
if USE_SCOREBOARD_CACHE and msg.attachments:
attachment = msg.attachments[0]
_scoreboard_cache[cache_key] = attachment.url
logging.info(f"[SEND] Scoreboard sent for {session_id}{guild_name} ({channel_id})")
# Premium upsell for non-entitled guilds
if not await is_guild_entitled(guild_id):
warn_embed = discord.Embed(
title=t(lang, "autolog.server_not_upgraded_title"),
description=t(lang, "autolog.server_not_upgraded_autolog_desc", deadline=PREMIUM_ACTIVATION_TS),
color=discord.Color.orange(),
)
warn_embed.set_footer(text=DEFAULT_FOOTER_CAT)
try:
await channel.send(embed=warn_embed)
except Exception as e:
logging.error("(AUTOLOG) Error sending premium warning: %s", e)
return
except (BrokenPipeError, ConnectionError, TimeoutError, aiohttp.ClientOSError, ConnectionResetError) as e:
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
await asyncio.sleep(delay)
continue
else:
logging.error(f"[SEND] Network failure after {max_retries} retries for {session_id}{guild_name} ({channel_id}): {e}")
return
except discord.HTTPException as e:
logging.error(f"[SEND] Discord HTTP error for {session_id}{guild_name} ({channel_id}): {e}")
return
except Exception as e:
logging.error(f"[SEND] Unexpected error for {session_id}{guild_name} ({channel_id}): {e}")
return
except Exception as e:
logging.error(f"[SEND] Critical error for {session_id}{guild_name} ({guild_id}): {e}")
logging.error(traceback.format_exc())
def build_scoreboard_view(guild_id: int, session_id: str, lang: str = "en") -> discord.ui.View:
"""Create a Discord UI View with interactive buttons for a scoreboard message.
Buttons included: View Replay (link), View on Website (link), View Video,
View Log, and View Chat (only if a chat log exists in replay data).
Args:
guild_id: Discord guild ID.
session_id: Hex session ID used to build the replay URL and callbacks.
Returns:
discord.ui.View: Persistent view with the assembled buttons.
"""
view = discord.ui.View(timeout=None)
session_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}"
web_url = f"https://sre.pawjob.us/games/{session_id}"
replay_button = discord.ui.Button(
label=t(lang, "buttons.view_replay"),
style=discord.ButtonStyle.link,
url=session_url,
)
view.add_item(replay_button)
website_button = discord.ui.Button(
label=t(lang, "buttons.view_website"),
style=discord.ButtonStyle.link,
url=web_url,
emoji="🌐",
)
view.add_item(website_button)
video_button = discord.ui.Button(
label=t(lang, "buttons.view_video"),
style=discord.ButtonStyle.blurple,
emoji="🎬",
)
async def on_video_click(interaction):
await handle_view_video(interaction, session_id)
video_button.callback = on_video_click
view.add_item(video_button)
battlelog_button = discord.ui.Button(
label=t(lang, "buttons.view_log"),
style=discord.ButtonStyle.green,
emoji="📜",
)
async def on_battlelog_click(interaction):
await handle_view_battlelog(interaction, session_id)
battlelog_button.callback = on_battlelog_click
view.add_item(battlelog_button)
# Check if chat_log exists in replay_data
replay_data = load_replay_data_from_disk(session_id)
if replay_data and replay_data.get("chat_log"):
chatlog_button = discord.ui.Button(
label=t(lang, "buttons.view_chat"),
style=discord.ButtonStyle.green,
emoji="💬",
)
async def on_chatlog_click(interaction):
await handle_view_chatlog(interaction, session_id)
chatlog_button.callback = on_chatlog_click
view.add_item(chatlog_button)
return view
async def handle_view_video(interaction: discord.Interaction, session_id: str):
"""Callback for 'View Video' - renders replay JSON to MP4, sends ephemerally."""
try:
try:
await interaction.response.defer(thinking=True, ephemeral=True)
except Exception:
return
_gf = await load_features(interaction.guild_id) if interaction.guild_id else {}
_lang = lang_from_features(_gf)
replay_dir = replay_session_dir(session_id)
replay_json_path = replay_data_path(session_id)
video_path = replay_dir / "replay_video.mp4"
if not replay_json_path.exists():
await interaction.followup.send(
t(_lang, "autolog.replay_not_available"),
ephemeral=True
)
return
# Serve cached video if it exists
if not video_path.exists():
if _video_render_sem._value == 0:
await interaction.followup.send(
t(_lang, "autolog.too_many_videos"),
ephemeral=True
)
return
try:
def _generate():
d = load_gob_file(replay_json_path)
render_gob(d, video_path)
logging.info(f"REPLAY ({session_id}) RENDER START")
async with _video_render_sem:
await asyncio.get_event_loop().run_in_executor(None, _generate)
logging.info(f"REPLAY ({session_id}) RENDER END (Success)")
except Exception as e:
logging.info(f"REPLAY ({session_id}) RENDER END (Fail)")
# Clean up broken/partial mp4 so it doesn't get cached
if video_path.exists():
video_path.unlink(missing_ok=True)
error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
await interaction.followup.send(t(_lang, "autolog.video_gen_failed", error=error_str), ephemeral=True)
return
if not video_path.exists() or video_path.stat().st_size == 0:
await interaction.followup.send(
t(_lang, "autolog.video_missing"),
ephemeral=True
)
return
file_size = video_path.stat().st_size
guild = interaction.guild
max_size = guild.filesize_limit if guild else 25 * 1_048_576
if file_size > max_size:
file_mb = file_size / 1_048_576
limit_mb = max_size / 1_048_576
await interaction.followup.send(
t(_lang, "autolog.video_too_large", file_mb=file_mb, limit_mb=limit_mb),
ephemeral=True
)
return
web_url = f"https://sre.pawjob.us/games/{session_id}"
try:
await interaction.followup.send(
content=t(_lang, "autolog.video_web_fallback", url=web_url),
file=discord.File(video_path),
ephemeral=True,
)
except discord.HTTPException:
# Upload failed (too large, rate limited, etc.) — fall back to web link
await interaction.followup.send(
content=t(_lang, "autolog.video_upload_failed", url=web_url),
ephemeral=True,
)
except Exception as e:
try:
error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
await interaction.followup.send(
t("en", "autolog.video_unexpected_error", error=error_str),
ephemeral=True
)
except:
pass
async def handle_view_chatlog(interaction: discord.Interaction, session_id: str):
"""Callback for 'View Chat Log' - loads and displays chat_log from replay_data.json."""
session_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}"
try:
try:
await interaction.response.defer(thinking=True, ephemeral=True)
except Exception:
return
_gf = await load_features(interaction.guild_id) if interaction.guild_id else {}
_lang = lang_from_features(_gf)
replay_data = load_replay_data_from_disk(session_id)
if replay_data is None:
await interaction.followup.send(
t(_lang, "autolog.replay_not_found", session_id=session_id),
ephemeral=True
)
return
# Check if chat_log exists
chat_log = replay_data.get("chat_log", [])
if not chat_log:
await interaction.followup.send(
t(_lang, "autolog.no_chat_log", session_id=session_id),
ephemeral=True
)
return
# Get winning and losing squadrons
winning_squadron = replay_data.get("winning_team_squadron", "")
losing_squadron = replay_data.get("losing_team_squadron", "")
# Format chat log with diff syntax (green for winners, red for losers)
formatted_lines = []
# Pattern: [timestamp] [TEAM/ALL] [squadron] `username`: message
pattern = r'\[([^\]]+)\]\s*\[([A-Z]+)\]\s*\[([^\]]+)\]\s*`([^`]+)`:\s*(.+)'
for line in chat_log:
match = re.match(pattern, line)
if match:
timestamp = match.group(1)
scope = match.group(2)
squadron = match.group(3)
username = match.group(4)
message = match.group(5)
chat_scope_prefix = "T" if scope == "TEAM" else "A"
# Determine prefix based on winning/losing team
if squadron == winning_squadron:
prefix = "+"
elif squadron == losing_squadron:
prefix = "-"
else:
prefix = " "
formatted_line = f"{prefix}[{timestamp}] [{chat_scope_prefix}] {f'[{squadron}]':<7} {username}: {message}"
formatted_lines.append(formatted_line)
else:
formatted_lines.append(f" {line}")
chat_text = "\n".join(formatted_lines)
# Discord message limit is 2000 chars, split if needed
if len(chat_text) <= 1900:
await interaction.followup.send(
t(_lang, "autolog.chat_log_title", session_id=session_id, url=session_url) + f"\n```diff\n{chat_text}\n```",
ephemeral=True
)
else:
# Split into chunks
chunks = []
current_chunk = []
current_length = 0
for line in formatted_lines:
line_length = len(line) + 1
if current_length + line_length > 1800:
chunks.append("\n".join(current_chunk))
current_chunk = [line]
current_length = line_length
else:
current_chunk.append(line)
current_length += line_length
if current_chunk:
chunks.append("\n".join(current_chunk))
# Send first chunk with header
await interaction.followup.send(
t(_lang, "autolog.chat_log_part_title", session_id=session_id, url=session_url, part=1, total=len(chunks)) + f"\n```diff\n{chunks[0]}\n```",
ephemeral=True
)
# Send remaining chunks
for i, chunk in enumerate(chunks[1:], start=2):
await interaction.followup.send(
t(_lang, "autolog.chat_log_part_only", part=i, total=len(chunks)) + f"\n```diff\n{chunk}\n```",
ephemeral=True
)
except Exception as e:
try:
error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
await interaction.followup.send(
t("en", "autolog.chat_log_error", error=error_str),
ephemeral=True
)
except:
pass
async def handle_view_battlelog(interaction: discord.Interaction, session_id: str):
"""Callback for 'Battle Log' - loads and displays events from replay_data.json."""
session_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}"
try:
try:
await interaction.response.defer(thinking=True, ephemeral=True)
except Exception:
return
_gf = await load_features(interaction.guild_id) if interaction.guild_id else {}
_lang = lang_from_features(_gf)
replay_data = load_replay_data_from_disk(session_id)
if replay_data is None:
await interaction.followup.send(
t(_lang, "autolog.replay_not_found", session_id=session_id),
ephemeral=True
)
return
formatted_lines = replay_data.get("battle_log", [])
if not formatted_lines:
await interaction.followup.send(
t(_lang, "autolog.no_battle_log", session_id=session_id),
ephemeral=True
)
return
log_text = "\n".join(formatted_lines)
# Discord message limit is 2000 chars, split if needed
if len(log_text) <= 1900:
await interaction.followup.send(
t(_lang, "autolog.battle_log_title", session_id=session_id, url=session_url) + f"\n```diff\n{log_text}\n```",
ephemeral=True
)
else:
chunks = []
current_chunk = []
current_length = 0
for line in formatted_lines:
line_length = len(line) + 1
if current_length + line_length > 1800:
chunks.append("\n".join(current_chunk))
current_chunk = [line]
current_length = line_length
else:
current_chunk.append(line)
current_length += line_length
if current_chunk:
chunks.append("\n".join(current_chunk))
await interaction.followup.send(
t(_lang, "autolog.battle_log_part_title", session_id=session_id, url=session_url, part=1, total=len(chunks)) + f"\n```diff\n{chunks[0]}\n```",
ephemeral=True
)
for i, chunk in enumerate(chunks[1:], start=2):
await interaction.followup.send(
t(_lang, "autolog.battle_log_part_only", part=i, total=len(chunks)) + f"\n```diff\n{chunk}\n```",
ephemeral=True
)
except Exception as e:
try:
error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
await interaction.followup.send(
t("en", "autolog.battle_log_error", error=error_str),
ephemeral=True
)
except:
pass
async def get_points_diffs(
sid: str,
guild_id: int,
guild_name: str,
guild_prefs: dict[str, Any],
replay_data: dict[str, Any]
) -> dict[str, dict[str, Any]]:
"""
Calls the PTS API for both teams in a replay and returns a structured diffs dict.
Always processes both teams regardless of guild preferences.
"""
diffs: dict[str, dict[str, Any]] = {}
raw_teams = (replay_data.get("teams") or [])[:2]
for team in raw_teams:
if not team:
continue
long_name = team.get("squadron_long") or ""
if not long_name:
continue
try:
points_diff, diff_total, current_points = await get_point_diff(sid, team)
diffs[long_name] = {
"points_diff": points_diff,
"diff_total": diff_total,
"current_points": current_points,
}
except Exception as e:
logging.error(f"Failed to get point diff for {long_name}: {e}")
return diffs
async def build_scoreboard_context(
session_id: str,
replay_data: dict[str, Any],
*,
received_time: Optional[int] = None,
end_time: Optional[int] = None,
) -> dict[str, Any]:
"""Build the scoreboard payload once per session.
The result is reused by the Discord renderer and queued into the external
websocket bridge so AXBot can render without reconstructing WL or point
diff state from scratch.
"""
teams: List[Dict[str, Any]] = list((replay_data.get("teams") or [])[:2])
# Resolve each team independently and concurrently so the result is always
# index-aligned with the teams list. Batching both teams into one
# resolve_clans call can invert the order when one team resolves via the
# short-name pass and the other falls through to the tag pass.
async def _resolve_team(team: dict) -> Optional[dict]:
sq = str(team.get("squadron") or "")
tag = str(team.get("squadron_tagged") or "")
if not sq and not tag:
return None
results = await resolve_clans(shorts=[sq] if sq else [], tags=[tag] if tag else [])
return results[0] if results else None
resolved_teams = await asyncio.gather(*[_resolve_team(t) for t in teams])
for team, result in zip(teams, resolved_teams):
if team and result:
if result.get("long_name") and result["long_name"] != "<unresolved>":
team["squadron_long"] = result["long_name"]
if result.get("short_name"):
team["squadron_short"] = result["short_name"]
home_tag: Optional[str] = (teams[0] or {}).get("squadron") if len(teams) > 0 else None
away_tag: Optional[str] = (teams[1] or {}).get("squadron") if len(teams) > 1 else None
def _clean(tag: Optional[str]) -> bool:
return bool(tag and tag.strip() and tag.upper() != "UNKNOWN")
squadrons_clean: List[str] = [t for t in (home_tag, away_tag) if _clean(t) and t is not None]
winner_tag: Optional[str] = replay_data.get("winning_team_squadron")
is_draw = bool(replay_data.get("draw", False))
if not winner_tag:
w_idx = replay_data.get("winner")
if w_idx in (0, 1):
winner_tag = (home_tag, away_tag)[w_idx]
if is_draw and len(squadrons_clean) >= 2:
try:
wl = await record_draw(squadrons_clean, session_id)
except Exception as e:
logging.error(f"[W/L] record_draw failed ({session_id}): {e}")
wl = get_standings(squadrons_clean)
elif winner_tag and winner_tag in squadrons_clean and len(squadrons_clean) >= 2:
try:
wl = await record_result(winner_tag, squadrons_clean, session_id)
except Exception as e:
logging.error(f"[W/L] record_result failed ({session_id}): {e}")
wl = get_standings(squadrons_clean)
else:
wl = get_standings(squadrons_clean)
points_diffs = await get_points_diffs(session_id, 0, "", {}, replay_data)
match_details: Dict[str, Any] = {
"utc_timestamp": int(end_time or replay_data.get("end_ts") or 0),
"session_id": session_id,
}
if received_time is not None:
match_details["received_unix"] = int(received_time)
return {
"match_details": match_details,
"winner": winner_tag,
"is_draw": is_draw,
"wl": wl,
"points_diffs": points_diffs,
}
# ============================================================================
# COMPANION PROCESSING
# ============================================================================
async def process_comps(new_games):
"""Track team composition updates for each squadron.
For every game, reads replay data, translates vehicles, and compares the
player roster against existing compositions stored in per-squadron JSON
files under ``STORAGE_DIR / "COMPS"``. If a roster matches an existing
comp (fewer than 2 additions and 2 removals), the comp is updated in place;
otherwise a new comp entry is created.
Args:
new_games: List of game dicts, each containing ``sessionIdHex`` and
``endTime`` keys.
"""
comp_dir = STORAGE_DIR / "COMPS"
comp_dir.mkdir(parents=True, exist_ok=True)
for game in new_games:
session_id = game.get('sessionIdHex')
endtime_raw = game.get('endTime')
replay_path = replay_data_path(session_id)
try:
raw = await asyncio.to_thread(replay_path.read_bytes)
replay_data = json.loads(gzip.decompress(raw))
except FileNotFoundError:
logging.warning(f"(COMP-WRITE) Replay file not found: {replay_path}")
continue
except (OSError, json.JSONDecodeError) as e:
logging.warning(f"(COMP-WRITE) Invalid replay: {replay_path}: {e}")
continue
# Translate vehicle names
translate = LangTableReader("English")
for team in replay_data.get("teams", []):
for p in team.get("players", []):
veh = p.get("vehicle")
if veh:
p["vehicle_new"] = translate.get_translate(veh)
else:
p["vehicle"] = "DISCONNECTED"
p["vehicle_new"] = "DISCONNECTED"
# Compare to existing comps and write new/updated ones
for team in replay_data.get("teams", []):
squadron = re.sub(r'^[^A-Za-z0-9]+|[^A-Za-z0-9]+$', '',
team.get("squadron", "UNKNOWN")).upper() or "UNKNOWN"
squad_file = comp_dir / f"{squadron}.json"
# Load existing comps_data (or start fresh)
comps_data = {}
if squad_file.exists():
try:
async with aiofiles.open(squad_file, "r", encoding="utf-8") as f:
comps_data = json.loads(await f.read())
except json.JSONDecodeError:
comps_data = {}
# Build this game's player list
players_list = [
{
"UID": p["uid"],
"nick": (p.get("nick") or "").replace("coop/", "") or "Unknown",
"vehicle": p.get("vehicle_new") or "DISCONNECTED",
"vehicle_internal": p.get("vehicle") or "DISCONNECTED"
}
for p in team.get("players", [])
]
new_uids = {p["UID"]: p for p in players_list}
# Try to match an existing COMP by roster similarity
matched_key = None
added = set()
removed = set()
vehicle_diff = 0
for comp_key, comp in comps_data.items():
old_players = comp.get("Players", [])
old_uids = {p["UID"]: p for p in old_players}
added = set(new_uids) - set(old_uids)
removed = set(old_uids) - set(new_uids)
shared = set(new_uids) & set(old_uids)
vehicle_diff = sum(
1 for uid in shared
if new_uids[uid]["vehicle"] != old_uids[uid]["vehicle"]
)
# Treat as same comp if fewer than 2 added and fewer than 2 removed
if len(added) < 2 and len(removed) < 2:
matched_key = comp_key
break
if matched_key:
# Always update roster + timestamp when matched
comps_data[matched_key]["Players"] = players_list
comps_data[matched_key]["upd"] = endtime_raw
async with aiofiles.open(squad_file, "w", encoding="utf-8") as f:
await f.write(json.dumps(comps_data, ensure_ascii=False, indent=2))
logging.info(f"(COMP-WRITE) Updated {matched_key} for {squadron} | added={len(added)} removed={len(removed)} veh_diff={vehicle_diff} | upd={endtime_raw}")
continue
# No match => create a new COMP entry
new_comp_key = f"COMP{len(comps_data) + 1}"
comps_data[new_comp_key] = {
"reg": endtime_raw,
"upd": endtime_raw,
"Players": players_list
}
async with aiofiles.open(squad_file, "w", encoding="utf-8") as f:
await f.write(json.dumps(comps_data, ensure_ascii=False, indent=2))
logging.info(f"(COMP-WRITE) Created {new_comp_key} for {squadron} | {len(players_list)} players | reg={endtime_raw}")
def as_int(x, default=0):
"""Safely convert *x* to int, returning *default* on failure.
Args:
x: Value to convert.
default: Returned when conversion raises TypeError or ValueError.
Returns:
int: Converted value or *default*.
"""
try:
return int(x)
except (TypeError, ValueError):
return default
async def process_stats(new_games):
"""Upsert per-player game statistics into the SQLite player_games_hist table.
Loads replay data for each new game, translates vehicle names, and inserts
one row per player per session (skipping AI bot backfills). Sessions already
present in the database are skipped for deduplication.
Args:
new_games: List of game dicts with ``sessionIdHex`` and ``endTime``.
"""
if not new_games:
return
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn:
await init_players_db(conn)
# Collect session IDs once
session_ids = []
end_times = {}
for game in new_games:
sid = game.get("sessionIdHex")
if sid:
session_ids.append(sid)
end_times[sid] = int(game.get("endTime", 0) or 0)
if not session_ids:
return
# Skip sessions already in DB
placeholders = ",".join("?" for _ in session_ids)
existing = await conn.execute_fetchall(
f"SELECT DISTINCT session_id FROM player_games_hist WHERE session_id IN ({placeholders})",
session_ids
)
existing_sessions = {row[0] for row in existing}
sessions_to_process = [sid for sid in session_ids if sid not in existing_sessions]
if not sessions_to_process:
return
translate = get_translator()
all_rows = []
await conn.execute("BEGIN;")
for sid in sessions_to_process:
endtime_raw = end_times.get(sid, 0)
replay_path = replay_data_path(sid)
try:
raw = await asyncio.to_thread(replay_path.read_bytes)
replay_data = json.loads(gzip.decompress(raw))
except (FileNotFoundError, OSError, json.JSONDecodeError):
continue
winning_team = replay_data.get("winning_team_squadron")
teams = replay_data.get("teams") or []
for team in teams:
# WT replay JSON occasionally leaks decorative tag glyphs (box-drawing
# chars, brackets, "=" etc.) into the "squadron" field, producing
# duplicate squadron_name variants for the same clan. Strip to plain
# short-name alphanumerics; fall back to UNKNOWN if stripping empties.
squadron_name = re.sub(r"[^A-Za-z0-9_-]", "", team.get("squadron", "UNKNOWN")) or "UNKNOWN"
squadron_tagged = team.get("squadron_tagged", "UNKNOWN")
victor_text = "Win" if (winning_team and squadron_name == winning_team) else "Loss"
team_clan_id = _resolve_clan_id_for_pgh(squadron_name)
players = team.get("players") or []
for p in players:
uid = str(p.get("uid") or "").strip()
nick = str(p.get("nick") or "").strip()
if not (uid and nick and sid):
continue
# Skip AI bot backfills (e.g. "coop/Bot340") —
# these replace disconnected players but keep their UID,
# polluting the nick column for real players.
if nick.startswith("coop/"):
continue
veh_internal = p.get("vehicle") or "DISCONNECTED"
veh_display = p.get("vehicle_new")
if not veh_display:
if veh_internal != "DISCONNECTED":
veh_display = translate.get_translate(str(veh_internal))
else:
veh_display = "DISCONNECTED"
all_rows.append((
uid,
nick,
squadron_name,
squadron_tagged,
sid,
veh_display,
veh_internal,
as_int(p.get("ground_kills")),
as_int(p.get("air_kills")),
as_int(p.get("assists")),
as_int(p.get("captures")),
as_int(p.get("deaths")),
victor_text,
endtime_raw,
team_clan_id,
))
if not all_rows:
await conn.execute("ROLLBACK;")
return
try:
await conn.executemany(UPSERT_SQL, all_rows)
await conn.commit()
except Exception as e:
await conn.execute("ROLLBACK;")
logging.error(f"(STAT-MANAGER) DB upsert failed: {e}")
def _compact(obj: Any) -> str:
"""Serialize *obj* to a compact JSON string with no extra whitespace.
Args:
obj: JSON-serializable object.
Returns:
str: Minified JSON string.
"""
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def _find_winner_loser(replay_data: Dict[str, Any]) -> Optional[Tuple[Dict, Dict]]:
"""Extract the winner and loser team dicts from replay data.
Args:
replay_data: Parsed replay JSON containing ``teams``,
``winning_team_squadron``, and ``losing_team_squadron`` keys.
Returns:
A ``(winner_team, loser_team)`` tuple of team dicts, or ``None`` if
fewer than two teams exist or either tag cannot be resolved.
"""
teams: List[Dict] = replay_data.get("teams") or []
if len(teams) < 2:
return None
winner_tag = replay_data.get("winning_team_squadron")
loser_tag = replay_data.get("losing_team_squadron")
# Map tags to team dicts
tag_to_team = {(t or {}).get("squadron"): (t or {}) for t in teams}
winner_team_dict = tag_to_team.get(winner_tag)
loser_team_dict = tag_to_team.get(loser_tag)
if winner_team_dict and loser_team_dict:
return winner_team_dict, loser_team_dict
async def process_match_summaries(new_games: List[Dict[str, Any]]) -> None:
"""Create or update match_summary records for processed games.
Stores session_id, map_name, endtime_unix, winning_sq, losing_sq,
winning_team_json, and losing_team_json. Runs schema migration for
any missing columns on first call.
Args:
new_games: List of replay dicts containing match details.
"""
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn:
# Fresh DBs
await conn.execute("""
CREATE TABLE IF NOT EXISTS match_summary (
session_id TEXT PRIMARY KEY,
map_name TEXT,
endtime_unix INTEGER,
winning_sq TEXT,
losing_sq TEXT,
winning_team_json TEXT,
losing_team_json TEXT
)
""")
# Migrate (add any missing columns)
cols = {r[1] for r in await conn.execute_fetchall("PRAGMA table_info(match_summary)")}
wanted = {
"session_id", "map_name", "endtime_unix", "winning_sq", "losing_sq",
"winning_team_json", "losing_team_json", "game_type", "mission_mode"
}
for col in (wanted - cols):
await conn.execute(f"ALTER TABLE match_summary ADD COLUMN {col} TEXT")
if "received_unix" not in cols:
await conn.execute("ALTER TABLE match_summary ADD COLUMN received_unix INTEGER")
if "winning_clan_id" not in cols:
await conn.execute("ALTER TABLE match_summary ADD COLUMN winning_clan_id INTEGER")
if "losing_clan_id" not in cols:
await conn.execute("ALTER TABLE match_summary ADD COLUMN losing_clan_id INTEGER")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ms_winning_clanid ON match_summary(winning_clan_id)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ms_losing_clanid ON match_summary(losing_clan_id)"
)
await conn.commit()
# One-time backfill: older rows embed the gamemode in map_name as a
# "[Conquest #1] <map>" prefix (sometimes with a leading space). Split
# that into mission_mode and a clean map_name. Idempotent — only touches
# rows where mission_mode is unset and the prefix is still present.
await conn.execute("""
UPDATE match_summary
SET mission_mode = TRIM(SUBSTR(TRIM(map_name), 2, INSTR(TRIM(map_name), ']') - 2)),
map_name = LTRIM(SUBSTR(TRIM(map_name), INSTR(TRIM(map_name), ']') + 1))
WHERE TRIM(map_name) LIKE '[%]%'
AND (mission_mode IS NULL OR mission_mode = '')
""")
await conn.commit()
rows: List[Tuple[str, str, int, str, str, bytes, bytes, str, str, Optional[int], Optional[int], Optional[int]]] = []
for game in new_games:
session_id: str = game.get("sessionIdHex") or ""
if not session_id:
continue
received_raw = game.get("receivedTime")
received_unix: Optional[int] = int(received_raw) if received_raw else None
# Load replay JSON
replay_path = replay_data_path(session_id)
try:
raw = await asyncio.to_thread(replay_path.read_bytes)
replay_data = json.loads(gzip.decompress(raw))
except (FileNotFoundError, OSError, json.JSONDecodeError):
continue
# Translate vehicle names & sanitize
translate = LangTableReader("English")
for team in replay_data.get("teams", []):
for p in team.get("players", []):
veh = p.get("vehicle")
if veh:
p["vehicle_new"] = translate.get_translate(f"{veh}")
else:
p["vehicle"] = "DISCONNECTED"
p["vehicle_new"] = "DISCONNECTED"
# Map fields
map_name: str = str(game.get("missionName", "") or "")
endtime_unix: int = int(game.get("endTime", 0) or 0)
# Winner / loser
wl = _find_winner_loser(replay_data)
if not wl:
continue
winner_team, loser_team = wl
winning_sq = str(winner_team.get("squadron") or "")
losing_sq = str(loser_team.get("squadron") or "")
winning_clan_id = _resolve_clan_id_for_pgh(winning_sq)
losing_clan_id = _resolve_clan_id_for_pgh(losing_sq)
rows.append((
session_id,
str(map_name),
int(endtime_unix),
winning_sq,
losing_sq,
compress_json(winner_team, ensure_ascii=False, separators=(",", ":")),
compress_json(loser_team, ensure_ascii=False, separators=(",", ":")),
str(replay_data.get("type", "")),
str(replay_data.get("mode", "")),
received_unix,
winning_clan_id,
losing_clan_id,
))
if not rows:
return
# Upsert (one row per session)
try:
await conn.executemany("""
INSERT INTO match_summary
(session_id, map_name, endtime_unix, winning_sq, losing_sq,
winning_team_json, losing_team_json, game_type, mission_mode,
received_unix, winning_clan_id, losing_clan_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
map_name = excluded.map_name,
endtime_unix = excluded.endtime_unix,
winning_sq = excluded.winning_sq,
losing_sq = excluded.losing_sq,
winning_team_json = excluded.winning_team_json,
losing_team_json = excluded.losing_team_json,
game_type = excluded.game_type,
mission_mode = excluded.mission_mode,
received_unix = COALESCE(excluded.received_unix, match_summary.received_unix),
winning_clan_id = COALESCE(excluded.winning_clan_id, match_summary.winning_clan_id),
losing_clan_id = COALESCE(excluded.losing_clan_id, match_summary.losing_clan_id)
""", rows)
await conn.commit()
except Exception as e:
logging.error(f"(MATCH-SUMMARY) DB upsert failed: {e}")