""" botscript.py Main Discord bot module. Defines the MyBot class and all slash commands including squadron comparison, player statistics, leaderboards, notifications, metadata management, translations, and administrative functions with interactive UI components. """ # Standard Library Imports import asyncio import gzip import json import logging import math import os import re import time as time_module import traceback from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Awaitable, Callable, Dict, List, Optional, cast # Third-Party Library Imports import aiofiles import aiosqlite import deepl import discord import matplotlib matplotlib.use('Agg') # Use non-interactive backend import matplotlib.cm as cm # noqa: E402 import matplotlib.dates as mdates # noqa: E402 import matplotlib.pyplot as plt # noqa: E402 import numpy as np from discord import Color, Embed, app_commands from discord.ext import commands from discord.ui import Select, View, button from dotenv import load_dotenv import matplotlib.patheffects as path_effects # noqa: E402 from matplotlib.collections import LineCollection from matplotlib.colors import TwoSlopeNorm # Local Module Imports (relative imports within BOT package) from . import utils from .analytics import ( get_map_stats, get_comp_analysis, get_player_consistency, get_time_performance, get_matchup_history, ) from .autologging import init_players_db, load_replay_data_from_disk, build_scoreboard_view from data_parser import LangTableReader, count_unit_types, get_unit_type_abbrev, normalize_name from shared_store import get_linked_uid, save_player_link from .game_api import ( ClanInfoError, obtain_clan_info_api, obtain_clan_new_points, obtain_clans_leaderboard, ) from .health import init_health, get_health_snapshot, get_recent_ttl_stats from .utils import t, guild_lang from .lux_apis import fetch_replay_by_id from .meta_manager import ( add_player_to_guild_meta, bulk_add_squadron_players_to_guild_meta, create_or_update_guild, get_guild_meta_players, get_guild_settings, get_squadron_owner, init_meta_db, refresh_guild_player_vehicles, remove_player_from_guild_meta, search_guild_meta_by_vehicle, transfer_squadron_to_guild, update_guild_settings, update_squadron_password, validate_squadron_password, ) from .scoreboard import create_scoreboard from .stack_manager import register_commands as register_stack_commands from .tasks import start_all_tasks from . import utils as _utils from .utils import ( STORAGE_DIR, ICONS_DIR, SQ_BATTLES_DB_PATH, SQUADRONS_DB_PATH, COMMAND_DATA_DB_PATH, DISCORD_SKU_ID_STANDARD, ENTITLEMENTS_DB_PATH, TOKEN, DEFAULT_FOOTER_CAT, compress_json, decompress_json, init_game_cache, init_vehicle_translation_cache, invalidate_entitled_guilds_cache, is_admin, is_blacklisted, gate_entitle, is_guild_entitled, get_guild_tier, permission_fail, refresh_entitled_guilds, tier_cap, tier_enforcement_active, tier_allows_wildcard, TIER_ORDER, DISCORD_SKU_ID_PRO, DISCORD_SKU_ID_MAX, enabled_pref_keys_for, enabled_non_wildcard_keys_for, allowed_pref_keys_for, is_notif_enabled, WILDCARD_KEYS, resolve_clan, resolve_clan_id, resolve_clans, get_guild_squadron, load_guild_preferences, save_guild_preferences, load_features, save_features, load_json, write_json, set_bot, esc, init_command_stats_db, collect_command_stats, command_locale, is_dev_team, LocaleJsonTranslator, COMP_FREE_UNTIL_TS, COMP_LIMIT_PER_TIMESLOT, COMP_LIMIT_PER_USER_PER_TIMESLOT, get_current_timeslot_start_ts, get_comp_usage_in_timeslot, get_comp_usage_in_timeslot_by_user, get_entitled_guild_ids, SQB_SLOTS_POSTED, RECAP_LANGS, RecapError, get_player_recap, get_seasons, get_squadron_recap, replay_session_dir, ) from .wl import wl_bootstrap # Load environment variables from .env file load_dotenv() logging.basicConfig(level=logging.INFO) logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("hpack").setLevel(logging.WARNING) logging.getLogger("deepl").setLevel(logging.WARNING) intents = discord.Intents.default() intents.message_content = False intents.reactions = True intents.messages = True class MyBot(commands.Bot): """Custom Discord bot subclass for SRE Bot. Extends commands.Bot with a sync guard to prevent duplicate slash-command tree syncs on reconnect. """ def __init__(self): """Initialize the bot with default prefix and configured intents.""" super().__init__(command_prefix='~', intents=intents) self.synced = False async def setup_hook(self): """Sync the slash-command tree with Discord on first startup.""" await self.tree.set_translator(LocaleJsonTranslator()) await self.tree.sync() self.synced = True bot = MyBot() set_bot(bot) # Register bot globally for other modules register_stack_commands(bot) @bot.event async def on_ready(): """Handle bot startup: write guild report, refresh presence, init caches, and start tasks.""" GUILD_TOTAL = len(bot.guilds) out_path = STORAGE_DIR / "GUILD_REPORT.txt" with out_path.open("w", encoding="utf-8") as f: f.write(f"We have logged in as {bot.user} in the following Guilds:\n\n") for guild in bot.guilds: created = guild.created_at.strftime("%Y-%m-%d") f.write( f" - {guild.name} (id: {guild.id}) | Members: {guild.member_count} | Created: {created}\n" ) logging.info(f"Guild report written to {out_path.resolve()}") logging.info(f' - Total Guilds: {GUILD_TOTAL}') await _refresh_presence() if not bot.synced: await bot.tree.sync() bot.synced = True try: await init_game_cache() logging.info("Initialized game cache . . .") except Exception as e: logging.error(f"Error initializing game cache in startup: {e}") try: await init_vehicle_translation_cache() except Exception as e: logging.error(f"Error initializing vehicle translation cache in startup: {e}") try: await refresh_entitled_guilds(force=True) logging.info("Initialized entitlements cache") except Exception as e: logging.error(f"Error initializing entitlements cache: {e}") try: await cast(Callable[[], Awaitable[None]], init_meta_db)() logging.info("Initialized Meta.db . . .") except Exception as e: logging.error(f"Error initializing Meta.db in startup: {e}") try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn: await init_players_db(conn) logging.info("Initialized sq_battles.db . . .") except Exception as e: logging.error(f"Error initializing sq_battles.db in startup: {e}") try: await init_command_stats_db() except Exception as e: logging.error(f"Error initializing COMMAND_DATA.db in startup: {e}") try: # Initialize health monitoring init_health(started_at=time_module.time(), guild_count=GUILD_TOTAL) # Start all background tasks from tasks.py await cast(Callable[[], Awaitable[None]], start_all_tasks)() # Start the W/L queue consumer wl_bootstrap() logging.info("Engines 1-3 are a go . . .") logging.info("We have liftoff ! ! !") except Exception as e: logging.error(f"Error starting tasks in startup: {e}") @bot.event async def on_guild_join(guild): """Handle joining a new guild: update presence and send setup hint. Args: guild: The Discord guild the bot just joined. """ GUILD_TOTAL = len(bot.guilds) logging.info(f"Joined new guild: {guild.name} (id: {guild.id})") logging.info(f"Updated total guilds: {GUILD_TOTAL}") try: await _refresh_presence() except Exception as e: logging.error(f"Error changing bot status after guild join: {e}") # Send setup hint to system channel or first available text channel try: target = guild.system_channel if not target: for ch in guild.text_channels: if ch.permissions_for(guild.me).send_messages: target = ch break if target: lang = 'en' embed = Embed( title=t(lang, "events.guild_join_title"), description=t(lang, "events.guild_join_desc"), color=Color.blue() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await target.send(embed=embed) except Exception as e: logging.error(f"Error sending setup hint to guild {guild.id}: {e}") @bot.event async def on_guild_remove(guild): """Handle being removed from a guild: update presence. Args: guild: The Discord guild the bot was removed from. """ GUILD_TOTAL = len(bot.guilds) logging.info(f"Removed from guild: {guild.name} (id: {guild.id})") logging.info(f"Updated total guilds: {GUILD_TOTAL}") try: await _refresh_presence() except Exception as e: logging.error(f"Error changing bot status after guild removal: {e}") @bot.event async def on_entitlement_create(entitlement): """Invalidate the entitlement cache when a Discord SKU subscription is created.""" invalidate_entitled_guilds_cache() logging.info(f"[PREMIUM] Discord entitlement created for guild {entitlement.guild_id}, cache invalidated") async def _refresh_presence(): """Update the bot's Discord presence to show the current guild count.""" logging.info("Refreshing status . . .") GUILD_TOTAL = len(bot.guilds) await bot.change_presence( activity=discord.Activity( type=discord.ActivityType.playing, name=f"Playing War Thunder in {GUILD_TOTAL} servers!" ) ) async def squadron_autocomplete( interaction: discord.Interaction, current: str, ) -> List[discord.app_commands.Choice[str]]: """Autocomplete for squadron short names. Shared across all squadron-param commands.""" try: async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: await db.create_function("ulower", 1, str.lower) if not current: async with db.execute( """SELECT short_name, long_name FROM squadrons_data WHERE short_name IS NOT NULL ORDER BY position ASC NULLS LAST LIMIT 25""" ) as cursor: rows = await cursor.fetchall() else: search = f"%{current}%" async with db.execute( """SELECT short_name, long_name FROM squadrons_data WHERE short_name IS NOT NULL AND (ulower(short_name) LIKE ulower(?) OR ulower(long_name) LIKE ulower(?) OR ulower(tag_name) LIKE ulower(?)) ORDER BY CASE WHEN ulower(short_name) = ulower(?) THEN 0 WHEN ulower(short_name) LIKE ulower(?) THEN 1 ELSE 2 END, position ASC NULLS LAST LIMIT 25""", (search, search, search, current, f"{current}%") ) as cursor: rows = await cursor.fetchall() return [ discord.app_commands.Choice( name=row[0], value=row[0] ) for row in rows ] except Exception: return [] @is_blacklisted() @bot.tree.command(name='comp', description=command_locale('Find the last known comps for a given team', "commands.comp.description")) @app_commands.describe(squadron_short=command_locale('The shortname of the enemy team', "commands.comp.squadron_short")) @discord.app_commands.autocomplete(squadron_short=squadron_autocomplete) async def comp(interaction: discord.Interaction, squadron_short: str): """Show the last known team compositions for a squadron. Loads comp data from the COMPS JSON directory, filters to comps seen in the last hour, sorts by recency, and displays each comp with player-vehicle lists and unit-type notation (e.g. 2F / 1T / 1AA). Args: interaction: The Discord interaction. squadron_short: Short name of the enemy team. """ guild = interaction.guild if guild is None: return lang = await guild_lang(guild.id) await interaction.response.defer() # ── Comp usage limit (after free period) ────────────────────────── comp_footer = DEFAULT_FOOTER_CAT now_ts_check = int(time_module.time()) if now_ts_check >= COMP_FREE_UNTIL_TS: slot_start = get_current_timeslot_start_ts() if slot_start is not None: entitled = await is_guild_entitled(guild.id) if not entitled: used_server = await get_comp_usage_in_timeslot(guild.id, slot_start) used_user = await get_comp_usage_in_timeslot_by_user( interaction.user.id, slot_start, exclude_guild_ids=get_entitled_guild_ids(), ) logging.info( "[COMP-USAGE] guild=%s user=%s squadron=%s used_server=%s/%s used_user=%s/%s slot_start=%s entitled=%s", guild.id, interaction.user.id, squadron_short, used_server, COMP_LIMIT_PER_TIMESLOT, used_user, COMP_LIMIT_PER_USER_PER_TIMESLOT, slot_start, entitled, ) if used_user >= COMP_LIMIT_PER_USER_PER_TIMESLOT: logging.warning( "[COMP-LIMIT] guild=%s user=%s squadron=%s scope=user used_user=%s/%s slot_start=%s action=blocked", guild.id, interaction.user.id, squadron_short, used_user, COMP_LIMIT_PER_USER_PER_TIMESLOT, slot_start, ) embed_limit = discord.Embed( title=t(lang, "comp.limit_reached_title"), description=t( lang, "comp.user_limit_reached_desc", limit=COMP_LIMIT_PER_USER_PER_TIMESLOT, ), color=discord.Color.red() ) embed_limit.set_footer( text=t( lang, "comp.user_remaining_footer", remaining=0, limit=COMP_LIMIT_PER_USER_PER_TIMESLOT, ) ) return await interaction.followup.send(embed=embed_limit) if used_server >= COMP_LIMIT_PER_TIMESLOT: logging.warning( "[COMP-LIMIT] guild=%s user=%s squadron=%s scope=server used_server=%s/%s slot_start=%s action=blocked", guild.id, interaction.user.id, squadron_short, used_server, COMP_LIMIT_PER_TIMESLOT, slot_start, ) embed_limit = discord.Embed( title=t(lang, "comp.limit_reached_title"), description=t(lang, "comp.limit_reached_desc", limit=COMP_LIMIT_PER_TIMESLOT), color=discord.Color.red() ) embed_limit.set_footer(text=t(lang, "comp.remaining_footer", remaining=0, limit=COMP_LIMIT_PER_TIMESLOT)) return await interaction.followup.send(embed=embed_limit) user_remaining = COMP_LIMIT_PER_USER_PER_TIMESLOT - used_user - 1 server_remaining = COMP_LIMIT_PER_TIMESLOT - used_server - 1 logging.info( "[COMP-REMAINING] guild=%s user=%s squadron=%s user_remaining=%s/%s server_remaining=%s/%s slot_start=%s", guild.id, interaction.user.id, squadron_short, user_remaining, COMP_LIMIT_PER_USER_PER_TIMESLOT, server_remaining, COMP_LIMIT_PER_TIMESLOT, slot_start, ) comp_footer = t( lang, "comp.remaining_footer_combined", user_remaining=user_remaining, user_limit=COMP_LIMIT_PER_USER_PER_TIMESLOT, server_remaining=server_remaining, server_limit=COMP_LIMIT_PER_TIMESLOT, ) squadron_short = squadron_short.upper() # ── Avg TTL across the last 20 games globally ───────────────────── # TTL = received_unix - endtime_unix: how long after a match ended # the result was ingested. Not squadron-scoped — this is a server-wide # signal, so when Gaijin is slow we can explain why no "recent" comps # show up: the games we have are simply older than the freshness cap. avg_ttl_seconds: Optional[int] = None ttl_sample_size = 0 try: ttl_stats = await get_recent_ttl_stats(limit=20) avg_ttl_seconds = ttl_stats["avg_delay"] ttl_sample_size = ttl_stats["sample_size"] except Exception: logging.exception("(COMP-CMD) Failed to compute TTL stats") if avg_ttl_seconds is not None: a_min, a_sec = divmod(avg_ttl_seconds, 60) if avg_ttl_seconds > 3600: ttl_icon = " 🚨" elif avg_ttl_seconds > 600: ttl_icon = " ⚠️" else: ttl_icon = "" ttl_footer = f"Avg TTL (last {ttl_sample_size}): {a_min}m {a_sec:02d}s{ttl_icon}" comp_footer = f"{comp_footer}\n{ttl_footer}" if comp_footer else ttl_footer comp_dir = STORAGE_DIR / "COMPS" squadron_key = re.sub(r'^[^A-Za-z0-9]+|[^A-Za-z0-9]+$', '', squadron_short).upper() or squadron_short.upper() squad_file = comp_dir / f"{squadron_key}.json" if not squad_file.exists(): return await interaction.followup.send( embed=discord.Embed( title=t(lang, "comp.not_found_title"), description=t(lang, "comp.not_found_desc", squadron=squadron_short), color=discord.Color.red() ) ) # Load all comps try: async with aiofiles.open(squad_file, "r", encoding="utf-8") as f: comps_data = json.loads(await f.read()) logging.info(f"(COMP-CMD) Loaded {len(comps_data)} comps for {squadron_short}") except Exception as e: logging.error(f"(COMP-CMD) Failed to load comp file for {squadron_short}: {e}") return await interaction.followup.send( embed=discord.Embed( title=t(lang, "comp.error_loading_title"), description=t(lang, "comp.error_loading_desc", error=e), color=discord.Color.red() ) ) # Thresholds # Normal freshness window is 1h. When the server-wide avg TTL is high # (>50m), recently played comps may not have been ingested yet — extend # to 90m so users still see something, and flag any field past 60m with # an alarm icon on the age line. base_threshold_seconds = 3600 # 1 hour extended_threshold_seconds = 5400 # 90 minutes ttl_extended = avg_ttl_seconds is not None and avg_ttl_seconds > 3000 threshold_seconds = extended_threshold_seconds if ttl_extended else base_threshold_seconds now_ts = int(time_module.time()) embed = discord.Embed( title=t(lang, "comp.title", squadron=squadron_short), description=t(lang, "comp.desc", minutes=threshold_seconds // 60), color=discord.Color.blurple() ) embed.set_footer(text=comp_footer) added_any = False comp_index = 1 # <- start numbering here # Sort by most recent (upd or reg) for comp_key, comp in sorted( comps_data.items(), key=lambda kv: kv[1].get("upd") or kv[1].get("reg", 0), reverse=True ): logging.info(f"(COMP-CMD) Processing {comp_key} for {squadron_short}") reg_ts = comp.get("reg", 0) upd_ts = comp.get("upd", 0) last_seen_ts = upd_ts or reg_ts age = now_ts - last_seen_ts # Skip if too old if age > threshold_seconds: continue # Discord‐style relative timestamps reg_str = f"" last_str = f"" # Build player list block with padding players = comp.get("Players", []) if players: _type_order = {"F": 0, "B": 1, "H": 2, "L": 3, "T": 4, "AA": 5, "?": 6} players = sorted(players, key=lambda p: _type_order.get(get_unit_type_abbrev(p.get("vehicle_internal")), 6)) max_nick_len = max(len(p["nick"]) for p in players) lines = [] for p in players: try: vehicle_raw = p.get("vehicle") vehicle = normalize_name(vehicle_raw) or "DISCONNECTED" lines.append(f"{p['nick']:<{max_nick_len}} | {vehicle}") except Exception as e: logging.error(f"(COMP-CMD) Error processing player {p.get('nick', 'UNKNOWN')} in {comp_key}: {e}, player data: {p}") lines.append(f"{p.get('nick', 'UNKNOWN'):<{max_nick_len}}: ERROR") block = "```\n" + "\n".join(lines) + "\n```" else: block = t(lang, "comp.no_players_recorded") # Build comp notation if players: vehicles = [] try: for p in players: v_internal = p.get("vehicle_internal") if v_internal is None: logging.warning(f"(COMP-CMD) Player {p.get('nick', 'UNKNOWN')} missing vehicle_internal in {comp_key}, player data: {p}") vehicles.append(v_internal) notation_list = count_unit_types(vehicles) except Exception as e: logging.error(f"(COMP-CMD) Error building comp notation for {comp_key}: {e}, vehicles: {vehicles}") notation_list = {} comp_order = [ ("F", "Fighters"), ("B", "Bombers"), ("H", "Helicopters"), ("L", "Light"), ("T", "Tanks"), ("AA", "AA"), ("?", "?") ] summary_parts = [ f"{notation_list.get(code, 0)}{code}" for code, _ in comp_order if notation_list.get(code, 0) > 0 ] comp_notation = " / ".join(summary_parts) or "None" else: comp_notation = "None" comp_key_str = str(comp_key) if comp_key is not None else "" comp_key_str = comp_key_str.replace("COMP", "") squad_key = f"({comp_key_str})" comp_title = t(lang, "comp.comp_title", index=comp_index) comp_index += 1 if age > base_threshold_seconds: age_icon = ' 🚨' elif age > 1200: age_icon = ' ⚠️' else: age_icon = '' embed.add_field( name=comp_title, value=( #f"SQ Number {squad_key}\n" t(lang, "comp.last_seen_label", timestamp=last_str, warning=age_icon) + "\n" + t(lang, "comp.comp_label", notation=comp_notation) + "\n" + f"{block}" ), inline=False ) added_any = True if not added_any: no_embed = discord.Embed( title=t(lang, "comp.no_recent_title"), description=t(lang, "comp.no_recent_desc", minutes=threshold_seconds // 60), color=discord.Color.red() ) no_embed.set_footer(text=comp_footer) return await interaction.followup.send(embed=no_embed) await collect_command_stats(interaction) await interaction.followup.send(embed=embed) @comp.error async def comp_perm_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @is_admin() @gate_entitle("standard") @bot.tree.command( name="quick-log", description=command_locale("Quickly set an alarm for this squadron in this channel", "commands.quick_log.description") ) @app_commands.describe( squadron_name=command_locale("The SHORT name of the squadron to monitor", "commands.quick_log.squadron_name"), type=command_locale("Choose Logs, Points, Player Leave, Leaderboard, Weekly BR, or Both", "commands.quick_log.type") ) @app_commands.choices(type=[ app_commands.Choice(name=command_locale("Logs", "commands.quick_log.choice_logs"), value="Logs"), app_commands.Choice(name=command_locale("Points", "commands.quick_log.choice_points"), value="Points"), app_commands.Choice(name=command_locale("Player Leave", "commands.quick_log.choice_player_leave"), value="Leave"), app_commands.Choice(name=command_locale("Leaderboard", "commands.quick_log.choice_leaderboard"), value="Leaderboard"), app_commands.Choice(name=command_locale("Weekly BR", "commands.quick_log.choice_weekly_br"), value="WeeklyBR"), app_commands.Choice(name=command_locale("Both Logs and Points", "commands.quick_log.choice_both"), value="Both"), ]) @discord.app_commands.autocomplete(squadron_name=squadron_autocomplete) async def quick_log( interaction: discord.Interaction, squadron_name: str = "", type: str = "Logs" ): """Set a Logs, Points, Player Leave, Leaderboard, Weekly BR, or Both alarm for a squadron. Resolves the squadron name, validates the alarm type, writes the channel preference to the guild's preferences JSON, and confirms with a premium reminder if applicable. Args: interaction: The Discord interaction. squadron_name: Short name of the squadron to monitor (empty = wildcard for Logs and Weekly BR). type: Alarm type -- Logs, Points, Player Leave, Leaderboard, Weekly BR, or Both. """ await collect_command_stats(interaction) # Normalize and validate type type_normalized = type.title() if type.lower() != "weeklybr" else "WeeklyBR" type_lower = type_normalized.lower() is_leaderboard = type_lower in ("leaderboard", "leaderboards") is_both = type_lower in ("both", "all") is_weekly_br = type_lower == "weeklybr" is_leave = type_lower in ("leave", "player leave", "player_leave") lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' if is_leaderboard: alarm_type = "Leaderboard" elif is_both: alarm_type = "Both" elif is_weekly_br: alarm_type = "WeeklyBR" elif is_leave: alarm_type = "Leave" else: if type_normalized not in ("Logs", "Points", "Leave"): await interaction.response.send_message( t(lang, "quick_log.invalid_type"), ephemeral=True ) return alarm_type = type_normalized # Defer response to allow I/O await interaction.response.defer() guild_id = interaction.guild.id # type: ignore guild_name = interaction.guild.name # type: ignore # Load existing preferences or initialize preferences = await load_guild_preferences(guild_id) logging.info(f"Loaded preferences for guild {guild_id}") # At the top of quick_log (after loading prefs etc.) guild_id = str(interaction.guild_id) # `pref_key` is the dict key written into preferences. Real squadrons key # by str(clan_id) so a future rename doesn't orphan the entry; wildcard / # leaderboard slots keep their literal special string. # `display_name` is what we show to the user. resolved_clan: Optional[Dict[str, Any]] = None short_for_entry: Optional[str] = None if is_leaderboard: long_name = "Global" pref_key = "Global" display_name = "Global" elif is_weekly_br and not squadron_name: # Weekly BR allows empty squadron -> wildcard (top-20 mode) long_name = "everything" pref_key = "everything" display_name = "everything" else: # Logs, Points, Player Leave, Both, and Weekly-BR-with-squadron require a name if not squadron_name: await interaction.followup.send( t(lang, "quick_log.squadron_required"), ephemeral=True ) return sq_lower = squadron_name if sq_lower in ("*", "everything", "all"): if alarm_type not in ("Logs", "WeeklyBR"): await interaction.followup.send( t(lang, "quick_log.wildcard_logs_only"), ephemeral=True ) return long_name = "everything" pref_key = "everything" display_name = "everything" else: clan = await resolve_clan(short=sq_lower) if not clan or clan.get("long_name") == "": await interaction.followup.send( t(lang, "quick_log.squadron_not_resolved", squadron=squadron_name), ephemeral=True ) return resolved_clan = clan long_name = str(clan.get("long_name") or "") short_for_entry = str(clan.get("short_name") or "") or None clan_id_val = clan.get("clan_id") pref_key = str(clan_id_val) if clan_id_val else long_name display_name = long_name # Determine channel mention channel_id = interaction.channel.id # type: ignore channel_mention = f"<#{channel_id}>" # Tier gating — blocks wildcards on Standard and rejects new additions past the per-notif cap. # Channel swaps on already-enabled entries are allowed (no counted delta). # Free/unentitled users are NOT blocked here — the autologging premium gate already # stops dispatch for them, and letting them configure prefs up-front is better UX. tier = await get_guild_tier(interaction.guild_id) if interaction.guild_id else None if tier_enforcement_active() and tier is not None: is_wildcard = long_name.lower() in WILDCARD_KEYS # Wildcard tier-gate doesn't apply to Leaderboard or Weekly BR # (both are uncapped + free, like /schedule). if is_wildcard and alarm_type not in ("Leaderboard", "WeeklyBR") \ and not tier_allows_wildcard(tier): wb_embed = discord.Embed( title=t(lang, "autolog.wildcard_blocked_title"), description=t(lang, "autolog.wildcard_blocked_desc", tier=(tier or "none").title(), notif="Logs"), color=discord.Color.orange(), ) wb_embed.set_footer(text=t(lang, "autolog.over_cap_footer")) await interaction.followup.send(embed=wb_embed, ephemeral=True) return if is_leaderboard or is_weekly_br: types_to_check: list[str] = [] # Both are uncapped elif is_both: types_to_check = ["Logs", "Points"] elif alarm_type in ("Logs", "Points"): types_to_check = [alarm_type] else: types_to_check = [] for nt in types_to_check: cap = tier_cap(tier, nt) if cap is None: continue # unlimited for this tier already_enabled = is_notif_enabled(preferences.get(pref_key, {}), nt) if already_enabled: continue # channel swap on existing entry — no new slot consumed # Wildcards (*, all, everything) don't count toward the cap if long_name.lower() in WILDCARD_KEYS: continue enabled_now = set(enabled_non_wildcard_keys_for(preferences, nt)) - {pref_key} if len(enabled_now) >= cap: oc_embed = discord.Embed( title=t(lang, "autolog.over_cap_title"), description=t(lang, "autolog.over_cap_desc", tier=(tier or "none").title(), notif=nt, cap=cap, squadron=display_name), color=discord.Color.orange(), ) oc_embed.set_footer(text=t(lang, "autolog.over_cap_footer")) await interaction.followup.send(embed=oc_embed, ephemeral=True) return # Set or overwrite the channel for this alarm type entry = preferences.setdefault(pref_key, {}) if isinstance(entry, dict): # Cache display fields on the entry so a stale squadrons_data lookup # later still has a name to show. if resolved_clan and long_name: entry["Long"] = long_name if short_for_entry: entry["Short"] = short_for_entry if is_both: entry["Logs"] = channel_mention entry["Points"] = channel_mention else: entry[alarm_type] = channel_mention # Save back to preferences file success = await save_guild_preferences(interaction.guild.id, preferences) # type: ignore if not success: await interaction.followup.send( t(lang, "quick_log.save_failed"), ephemeral=True ) return logging.info(f"Saved preferences for guild {guild_id}") # Premium reminder for log types logs_involved = is_both or alarm_type == "Logs" premium_note = "" if logs_involved and interaction.guild_id and not await is_guild_entitled(interaction.guild_id): premium_note = t(lang, "quick_log.premium_warning") # Confirmation message if is_leaderboard: await interaction.followup.send( t(lang, "quick_log.leaderboard_set"), ephemeral=True ) elif is_weekly_br: if long_name == "everything": await interaction.followup.send( t(lang, "quick_log.weekly_br_wildcard_set"), ephemeral=True ) else: await interaction.followup.send( t(lang, "quick_log.weekly_br_squadron_set", squadron=long_name), ephemeral=True ) elif is_both: await interaction.followup.send( t(lang, "quick_log.both_set", squadron=long_name, premium_note=premium_note), ephemeral=True ) else: await interaction.followup.send( t(lang, "quick_log.alarm_set", alarm_type=alarm_type, squadron=long_name, premium_note=premium_note), ephemeral=True ) logging.info( f"{guild_name} ({guild_id}) is now setting {alarm_type} alarm for " f"{long_name} in channel ID {channel_id}" ) @quick_log.error async def quick_log_error(interaction, error): await permission_fail(interaction, error) # ============================================================================ # /test-weekly-br -- preview the Weekly BR Report for the current/most-recent # BR window. Admin-only. Always sends the wildcard embeds (top-20 + top-5 # players each); if a squadron is provided, also sends the per-squadron embed # (top-15 players for that squadron). Useful for previewing the format # without waiting for the scheduled fire time. # ============================================================================ def _pick_test_br_window() -> Optional[Dict[str, Any]]: """Return the most recently ended BR window (mirrors the real scheduled task).""" schedule_path = Path(__file__).parent / "SCHEDULE.json" try: with open(schedule_path, "r", encoding="utf-8") as fp: schedule = json.load(fp) except Exception as e: logging.error("[TEST-WBR] Failed to read SCHEDULE.json: %s", e) return None now_ts = int(time_module.time()) most_recent_past = None for entry in schedule: try: e = int(entry["end"]) except (KeyError, TypeError, ValueError): continue if e < now_ts and (most_recent_past is None or e > int(most_recent_past["end"])): most_recent_past = entry return most_recent_past @is_blacklisted() @bot.tree.command( name="test-weekly-br", description="[DEV] Preview the Weekly BR Report for the current/most-recent BR window", ) @app_commands.describe( squadron_name="Optional: also send the per-squadron variant for this squadron", ) @discord.app_commands.autocomplete(squadron_name=squadron_autocomplete) async def test_weekly_br( interaction: discord.Interaction, squadron_name: str = "", ): """Preview Weekly BR Report embeds in the current channel.""" from .task_executors import ( _build_squadron_embed, _build_wildcard_embeds, _load_squadron_name_lookup, ) from .weekly_br_elo import ( squadron_report_for_variants, top_n_squadrons_with_top_k_players, ) await collect_command_stats(interaction) if not await is_dev_team(interaction): await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) return await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else "en" window = _pick_test_br_window() if window is None: await interaction.followup.send( "Could not pick a BR window from SCHEDULE.json.", ephemeral=True, ) return start_ts = int(window["start"]) end_ts = int(window["end"]) # Build wildcard payload (top 20 squadrons x top 5 players each) wildcard_payload = await top_n_squadrons_with_top_k_players( start_ts, end_ts, n=20, k=5 ) if not wildcard_payload: await interaction.followup.send( f"No squadron activity in the {window['max_br']} BR window " f"() yet — nothing to preview.", ephemeral=True, ) return name_lookup = await _load_squadron_name_lookup() channel = interaction.channel if not isinstance(channel, (discord.TextChannel, discord.Thread)): await interaction.followup.send( "This command must be run in a regular text channel or thread.", ephemeral=True, ) return # Wildcard preview (always) wildcard_embeds = _build_wildcard_embeds(window, wildcard_payload, name_lookup, lang) if wildcard_embeds: await channel.send(embeds=wildcard_embeds) # Per-squadron preview (only if squadron_name provided) sq_section_status = "" if squadron_name: clan = await resolve_clan(short=squadron_name) if not clan or clan.get("long_name") == "": sq_section_status = ( f"⚠️ Squadron `{squadron_name}` could not be resolved — " "skipped per-squadron preview." ) else: sq_info = { "long_name": str(clan.get("long_name") or ""), "tag_name": str(clan.get("tag_name") or ""), "short_name": str(clan.get("short_name") or ""), "clan_id": clan.get("clan_id"), } variants = [sq_info["long_name"], sq_info["tag_name"], sq_info["short_name"]] sq_row, roster = await squadron_report_for_variants( start_ts, end_ts, variants, k=15 ) sq_embed = _build_squadron_embed(window, sq_info, sq_row, roster, lang) await channel.send(embed=sq_embed) summary = ( f"✅ Previewed Weekly BR Report for **{window['max_br']} BR** " f"()\n" f"• Wildcard: {len(wildcard_payload)} squadrons rendered\n" f"• Per-squadron: " + ("rendered above" if squadron_name and not sq_section_status else (sq_section_status or "skipped (no squadron_name)")) ) await interaction.followup.send(summary, ephemeral=True) @test_weekly_br.error async def test_weekly_br_error(interaction, error): await permission_fail(interaction, error) async def _diagnose_perms_logic(interaction: discord.Interaction, target_channel=None): """Core logic for autolog diagnostics. Called from the diagnose channel select.""" await interaction.response.defer(ephemeral=True) guild = interaction.guild if guild is None: await interaction.followup.send(t('en', "common.must_use_in_server"), ephemeral=True) return lang = await guild_lang(guild.id) guild_id = str(guild.id) channel = target_channel or interaction.channel if channel is None or not isinstance(channel, discord.abc.GuildChannel): await interaction.followup.send(t(lang, "common.could_not_resolve_channel"), ephemeral=True) return bot_member = guild.me lines: list[str] = [] # ── 1. Check bot permissions in this channel ── perms = channel.permissions_for(bot_member) perm_checks = { "View Channel": perms.view_channel, "Send Messages": perms.send_messages, "Attach Files": perms.attach_files, "Embed Links": perms.embed_links, } all_ok = all(perm_checks.values()) lines.append(t(lang, "diagnostics.channel_permissions_header", channel_id=channel.id)) for name, has in perm_checks.items(): lines.append(f" {'\u2705' if has else '\u274c'} {name}") if not all_ok: lines.append(t(lang, "diagnostics.perms_needed")) lines.append("") # ── 2. Check /set-squadron ── squadrons_json = await load_json(STORAGE_DIR / "SQUADRONS.json", {}) guild_sq = squadrons_json.get(guild_id, {}) if guild_sq: lines.append(t(lang, "diagnostics.server_squadron_header")) lines.append(t(lang, "diagnostics.server_squadron_short", short=guild_sq.get('SQ_ShortHand_Name', 'N/A'))) lines.append(t(lang, "diagnostics.server_squadron_long", long=guild_sq.get('SQ_LongHandName', 'N/A'))) else: lines.append(t(lang, "diagnostics.server_squadron_header")) lines.append(t(lang, "diagnostics.server_squadron_not_set")) lines.append("") # ── 3. Check preferences (this is what actually controls autologging) ── prefs = await load_guild_preferences(guild.id) lines.append(t(lang, "diagnostics.autolog_prefs_header")) if not prefs: lines.append(t(lang, "diagnostics.autolog_none_configured")) lines.append(t(lang, "diagnostics.autolog_setup_hint")) else: has_any_logs = False for key, cfg in prefs.items(): if utils.is_foreign_pref_entry(cfg): continue logs_chan = cfg.get("Logs", "") if not logs_chan or "DISABLED" in str(logs_chan).upper(): continue has_any_logs = True chan_id_match = re.search(r"\b(\d{17,19})\b", str(logs_chan)) chan_id = int(chan_id_match.group(1)) if chan_id_match else None # Check if channel is valid if chan_id: target_ch = bot.get_channel(chan_id) if target_ch is None: try: target_ch = await bot.fetch_channel(chan_id) except Exception: target_ch = None if target_ch and isinstance(target_ch, discord.abc.GuildChannel): target_perms = target_ch.permissions_for(bot_member) ch_ok = target_perms.send_messages and target_perms.attach_files icon = "\u2705" if ch_ok else "\u274c" status = "" if ch_ok else t(lang, "diagnostics.missing_send_attach") else: icon = "\u274c" status = t(lang, "diagnostics.channel_not_found") else: icon = "\u274c" status = t(lang, "diagnostics.invalid_channel_id") is_selected_channel = chan_id == channel.id here = t(lang, "diagnostics.selected_channel_tag") if is_selected_channel else "" lines.append(f" {icon} `{key}` -> <#{chan_id}>{here}{status}") if not has_any_logs: lines.append(t(lang, "diagnostics.autolog_no_logs_channels")) lines.append(t(lang, "diagnostics.autolog_enable_hint")) lines.append("") # ── 4. Premium / entitlement check ── lines.append(t(lang, "diagnostics.premium_status_header")) if await is_guild_entitled(guild.id): lines.append(t(lang, "diagnostics.premium_active")) else: lines.append(t(lang, "diagnostics.premium_not_subscribed")) lines.append(t(lang, "diagnostics.premium_autolog_required")) lines.append("") # ── Send ── embed = discord.Embed( title=t(lang, "diagnostics.title"), description="\n".join(lines), color=discord.Color.blue() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed, ephemeral=True) def _extract_pref_channel_id(raw: Any) -> Optional[int]: """Extract a Discord channel ID from a stored preference value.""" if raw is None: return None match = re.search(r"\b(\d{17,19})\b", str(raw)) return int(match.group(1)) if match else None def _format_pref_target_name(pref_key: str, settings: dict[str, Any]) -> str: """Prefer Short/Long squadron labels over raw preference keys.""" if pref_key.lower() in WILDCARD_KEYS or pref_key == "Global": return pref_key short_name = str(settings.get("Short") or "").strip() long_name = str(settings.get("Long") or "").strip() if short_name and long_name and short_name.lower() != long_name.lower(): return f"{short_name} - {long_name}" if short_name: return short_name if long_name: return long_name return pref_key def _configured_pref_targets(preferences: dict[str, Any]) -> list[dict[str, Any]]: """Return enabled channel targets from autolog preferences.""" targets: list[dict[str, Any]] = [] seen: set[tuple[str, str, int]] = set() for squadron, settings in preferences.items(): if not isinstance(settings, dict) or utils.is_foreign_pref_entry(settings): continue pref_key = str(squadron) display_name = _format_pref_target_name(pref_key, settings) for notif_type, raw in settings.items(): if notif_type in ("Short", "Long"): continue raw_s = str(raw or "") if not raw_s or "DISABLED" in raw_s.upper(): continue channel_id = _extract_pref_channel_id(raw_s) if channel_id is None: continue dedupe_key = (pref_key, str(notif_type), channel_id) if dedupe_key in seen: continue seen.add(dedupe_key) targets.append({ "squadron": pref_key, "display": display_name, "type": str(notif_type), "raw": raw_s, "channel_id": channel_id, "short": str(settings.get("Short") or ""), "long": str(settings.get("Long") or ""), }) return targets REPORTS_NOTIF_TYPE = "Reports" REPORTS_STORAGE_TYPES = ("Leaderboard", "WeeklyBR") def _notif_type_label(notif_type: str) -> str: if notif_type == REPORTS_NOTIF_TYPE: return "Reports" if notif_type == "WeeklyBR": return "Weekly BR" return str(notif_type) def _notif_types_for_management(notif_type: str) -> tuple[str, ...]: if notif_type == REPORTS_NOTIF_TYPE: return REPORTS_STORAGE_TYPES return (notif_type,) def _encode_management_value(squadron: str, notif_type: str) -> str: return f"{notif_type}::{squadron}" def _decode_management_value(value: str) -> tuple[str, str]: notif_type, squadron = value.split("::", 1) return notif_type, squadron def _management_pref_rows( preferences: dict[str, Any], notif_type: str ) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for squadron, settings in preferences.items(): if not isinstance(settings, dict) or utils.is_foreign_pref_entry(settings): continue for storage_type in _notif_types_for_management(notif_type): if storage_type in settings: rows.append({ "squadron": squadron, "settings": settings, "notif_type": storage_type, }) return rows def _perm_diag_embed( guild: discord.Guild, target: dict[str, Any], channel: Optional[discord.abc.GuildChannel], entitled: bool, ) -> discord.Embed: """Build a support-facing permission diagnostic embed for one pref target.""" channel_id = int(target["channel_id"]) embed = discord.Embed( title="Configured Channel Permissions", color=discord.Color.green() if channel and entitled else discord.Color.red(), ) embed.add_field(name="Server", value=f"{esc(guild.name)} (`{guild.id}`)", inline=False) embed.add_field( name="Preference", value=( f"**{esc(target['type'])}** for `{esc(target.get('display') or target['squadron'])}`\n" f"Preference key: `{esc(target['squadron'])}`\n" f"Stored value: `{esc(target['raw'])}`\n" f"Channel ID: `{channel_id}`" ), inline=False, ) embed.add_field( name="Entitlement Check", value=( "✅ Premium entitlement active for this server." if entitled else "❌ Premium entitlement is not active; autolog uploads are blocked." ), inline=False, ) if channel is None: embed.description = "The bot cannot resolve this configured channel in the target server." result_lines = ["❌ Channel not found or the bot has no access to see it."] if not entitled: result_lines.append("❌ Autolog dispatch is also blocked by missing premium entitlement.") embed.add_field( name="Result", value="\n".join(result_lines), inline=False, ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return embed perms = channel.permissions_for(guild.me) checks = { "View Channel": perms.view_channel, "Send Messages": perms.send_messages, "Attach Files": perms.attach_files, "Embed Links": perms.embed_links, "Read Message History": perms.read_message_history, } ok = all(checks.values()) embed.color = discord.Color.green() if ok and entitled else discord.Color.red() embed.add_field(name="Resolved Channel", value=f"{channel.mention} (`{channel.id}`)", inline=False) embed.add_field( name="Permission Check", value="\n".join(f"{'✅' if allowed else '❌'} {name}" for name, allowed in checks.items()), inline=False, ) embed.add_field( name="Autolog Result", value=( "❌ Autolog uploads will not run until this server has an active premium entitlement." if not entitled else ( "✅ Scoreboard uploads should work for this channel." if ok else "❌ Discord will reject scoreboard uploads until the missing channel permissions are fixed." ) ), inline=False, ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return embed class GuildPermTargetSelect(discord.ui.Select): """Dropdown of configured autolog channels for a target guild.""" def __init__(self, guild: discord.Guild, targets: list[dict[str, Any]], owner_id: int, page: int = 0): self.guild = guild self.targets = targets self.owner_id = owner_id self.page = page start = page * 25 page_targets = targets[start:start + 25] options: list[discord.SelectOption] = [] for idx, target in enumerate(page_targets, start=start): desc_bits = [str(target["channel_id"])] if target.get("squadron") and target.get("display") and target["squadron"] != target["display"]: desc_bits.append(f"key {target['squadron']}") options.append(discord.SelectOption( label=f"{target['type']}: {target.get('display') or target['squadron']}"[:100], description=" • ".join(desc_bits)[:100], value=str(idx), )) super().__init__( placeholder=f"Select configured channel ({page + 1}/{max(1, math.ceil(len(targets) / 25))})", min_values=1, max_values=1, options=options, ) async def callback(self, interaction: discord.Interaction): if interaction.user.id != self.owner_id: await interaction.response.defer() return target = self.targets[int(self.values[0])] channel_id = int(target["channel_id"]) channel = self.guild.get_channel(channel_id) if channel is None: try: fetched = await bot.fetch_channel(channel_id) channel = fetched if isinstance(fetched, discord.abc.GuildChannel) else None except Exception: channel = None entitled = await is_guild_entitled(self.guild.id) await interaction.response.send_message( embed=_perm_diag_embed(self.guild, target, channel, entitled), ephemeral=True, ) class GuildPermPageButton(discord.ui.Button): """Page button for the /view-guild-perms target selector.""" def __init__(self, label: str, delta: int): super().__init__(label=label, style=discord.ButtonStyle.secondary) self.delta = delta async def callback(self, interaction: discord.Interaction): view: GuildPermTargetView = self.view # type: ignore if interaction.user.id != view.owner_id: await interaction.response.defer() return view.page = max(0, min(view.max_page, view.page + self.delta)) view.refresh_items() await interaction.response.edit_message(view=view) class GuildPermTargetView(discord.ui.View): """Paginated configured-channel selector for /view-guild-perms.""" def __init__(self, guild: discord.Guild, targets: list[dict[str, Any]], owner_id: int, page: int = 0): super().__init__(timeout=300) self.guild = guild self.targets = targets self.owner_id = owner_id self.page = page self.max_page = max(0, math.ceil(len(targets) / 25) - 1) self.refresh_items() def refresh_items(self): self.clear_items() self.add_item(GuildPermTargetSelect(self.guild, self.targets, self.owner_id, self.page)) if self.max_page > 0: self.add_item(GuildPermPageButton("Previous", -1)) self.add_item(GuildPermPageButton("Next", 1)) @is_blacklisted() @bot.tree.command(name="view-guild-perms", description="[DEV] Diagnose configured autolog channel permissions for a server") @app_commands.describe(server_id="Discord server ID to inspect") async def view_guild_perms(interaction: discord.Interaction, server_id: str): """Support command for diagnosing autolog channels from any server/ticket.""" await collect_command_stats(interaction) if not await is_dev_team(interaction): await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) return await interaction.response.defer(ephemeral=True) if not server_id.isdigit() or not (17 <= len(server_id) <= 19): await interaction.followup.send(t("en", "dev.invalid_server_id"), ephemeral=True) return guild = bot.get_guild(int(server_id)) if guild is None: await interaction.followup.send(f"I am not in a server with ID `{esc(server_id)}`.", ephemeral=True) return preferences = await load_guild_preferences(guild.id) targets = _configured_pref_targets(preferences) if not targets: await interaction.followup.send( f"No enabled configured notification channels found for **{esc(guild.name)}** (`{guild.id}`).", ephemeral=True, ) return view = GuildPermTargetView(guild, targets, interaction.user.id) embed = discord.Embed( title="Select Configured Channel", description=( f"Server: **{esc(guild.name)}** (`{guild.id}`)\n" f"Configured enabled targets: `{len(targets)}`\n\n" "Select a channel from this server's stored preferences to diagnose the bot's effective permissions." ), color=discord.Color.blurple(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed, view=view, ephemeral=True) @is_blacklisted() @bot.tree.command(name="sq-info", description=command_locale("Fetch information about a squadron", "commands.sq_info.description")) @app_commands.describe(squadron=command_locale("The short name of the squadron", "commands.common.squadron_short")) @discord.app_commands.autocomplete(squadron=squadron_autocomplete) async def sq_info(interaction: discord.Interaction, squadron: str = ""): """Fetch and display squadron info including placement, total points, and member list. Resolves the squadron, calls the game API for current point data, looks up leaderboard placement, and builds a paginated embed with all members and their individual points. Args: interaction: The Discord interaction. squadron: Short name of the squadron (falls back to guild default). """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' try: clan = await get_guild_squadron(interaction.guild_id, squadron) except ValueError as e: embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) squadron_name = clan["long_name"] # Fetch squadron info and turn into embed sq_data = await obtain_clan_new_points(squadron_name) if sq_data: members: dict[str, dict[str, str | int]] = sq_data[0] total_points: int = sq_data[1] placement, _ = await get_current_squadron_placement(squadron_name, squadron or "") embed = discord.Embed( title=t(lang, "sq_info.title", squadron=squadron_name), color=discord.Color.green() ) embed.add_field(name=t(lang, "sq_info.placement_field"), value=f"#{placement}" if placement else "N/A", inline=True) embed.add_field(name=t(lang, "sq_info.total_points_field"), value=f"{total_points:,}", inline=True) embed.add_field(name=t(lang, "sq_info.total_members_field"), value=str(len(members)), inline=True) # Build full member list (all 128 possible members) lines: list[str] = [] for uid, info in members.items(): raw_nick: str = str(info.get("nick", "Unknown")) nick: str = esc(raw_nick) points: int = int(info.get("points", 0)) lines.append(f"**{nick}** — {points:,} pts") # Split into chunks so no field exceeds 1024 characters chunk = "" first = True for line in lines: if len(chunk) + len(line) + 1 > 1024: embed.add_field( name=t(lang, "sq_info.members_field") if first else "\u00A0", value=chunk.rstrip(), inline=False ) first = False chunk = "" chunk += line + "\n" if chunk: embed.add_field( name=t(lang, "sq_info.members_field") if first else "\u00A0", value=chunk.rstrip(), inline=False ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed) else: await interaction.followup.send(t(lang, "sq_info.fetch_failed"), ephemeral=True) @sq_info.error async def sq_info_error(interaction, error): await permission_fail(interaction, error) # Roster composition graph palette (separate from line-chart palette so it can # be retuned independently if categories grow). SQ_INFO_GRAPH_CATEGORY_COLORS = { 'core': '#5cb85c', # green — high games + WR ≥ 50% 'active': '#f0ad4e', # amber — high games + WR < 50% 'weak': '#d9534f', # red — below median games / no games } @is_blacklisted() @bot.tree.command( name="sq-info-graph", description=command_locale( "Show a roster composition graph by activity and WR (current season)", "commands.sq_info_graph.description", ), ) @app_commands.describe(squadron=command_locale("The short name of the squadron", "commands.common.squadron_short")) @discord.app_commands.autocomplete(squadron=squadron_autocomplete) async def sq_info_graph(interaction: discord.Interaction, squadron: str = ""): """Render a per-member WR bar chart, grouped into core / active / weak blocks. Pulls the current squadron roster the same way /sq-info does, then aggregates each member's games + wins from sq_battles.db within the current season's timestamp window. Members are bucketed by games-vs-median and WR-vs-50%, then drawn left-to-right in CORE → ACTIVE → WEAK order, sorted by games desc inside each bucket. Bar height = WR%. """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' try: clan = await get_guild_squadron(interaction.guild_id, squadron) except ValueError as e: embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) squadron_name = clan["long_name"] # Resolve the current in-progress season (we only score games inside its window). seasons = get_seasons() current_season_name: Optional[str] = None season_start = 0 season_end = 0 for name, rng in seasons.items(): if rng["status"] == "in_progress": current_season_name = name season_start = int(rng["start"]) season_end = int(rng["end"]) break if current_season_name is None: embed = discord.Embed( title=t(lang, "common.error_title"), description=t(lang, "sq_info_graph.no_active_season"), color=discord.Color.red(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) sq_data = await obtain_clan_new_points(squadron_name) if not sq_data: return await interaction.followup.send(t(lang, "sq_info.fetch_failed"), ephemeral=True) members: dict[str, dict[str, str | int]] = sq_data[0] if not members: embed = discord.Embed( title=t(lang, "common.error_title"), description=t(lang, "sq_info_graph.no_members", squadron=squadron_name), color=discord.Color.orange(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) uids = list(members.keys()) # Aggregate per-UID games + wins for the current season in a single query. stats: dict[str, dict[str, int]] = {} placeholders = ",".join("?" * len(uids)) try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: db.row_factory = aiosqlite.Row async with db.execute( f""" SELECT UID, COUNT(*) AS games, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) AS wins FROM player_games_hist WHERE UID IN ({placeholders}) AND endtime_unix BETWEEN ? AND ? GROUP BY UID """, [*uids, season_start, season_end], ) as cursor: async for row in cursor: stats[row["UID"]] = { "games": int(row["games"] or 0), "wins": int(row["wins"] or 0), } except Exception as e: logging.error(f"sq_info_graph DB query failed: {e}") return await interaction.followup.send( t(lang, "common.database_error", error=str(e)[:1500]), ephemeral=True, ) # Build per-member records. records: list[dict] = [] for uid in uids: s = stats.get(uid, {"games": 0, "wins": 0}) games = s["games"] wins = s["wins"] wr = (wins / games * 100.0) if games > 0 else 0.0 nick = str(members[uid].get("nick", "Unknown")) records.append({"uid": uid, "nick": nick, "games": games, "wins": wins, "wr": wr}) # Median games threshold computed only over members with at least one game, # so a roster of mostly-inactive members doesn't drop the bar to zero. active_games = [r["games"] for r in records if r["games"] >= 1] median_games = float(np.median(active_games)) if active_games else 0.0 # Percentile-based bucketing scales with the squadron's own WR distribution # rather than absolute thresholds. CORE = top 30% by WR and active enough, # ACTIVE = next slice up to top 45% with relaxed activity, WEAK = the rest. sq_total_games = sum(r["games"] for r in records) sq_total_wins = sum(r["wins"] for r in records) squadron_wr = (sq_total_wins / sq_total_games * 100.0) if sq_total_games > 0 else 50.0 n_total = len(records) core_rank_cutoff = max(1, int(n_total * 0.30)) if n_total > 0 else 0 active_rank_cutoff = max(core_rank_cutoff, int(n_total * 0.45)) if n_total > 0 else 0 # "Games around median" — a slightly relaxed activity floor for ACTIVE so # a top-WR member who plays a bit below median doesn't get dumped into WEAK. games_threshold_active = max(1.0, median_games * 0.7) # Rank members by WR desc; tiebreak by games desc so heavier players win ties. ranked = sorted(records, key=lambda r: (-r["wr"], -r["games"])) # Capture the WR boundary values for the chart's threshold lines. core_wr_threshold = ranked[core_rank_cutoff - 1]["wr"] if 0 < core_rank_cutoff <= len(ranked) else 0.0 active_wr_threshold = ranked[active_rank_cutoff - 1]["wr"] if 0 < active_rank_cutoff <= len(ranked) else 0.0 bucket_by_uid: dict[str, str] = {} for i, r in enumerate(ranked): if i < core_rank_cutoff and r["games"] >= median_games and r["games"] >= 1: bucket_by_uid[r["uid"]] = "core" elif i < active_rank_cutoff and r["games"] >= games_threshold_active and r["games"] >= 1: bucket_by_uid[r["uid"]] = "active" else: bucket_by_uid[r["uid"]] = "weak" core: list[dict] = [] active: list[dict] = [] weak: list[dict] = [] for r in records: b = bucket_by_uid.get(r["uid"], "weak") if b == "core": core.append(r) elif b == "active": active.append(r) else: weak.append(r) # Within each block: most-played first (ties broken by WR desc). for bucket in (core, active, weak): bucket.sort(key=lambda r: (-r["games"], -r["wr"])) ordered = core + active + weak n = len(ordered) # Render fig, ax = plt.subplots(figsize=(16, 8), facecolor=SQ_STATS_GRAPH_COLORS['bg']) ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) x_positions = list(range(n)) heights = [r["wr"] for r in ordered] colors = ( [SQ_INFO_GRAPH_CATEGORY_COLORS['core']] * len(core) + [SQ_INFO_GRAPH_CATEGORY_COLORS['active']] * len(active) + [SQ_INFO_GRAPH_CATEGORY_COLORS['weak']] * len(weak) ) if n > 0: # Faint ghost stubs first so 0-WR / 0-game members are still visible as # an occupied slot rather than invisible empty space. Drawn under the # real WR bars. ghost_height = 2.5 ax.bar( x_positions, [ghost_height] * n, width=1.0, color='#3a3a48', edgecolor=SQ_STATS_GRAPH_COLORS['bg'], linewidth=0.5, align='center', zorder=1, ) ax.bar( x_positions, heights, width=1.0, color=colors, edgecolor=SQ_STATS_GRAPH_COLORS['bg'], linewidth=0.5, align='center', zorder=2, ) # Per-bar vertical labels: "{nick} · {games}g" rotated 90°, drawn inside # the coloured portion when there's room, or above the bar otherwise. # Black text with a thin white halo keeps it readable on both green and # red fills. for i, r in enumerate(ordered): label = f"{r['nick']} · {r['games']}g" # Truncate to keep extreme nicks from overflowing the plot top. if len(label) > 28: label = label[:27] + "…" wr_val = r["wr"] if wr_val >= 12: y_text = wr_val / 2.0 color = '#0b0b0b' va = 'center' else: # Stack label above bar (or above the ghost stub for 0-game members). y_text = max(wr_val, ghost_height) + 1.5 color = SQ_STATS_GRAPH_COLORS['text'] va = 'bottom' txt = ax.text( i, y_text, label, rotation=90, ha='center', va=va, fontsize=6, color=color, alpha=0.95, clip_on=True, zorder=3, ) txt.set_path_effects([ path_effects.withStroke(linewidth=1.2, foreground='#ffffff' if color == '#0b0b0b' else '#000000'), ]) # 50% WR reference line (absolute "winning vs losing" baseline). ax.axhline(50, color=SQ_STATS_GRAPH_COLORS['text'], linestyle=':', linewidth=1, alpha=0.3) # CORE WR boundary — the lowest WR in the top-30% rank slice. Anyone at or # above this WR is a CORE candidate (still gated on games ≥ median). if core_wr_threshold > 0: ax.axhline( core_wr_threshold, color=SQ_INFO_GRAPH_CATEGORY_COLORS['core'], linestyle='--', linewidth=1.2, alpha=0.75, zorder=4, ) ax.text( n - 0.5 if n > 0 else 0.5, core_wr_threshold + 1.2, t(lang, "sq_info_graph.core_threshold_line", wr=f"{core_wr_threshold:.1f}"), ha='right', va='bottom', color=SQ_INFO_GRAPH_CATEGORY_COLORS['core'], fontsize=9, fontweight='bold', alpha=0.9, zorder=5, ) # WEAK WR boundary — the lowest WR in the top-45% rank slice. Below this # by WR alone puts a member in WEAK. if active_wr_threshold > 0 and active_wr_threshold < core_wr_threshold: ax.axhline( active_wr_threshold, color=SQ_INFO_GRAPH_CATEGORY_COLORS['weak'], linestyle='--', linewidth=1.2, alpha=0.75, zorder=4, ) ax.text( n - 0.5 if n > 0 else 0.5, active_wr_threshold + 1.2, t(lang, "sq_info_graph.weak_threshold_line", wr=f"{active_wr_threshold:.1f}"), ha='right', va='bottom', color=SQ_INFO_GRAPH_CATEGORY_COLORS['weak'], fontsize=9, fontweight='bold', alpha=0.9, zorder=5, ) # Vertical dividers between blocks. if core and (active or weak): ax.axvline(len(core) - 0.5, color=SQ_STATS_GRAPH_COLORS['text'], linewidth=1, alpha=0.7) if (core or active) and weak: ax.axvline(len(core) + len(active) - 0.5, color=SQ_STATS_GRAPH_COLORS['text'], linewidth=1, alpha=0.7) def _block_avg_wr(bucket: list[dict]) -> float: total_games = sum(b["games"] for b in bucket) total_wins = sum(b["wins"] for b in bucket) return (total_wins / total_games * 100.0) if total_games > 0 else 0.0 if n > 0: if core: cx = (len(core) - 1) / 2.0 ax.text( cx, 105, t(lang, "sq_info_graph.core_header", count=len(core), avg=f"{_block_avg_wr(core):.1f}"), ha='center', va='bottom', color=SQ_INFO_GRAPH_CATEGORY_COLORS['core'], fontweight='bold', fontsize=11, ) if active: ax_x = len(core) + (len(active) - 1) / 2.0 ax.text( ax_x, 105, t(lang, "sq_info_graph.active_header", count=len(active), avg=f"{_block_avg_wr(active):.1f}"), ha='center', va='bottom', color=SQ_INFO_GRAPH_CATEGORY_COLORS['active'], fontweight='bold', fontsize=11, ) if weak: wx = len(core) + len(active) + (len(weak) - 1) / 2.0 ax.text( wx, 105, t(lang, "sq_info_graph.weak_header", count=len(weak), avg=f"{_block_avg_wr(weak):.1f}"), ha='center', va='bottom', color=SQ_INFO_GRAPH_CATEGORY_COLORS['weak'], fontweight='bold', fontsize=11, ) ax.set_ylim(0, 118) ax.set_xlim(-0.5, max(n - 0.5, 0.5)) ax.set_ylabel(t(lang, "sq_info_graph.y_label"), fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) ax.set_title( t(lang, "sq_info_graph.title", squadron=squadron_name, season=current_season_name), fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text'], ) ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid'], axis='y') ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) for spine in ax.spines.values(): spine.set_color(SQ_STATS_GRAPH_COLORS['text']) ax.set_xticks([]) ax.set_yticks([0, 25, 50, 75, 100]) ax.set_yticklabels(['0%', '25%', '50%', '75%', '100%']) plt.tight_layout() safe_squadron = re.sub(r'[^A-Za-z0-9_-]+', '_', squadron_name) or 'squadron' temp_path = Path(f"/tmp/sq_info_graph_{safe_squadron}_{int(time_module.time())}.png") plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) plt.close(fig) embed = discord.Embed( title=t(lang, "sq_info_graph.embed_title", squadron=squadron_name), description=t( lang, "sq_info_graph.embed_desc", season=current_season_name, core=len(core), active=len(active), weak=len(weak), median=int(round(median_games)), ), color=discord.Color.green(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) embed.set_image(url=f"attachment://{temp_path.name}") try: await interaction.followup.send(embed=embed, file=discord.File(temp_path)) finally: try: temp_path.unlink() except Exception: pass @sq_info_graph.error async def sq_info_graph_error(interaction, error): await permission_fail(interaction, error) # ═══════════════════════════════════════════════════════════════════════════ # SEASON RECAP CARDS (/sq-card, /card) # ═══════════════════════════════════════════════════════════════════════════ # Cached pre-formatted season choices (refreshed periodically so status transitions # from in_progress → completed get picked up during long bot uptimes). _SEASONS_CHOICES_CACHE: list[tuple[str, str]] = [] # (name, display_label) _SEASONS_CHOICES_CACHE_TS: float = 0.0 _SEASONS_CHOICES_TTL_SECONDS = 60.0 def _build_seasons_choices() -> list[tuple[str, str]]: """Return (value, display_label) pairs, most recent first.""" seasons = get_seasons() entries = sorted(seasons.items(), key=lambda kv: kv[1]["start"], reverse=True) return [ (name, f"{name} (in progress)" if rng["status"] == "in_progress" else name) for name, rng in entries ] def _refresh_seasons_choices_cache() -> list[tuple[str, str]]: global _SEASONS_CHOICES_CACHE, _SEASONS_CHOICES_CACHE_TS _SEASONS_CHOICES_CACHE = _build_seasons_choices() _SEASONS_CHOICES_CACHE_TS = time_module.time() return _SEASONS_CHOICES_CACHE # Pre-warm at import so the first autocomplete call doesn't pay the parse cost. try: _refresh_seasons_choices_cache() except Exception as _e: print(f"[WARN] Failed to pre-warm seasons cache: {_e}", flush=True) async def seasons_autocomplete( interaction: discord.Interaction, current: str, ) -> List[discord.app_commands.Choice[str]]: """Autocomplete for season names from constants/seasons. Most recent first. Kept deliberately minimal — Discord invalidates the interaction after 3s, so every op here must be O(1)-ish on cached data. """ try: now = time_module.time() if now - _SEASONS_CHOICES_CACHE_TS > _SEASONS_CHOICES_TTL_SECONDS: choices = _refresh_seasons_choices_cache() else: choices = _SEASONS_CHOICES_CACHE current_l = (current or "").lower() if current_l: choices = [c for c in choices if current_l in c[0].lower()] return [ discord.app_commands.Choice(name=label, value=name) for name, label in choices[:25] ] except Exception as e: print(f"[AUTOCOMPLETE ERROR] seasons_autocomplete failed: {e}", flush=True) return [] def _recap_lang(guild_lang_code: str) -> str: """Map a guild lang to a lang the recap renderer supports (falls back to 'en').""" return guild_lang_code if guild_lang_code in RECAP_LANGS else 'en' RECAP_THEME_CHOICES = [ app_commands.Choice(name=command_locale("Dark", "commands.common.choice_dark"), value="dark"), app_commands.Choice(name=command_locale("Light", "commands.common.choice_light"), value="light"), ] @is_blacklisted() @gate_entitle("standard") @bot.tree.command(name="sq-card", description=command_locale("Generate a season recap card for a squadron", "commands.sq_card.description")) @app_commands.describe( season=command_locale("The season to generate the card for", "commands.common.season"), squadron=command_locale("The short name of the squadron", "commands.sq_card.squadron"), theme=command_locale("Card color theme", "commands.common.theme"), ) @app_commands.choices(theme=RECAP_THEME_CHOICES) @discord.app_commands.autocomplete( season=seasons_autocomplete, squadron=squadron_autocomplete, ) async def sq_card( interaction: discord.Interaction, season: str, squadron: str = "", theme: app_commands.Choice[str] | None = None, ): """Generate and send a season recap card PNG for a squadron. Args: interaction: The Discord interaction. season: Season identifier (e.g. "2026-II"). squadron: Short name of the squadron (falls back to guild default). theme: "dark" (default) or "light". """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' theme_value = theme.value if theme else 'dark' # Validate season up-front so bad input fails fast without rendering. seasons = get_seasons() if season not in seasons: embed = discord.Embed( title=t(lang, "common.error_title"), description=t(lang, "recap_card.unknown_season", season=season), color=discord.Color.red(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) try: clan = await get_guild_squadron(interaction.guild_id, squadron) except ValueError as e: embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) squadron_name = clan["long_name"] short_name = clan.get("short_name") or squadron_name clan_id = await resolve_clan_id(squadron_name) if clan_id is None: embed = discord.Embed( title=t(lang, "common.error_title"), description=t(lang, "recap_card.no_clan_id", squadron=squadron_name), color=discord.Color.red(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) try: path = await get_squadron_recap(clan_id, season, theme_value, _recap_lang(lang)) except RecapError: embed = discord.Embed( title=t(lang, "common.error_title"), description=t(lang, "recap_card.render_failed"), color=discord.Color.red(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) safe_name = re.sub(r'[^A-Za-z0-9_-]+', '_', short_name) or str(clan_id) filename = f"{safe_name}-{season}.png" await interaction.followup.send(file=discord.File(path, filename=filename)) @sq_card.error async def sq_card_error(interaction, error): await permission_fail(interaction, error) # Dark mode color scheme for graphs SQ_STATS_GRAPH_COLORS = { 'bg': '#1e1e2e', # Background color 'plot_bg': '#2b2b3c', # Plot area background 'text': "#16c52e", # Text color 'grid': "#6d6d6d", # Grid line color 'squadron_line': "#12ed2f", # Squadron total line } class PlayerSelect(discord.ui.Select): """Paginated dropdown for selecting players from a squadron's point history. Each page shows up to 25 players (Discord select limit). Selections are tracked in the parent PlayerRefineView so they persist across page changes. """ def __init__(self, squadron_name: str, timestamps: list, player_histories: dict, dates_numeric: list, filtered_ticks: list, filtered_labels: list, page: int = 0, parent_view=None, lang: str = "en"): self.squadron_name = squadron_name self.timestamps = timestamps self.all_player_histories = player_histories self.dates_numeric = dates_numeric self.filtered_ticks = filtered_ticks self.filtered_labels = filtered_labels self.page = page self.parent_view = parent_view self.lang = lang # Pagination: 25 players per page (Discord limit) players_per_page = 25 all_players = list(player_histories.items()) start_idx = page * players_per_page end_idx = start_idx + players_per_page page_players = all_players[start_idx:end_idx] # Create options with player name and UID options = [] for uid, player_data in page_players: clean_nick = re.sub(r'[^a-zA-Z0-9\u0400-\u04FF\u4E00-\u9FFF\s_-]', '', player_data["nick"]) # Truncate name if too long (Discord limit is 100 chars for label, 100 for description) label = clean_nick[:80] if len(clean_nick) > 80 else clean_nick description = f"UID: {uid}" # Mark as default if already selected is_default = parent_view and uid in parent_view.selected_uids if parent_view else False options.append(discord.SelectOption(label=label, description=description, value=uid, default=is_default)) super().__init__( placeholder=t(lang, "sq_stats.select_players_placeholder", page=page + 1), min_values=0, max_values=len(options), options=options ) async def callback(self, interaction: discord.Interaction): # Update parent view's selected UIDs if self.parent_view: # Remove previous selections from this page players_per_page = 25 all_players = list(self.all_player_histories.keys()) start_idx = self.page * players_per_page end_idx = start_idx + players_per_page page_player_uids = all_players[start_idx:end_idx] # Remove old selections from this page self.parent_view.selected_uids = {uid for uid in self.parent_view.selected_uids if uid not in page_player_uids} # Add new selections self.parent_view.selected_uids.update(self.values) # Acknowledge interaction silently (message stays for further interaction) await interaction.response.defer() class PlayerRefineView(discord.ui.View): """Interactive view for refining player selections and generating filtered charts. Contains a PlayerSelect dropdown, pagination buttons, and a "Generate Chart" button. Selected player UIDs persist across page changes so users can pick players from multiple pages before charting. """ def __init__(self, squadron_name: str, timestamps: list, player_histories: dict, dates_numeric: list, filtered_ticks: list, filtered_labels: list, page: int = 0, selected_uids: Optional[set] = None, lang: str = "en"): super().__init__(timeout=7200) # 2 hours self.message: Optional[discord.Message] = None self.squadron_name = squadron_name self.timestamps = timestamps self.player_histories = player_histories self.dates_numeric = dates_numeric self.filtered_ticks = filtered_ticks self.filtered_labels = filtered_labels self.page = page self.selected_uids = selected_uids if selected_uids is not None else set() self.lang = lang # Add player select dropdown self.select = PlayerSelect(squadron_name, timestamps, player_histories, dates_numeric, filtered_ticks, filtered_labels, page, parent_view=self, lang=lang) self.add_item(self.select) # Add pagination buttons if needed players_per_page = 25 total_pages = (len(player_histories) + players_per_page - 1) // players_per_page if total_pages > 1: # Previous button prev_button = discord.ui.Button( label=t(lang, "buttons.prev_arrow"), style=discord.ButtonStyle.secondary, disabled=(page == 0) ) prev_button.callback = self.previous_page self.add_item(prev_button) # Next button next_button = discord.ui.Button( label=t(lang, "buttons.next_arrow"), style=discord.ButtonStyle.secondary, disabled=(page >= total_pages - 1) ) next_button.callback = self.next_page self.add_item(next_button) # Add "Generate Chart" button generate_button = discord.ui.Button( label=t(lang, "buttons.generate_chart"), style=discord.ButtonStyle.success, row=2 ) generate_button.callback = self.generate_chart self.add_item(generate_button) async def on_timeout(self): for item in self.children: item.disabled = True # type: ignore[attr-defined] try: if self.message: await self.message.edit(view=self) except Exception: pass async def generate_chart(self, interaction: discord.Interaction): """Generate a player points chart filtered to the currently selected UIDs.""" await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' if not self.selected_uids: await interaction.followup.send(t(lang, "common.no_players_selected"), ephemeral=True) return # Filter player_histories to only selected UIDs filtered_histories = {uid: self.player_histories[uid] for uid in self.selected_uids if uid in self.player_histories} # Generate chart with filtered players view_helper = SquadronStatsView(self.squadron_name, self.timestamps, self.player_histories, self.dates_numeric, self.filtered_ticks, self.filtered_labels, lang=lang) embed, temp_path = await view_helper.generate_player_chart(filtered_histories, lang=lang) # Create new refine view for further refinement (preserve selections) refine_view = PlayerRefineView(self.squadron_name, self.timestamps, self.player_histories, self.dates_numeric, self.filtered_ticks, self.filtered_labels, 0, self.selected_uids, lang=lang) refine_view.message = await interaction.followup.send(embed=embed, file=discord.File(temp_path), view=refine_view, wait=True) # Clean up temporary file try: temp_path.unlink() except Exception: pass async def previous_page(self, interaction: discord.Interaction): new_page = self.page - 1 await self.update_page(interaction, new_page) async def next_page(self, interaction: discord.Interaction): new_page = self.page + 1 await self.update_page(interaction, new_page) async def update_page(self, interaction: discord.Interaction, new_page: int): # Create new view with updated page, preserving selected UIDs new_view = PlayerRefineView(self.squadron_name, self.timestamps, self.player_histories, self.dates_numeric, self.filtered_ticks, self.filtered_labels, new_page, self.selected_uids, lang=self.lang) # Update the message with new view await interaction.response.edit_message(view=new_view) class SquadronStatsView(discord.ui.View): """View attached to /sq-stats with buttons for player breakdown and leaderboard comparison. Holds all the squadron's historical data needed to generate individual player charts and nearby-squadron comparison charts on demand. """ def __init__(self, squadron_name: str, timestamps: list, player_histories: dict, dates_numeric: list, filtered_ticks: list, filtered_labels: list, lang: str = "en"): super().__init__(timeout=21600) self.message: Optional[discord.Message] = None self.squadron_name = squadron_name self.timestamps = timestamps self.player_histories = player_histories self.dates_numeric = dates_numeric self.filtered_ticks = filtered_ticks self.filtered_labels = filtered_labels self.lang = lang self.view_player_stats.label = t(lang, "buttons.view_player_stats") self.compare_nearby_squadrons.label = t(lang, "buttons.compare_nearby") async def on_timeout(self): for item in self.children: item.disabled = True # type: ignore[attr-defined] try: if self.message: await self.message.edit(view=self) except Exception: pass async def generate_player_chart(self, filtered_player_histories: Optional[dict] = None, lang: str = "en"): """Generate player stats chart. If filtered_player_histories is provided, use that, otherwise use all players.""" player_data = filtered_player_histories if filtered_player_histories else self.player_histories # Create player stats chart with dark mode fig, ax = plt.subplots(figsize=(16, 8), facecolor=SQ_STATS_GRAPH_COLORS['bg']) ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) # Collect all point changes to determine color scale range all_changes = [] for uid, p_data in player_data.items(): points = p_data["points"] if len(points) == len(self.dates_numeric) and len(points) > 1: for i in range(1, len(points)): change = points[i] - points[i-1] all_changes.append(change) # Determine color scale bounds using percentiles to avoid extreme outliers if all_changes: # Use 5th and 95th percentiles to avoid outliers dominating the scale p5 = float(np.percentile(all_changes, 5)) p95 = float(np.percentile(all_changes, 95)) # Ensure we have negative and positive bounds for diverging colormap # But don't force them to be symmetric - use actual data distribution vmin = float(min(p5, -abs(p95) * 0.1)) if p5 >= 0 else p5 # Allow negative even if all gains vmax = float(max(p95, abs(p5) * 0.1)) if p95 <= 0 else p95 # Allow positive even if all losses # TwoSlopeNorm requires vmin < vcenter(0) < vmax strictly # When all changes are 0, both vmin and vmax become 0, causing ValueError if vmin >= 0: vmin = -1.0 if vmax <= 0: vmax = 1.0 else: vmin, vmax = -1.0, 1.0 # Create colormap: Red for decreases, Yellow for neutral, Green for increases # Use TwoSlopeNorm to ensure 0 is always at the center (yellow) cmap = cm.get_cmap('RdYlGn') norm = TwoSlopeNorm(vmin=vmin, vcenter=0.0, vmax=vmax) # Collect endpoint positions for smart labeling endpoints = [] for uid, p_data in player_data.items(): points = p_data["points"] if len(points) == len(self.dates_numeric) and len(points) > 1: # Create line segments with colors based on point change segments = [] colors = [] for i in range(len(points) - 1): # Create segment from point i to point i+1 segment = [(self.dates_numeric[i], points[i]), (self.dates_numeric[i+1], points[i+1])] segments.append(segment) # Calculate change and map to color change = points[i+1] - points[i] color = cmap(norm(change)) colors.append(color) # Create and add LineCollection lc = LineCollection(segments, colors=colors, linewidths=1.5, alpha=0.7) ax.add_collection(cast(Any, lc)) # Clean special characters from nickname (keep ASCII, Cyrillic, Chinese/CJK, spaces, underscores, hyphens) clean_nick = re.sub(r'[^a-zA-Z0-9\u0400-\u04FF\u4E00-\u9FFF\s_-]', '', p_data["nick"]) # Use average color for endpoint label (based on overall trend) avg_change = (points[-1] - points[0]) / (len(points) - 1) if len(points) > 1 else 0 avg_color = cmap(norm(avg_change)) endpoints.append((points[-1], clean_nick, avg_color)) # Sort endpoints by Y position endpoints.sort(key=lambda x: x[0]) # Set axis limits based on data (LineCollection doesn't auto-scale) if self.dates_numeric and endpoints: all_y_values = [] for uid, p_data in player_data.items(): if len(p_data["points"]) == len(self.dates_numeric): all_y_values.extend(p_data["points"]) if all_y_values: ax.set_xlim(min(self.dates_numeric), max(self.dates_numeric)) ax.set_ylim(min(all_y_values) - 50, max(all_y_values) + 50) # Extend x-axis to make room for labels on the right x_min, x_max = ax.get_xlim() x_range = x_max - x_min ax.set_xlim(x_min, x_max + x_range * 0.08) # Add 8% padding to right # Set minimum separation based on whether this is a refined view or all players # Non-refined (all players): 50 points minimum separation # Refined (filtered players): 25 points minimum separation min_separation = 25 if filtered_player_histories is not None else 50 # Only label players with enough vertical separation last_labeled_y = None for y_pos, nick, color in endpoints: if last_labeled_y is None or abs(y_pos - last_labeled_y) >= min_separation: ax.annotate(nick, xy=(self.dates_numeric[-1], y_pos), xytext=(5, 0), textcoords='offset points', fontsize=7, color=SQ_STATS_GRAPH_COLORS['text'], va='center', alpha=0.9) last_labeled_y = y_pos # Formatting with dark mode colors ax.set_xlabel('Time', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) ax.set_ylabel('Player Points', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) ax.set_title(f'{self.squadron_name}', fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text']) ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid']) ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) ax.spines['bottom'].set_color(SQ_STATS_GRAPH_COLORS['text']) ax.spines['top'].set_color(SQ_STATS_GRAPH_COLORS['text']) ax.spines['left'].set_color(SQ_STATS_GRAPH_COLORS['text']) ax.spines['right'].set_color(SQ_STATS_GRAPH_COLORS['text']) # Set x-axis ticks to only show where changes occurred ax.set_xticks(self.filtered_ticks) ax.set_xticklabels(self.filtered_labels, rotation=45, ha='right', color=SQ_STATS_GRAPH_COLORS['text']) plt.tight_layout() # Save to temporary file temp_path = Path(f"/tmp/sq_stats_players_{self.squadron_name.replace(' ', '_')}_{int(time_module.time())}.png") plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) plt.close(fig) # Return the embed and temp_path embed = discord.Embed( title=t(lang, "sq_stats.player_title", squadron=self.squadron_name), description=t(lang, "sq_stats.player_desc"), color=discord.Color.green() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) embed.set_image(url=f"attachment://{temp_path.name}") return embed, temp_path async def generate_squadron_comparison_chart(self, lang: str = "en"): """Generate comparison chart showing this squadron vs 10 above and 10 below in leaderboard.""" # Get position of current squadron from squadrons_data async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: async with db.execute(""" SELECT position FROM squadrons_data WHERE LOWER(long_name) = ? LIMIT 1 """, (self.squadron_name.lower(),)) as cursor: row = await cursor.fetchone() if not row: return None, None, t(lang, "sq_stats.squadron_not_found_error") current_position = row[0] # Get squadrons 5 above and 5 below min_pos = max(0, current_position - 5) max_pos = current_position + 5 async with db.execute(""" SELECT long_name, short_name, position FROM squadrons_data WHERE position >= ? AND position <= ? AND position IS NOT NULL ORDER BY position ASC """, (min_pos, max_pos)) as cursor: nearby_squadrons = await cursor.fetchall() if not nearby_squadrons: return None, None, t(lang, "sq_stats.no_nearby_error") # Fetch historical data for each squadron squadron_histories = {} async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: for long_name, short_name, position in nearby_squadrons: async with db.execute(""" SELECT unix_time, total_score FROM squadrons_points WHERE long_name = ? ORDER BY unix_time ASC """, (long_name,)) as cursor: rows = await cursor.fetchall() if rows: # Limit to same timeframe as current squadron if len(self.timestamps) > 0: min_time = min(self.timestamps) max_time = max(self.timestamps) filtered_rows = [(t, s) for t, s in rows if min_time <= t <= max_time] if filtered_rows: squadron_histories[long_name] = { "short_name": short_name, "position": position, "timestamps": [r[0] for r in filtered_rows], "scores": [r[1] for r in filtered_rows] } if not squadron_histories: return None, None, t(lang, "sq_stats.no_historical_error") # Create comparison chart with dark mode fig, ax = plt.subplots(figsize=(16, 10), facecolor=SQ_STATS_GRAPH_COLORS['bg']) ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) # Convert timestamps to datetime for formatting # Find squadrons that have overtaken ours (their score crossed above ours at some point) overtaking_squadrons = set() our_data_pre = next((data for ln, data in squadron_histories.items() if ln.lower() == self.squadron_name.lower()), None) if our_data_pre: our_dates_numeric_pre = [mdates.date2num(datetime.fromtimestamp(ts)) for ts in our_data_pre["timestamps"]] our_scores_pre = our_data_pre["scores"] for long_name, data in squadron_histories.items(): if long_name.lower() == self.squadron_name.lower(): continue other_dates = [mdates.date2num(datetime.fromtimestamp(ts)) for ts in data["timestamps"]] other_scores = data["scores"] common_times = sorted(set(our_dates_numeric_pre) & set(other_dates)) if len(common_times) < 2: continue our_at_common = [our_scores_pre[our_dates_numeric_pre.index(t)] for t in common_times] other_at_common = [other_scores[other_dates.index(t)] for t in common_times] for i in range(1, len(common_times)): diff_prev = our_at_common[i-1] - other_at_common[i-1] diff_curr = our_at_common[i] - other_at_common[i] if diff_prev * diff_curr < 0 and diff_curr < 0: # other crossed above us overtaking_squadrons.add(long_name) break # Assign colors: our squadron = green, overtakers = red, rest = grey grey_shades = ['#888888', '#999999', '#aaaaaa', '#777777', '#bbbbbb', '#666666', '#cccccc', '#555555', '#dddddd', '#444444'] grey_idx = 0 # Store our squadron's data for crossover detection our_dates_numeric = None our_scores = None endpoints = [] for long_name, data in squadron_histories.items(): dates = [datetime.fromtimestamp(ts) for ts in data["timestamps"]] dates_numeric = [mdates.date2num(d) for d in dates] scores = data["scores"] is_ours = long_name.lower() == self.squadron_name.lower() has_overtaken = long_name in overtaking_squadrons if is_ours: color = SQ_STATS_GRAPH_COLORS['squadron_line'] linewidth = 3.5 alpha = 1.0 zorder = 100 marker = 'o' our_dates_numeric = dates_numeric our_scores = scores elif has_overtaken: color = '#e84040' linewidth = 2.5 alpha = 0.9 zorder = 50 marker = None else: color = grey_shades[grey_idx % len(grey_shades)] grey_idx += 1 linewidth = 1.2 alpha = 0.4 zorder = 1 marker = None ax.plot(dates_numeric, scores, marker=marker, linestyle='-', linewidth=linewidth, markersize=4, color=color, alpha=alpha, zorder=zorder) # Store endpoint for labeling display_name = data["short_name"] if data["short_name"] else long_name[:15] if is_ours: display_name = f"★ {display_name}" endpoints.append((scores[-1], display_name, color, data["position"], is_ours, has_overtaken)) # Detect and highlight crossover points (where our squadron crosses another) if our_dates_numeric and our_scores: for long_name, data in squadron_histories.items(): if long_name.lower() == self.squadron_name.lower(): continue other_dates = [mdates.date2num(datetime.fromtimestamp(ts)) for ts in data["timestamps"]] other_scores = data["scores"] # Interpolate both to common timestamps for comparison common_times = sorted(set(our_dates_numeric) & set(other_dates)) if len(common_times) < 2: continue our_at_common = [our_scores[our_dates_numeric.index(t)] for t in common_times] other_at_common = [other_scores[other_dates.index(t)] for t in common_times] # Find crossover points (sign change in difference) for i in range(1, len(common_times)): diff_prev = our_at_common[i-1] - other_at_common[i-1] diff_curr = our_at_common[i] - other_at_common[i] if diff_prev * diff_curr < 0: # sign changed = crossover cross_x = common_times[i] cross_y = our_at_common[i] # Green if we crossed above them, red if we fell below cross_color = SQ_STATS_GRAPH_COLORS['squadron_line'] if diff_curr > 0 else '#e84040' ax.plot(cross_x, cross_y, marker='X', markersize=14, color=cross_color, zorder=200, markeredgecolor='#000000', markeredgewidth=1.5) # Sort endpoints by Y position for labeling endpoints.sort(key=lambda x: x[0]) # Extend x-axis to make room for labels if endpoints: x_min, x_max = ax.get_xlim() x_range = x_max - x_min ax.set_xlim(x_min, x_max + x_range * 0.12) # Label squadrons with smart spacing last_labeled_y = None min_separation = (max(e[0] for e in endpoints) - min(e[0] for e in endpoints)) / 30 for y_pos, name, color, position, is_ours, has_overtaken in endpoints: if last_labeled_y is None or abs(y_pos - last_labeled_y) >= min_separation: label_color = color if (is_ours or has_overtaken) else SQ_STATS_GRAPH_COLORS['text'] ax.annotate(f"#{position+1} {name}", xy=(x_max, y_pos), xytext=(5, 0), textcoords='offset points', fontsize=9, color=label_color, va='center', alpha=1.0 if (is_ours or has_overtaken) else 0.6, fontweight='bold' if (is_ours or has_overtaken) else 'normal') last_labeled_y = y_pos # Formatting with dark mode colors ax.set_xlabel('Time', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) ax.set_ylabel('Total Squadron Score', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) ax.set_title(f'Leaderboard Comparison (±5 positions)', fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text']) ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid']) ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) ax.spines['bottom'].set_color(SQ_STATS_GRAPH_COLORS['text']) ax.spines['top'].set_color(SQ_STATS_GRAPH_COLORS['text']) ax.spines['left'].set_color(SQ_STATS_GRAPH_COLORS['text']) ax.spines['right'].set_color(SQ_STATS_GRAPH_COLORS['text']) # Format x-axis with dates ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d')) ax.xaxis.set_major_locator(mdates.AutoDateLocator()) plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right', color=SQ_STATS_GRAPH_COLORS['text']) plt.tight_layout() # Save to temporary file temp_path = Path(f"/tmp/sq_comparison_{self.squadron_name.replace(' ', '_')}_{int(time_module.time())}.png") plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) plt.close(fig) # Determine actual range of positions shown (convert to 1-indexed for display) if squadron_histories: positions = [data["position"] for data in squadron_histories.values()] min_shown_pos = min(positions) + 1 # Convert to 1-indexed max_shown_pos = max(positions) + 1 # Convert to 1-indexed position_range = f"#{min_shown_pos} to #{max_shown_pos}" else: position_range = "N/A" # Create embed embed = discord.Embed( title=t(lang, "sq_stats.comparison_title", squadron=self.squadron_name), description=t(lang, "sq_stats.comparison_desc", range=position_range), color=discord.Color.purple() ) embed.add_field(name=t(lang, "sq_stats.current_position_field"), value=f"#{current_position+1}", inline=True) embed.add_field(name=t(lang, "sq_stats.squadrons_shown_field"), value=str(len(squadron_histories)), inline=True) embed.set_footer(text=DEFAULT_FOOTER_CAT) embed.set_image(url=f"attachment://{temp_path.name}") return embed, temp_path, None @discord.ui.button(label="📊 View Player Stats", style=discord.ButtonStyle.primary) async def view_player_stats(self, interaction: discord.Interaction, button: discord.ui.Button): """Generate and send the individual player points chart with a refine view.""" await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' # Generate chart for all players embed, temp_path = await self.generate_player_chart(lang=lang) # Create view with refine button refine_view = PlayerRefineView(self.squadron_name, self.timestamps, self.player_histories, self.dates_numeric, self.filtered_ticks, self.filtered_labels, lang=lang) refine_view.message = await interaction.followup.send(embed=embed, file=discord.File(temp_path), view=refine_view, wait=True) # Clean up temporary file try: temp_path.unlink() except Exception: pass @discord.ui.button(label="📈 Compare Nearby Squadrons", style=discord.ButtonStyle.secondary) async def compare_nearby_squadrons(self, interaction: discord.Interaction, button: discord.ui.Button): """Generate and send a leaderboard comparison chart with 5 squadrons above and below.""" await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' # Generate comparison chart embed, temp_path, error = await self.generate_squadron_comparison_chart(lang=lang) if error or not embed or not temp_path: error_embed = discord.Embed( title=t(lang, "common.error_title"), description=error or t(lang, "sq_stats.comparison_chart_failed"), color=discord.Color.red() ) error_embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=error_embed, ephemeral=True) return await interaction.followup.send(embed=embed, file=discord.File(temp_path)) # Clean up temporary file try: temp_path.unlink() except Exception: pass @is_blacklisted() @bot.tree.command( name="sq-stats", description=command_locale("Display a squadron's points over time", "commands.sq_stats.description"), guild=None ) @discord.app_commands.autocomplete(squadron=squadron_autocomplete) async def sq_stats(interaction: discord.Interaction, squadron: str = "", data_points: int = 150): """Display a squadron's total score trend over time as a line chart. Reads historical point snapshots from squadrons_points in SQLite, plots the total score with timeslot-aware x-axis labels (NA/EU), and attaches a SquadronStatsView with buttons for player breakdown and leaderboard comparison. Args: interaction: The Discord interaction. squadron: Short name of the squadron (falls back to guild default). data_points: Number of recent data points to plot (clamped to 2-500). """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' # Clamp data_points to minimum of 2, so its not just a single dot lol if data_points < 1: data_points = 2 if data_points > 500: data_points = 500 try: clan = await get_guild_squadron(interaction.guild_id, squadron) except ValueError as e: embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) squadron_name = clan["long_name"] # Read historical data from squadrons_points table timestamps = [] total_scores = [] clan_pts_data = [] async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db: async with db.execute(""" SELECT unix_time, total_score, clan_pts FROM squadrons_points WHERE long_name = ? ORDER BY unix_time ASC """, (squadron_name,)) as cursor: rows = await cursor.fetchall() for row in rows: timestamps.append(row[0]) total_scores.append(row[1]) clan_pts_data.append(row[2]) if not timestamps or not total_scores: embed = discord.Embed( title=t(lang, "sq_stats.no_data_title"), description=t(lang, "sq_stats.no_data_desc", squadron=squadron_name), color=discord.Color.orange() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed, ephemeral=True) return # Limit to last N data points if len(timestamps) > data_points: timestamps = timestamps[-data_points:] total_scores = total_scores[-data_points:] clan_pts_data = clan_pts_data[-data_points:] # Parse player data from clan_pts JSON # Format: [members_dict, total_score] where members_dict = {uid: {"nick": str, "points": int}} player_histories = {} # {uid: {"nick": str, "points": [list of points over time]}} for clan_pts_json in clan_pts_data: try: members_dict, _ = decompress_json(clan_pts_json) for uid, player_data in members_dict.items(): if uid not in player_histories: player_histories[uid] = { "nick": player_data["nick"], "points": [] } player_histories[uid]["points"].append(player_data["points"]) except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError): # If parsing fails, skip this data point continue # Create line chart with dark mode fig, ax = plt.subplots(figsize=(12, 6), facecolor=SQ_STATS_GRAPH_COLORS['bg']) ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) # Convert timestamps to datetime objects for better formatting dates = [datetime.fromtimestamp(ts) for ts in timestamps] # Convert to matplotlib numeric format for type compatibility dates_numeric = [mdates.date2num(d) for d in dates] # Plot squadron total with dark mode color ax.plot(dates_numeric, total_scores, marker='o', linestyle='-', linewidth=2.5, markersize=5, color=SQ_STATS_GRAPH_COLORS['squadron_line']) # Formatting with dark mode colors ax.set_xlabel('Time', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) ax.set_ylabel('Total Squadron Score', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) ax.set_title(f'{squadron_name}', fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text']) ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid']) ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) ax.spines['bottom'].set_color(SQ_STATS_GRAPH_COLORS['text']) ax.spines['top'].set_color(SQ_STATS_GRAPH_COLORS['text']) ax.spines['left'].set_color(SQ_STATS_GRAPH_COLORS['text']) ax.spines['right'].set_color(SQ_STATS_GRAPH_COLORS['text']) # Add padding to y-axis to prevent annotation clipping y_min, y_max = ax.get_ylim() y_range = y_max - y_min ax.set_ylim(y_min, y_max + y_range * 0.15) # Add 15% padding to top for arrow annotations # Determine timeslot region by hour: NA ~01:00-07:00, EU ~14:00-22:00 def get_timeslot_region(dt): """Return 'NA' or 'EU' region label based on UTC hour, or None if outside SQB hours.""" if 0 <= dt.hour <= 9: return 'NA' elif dt.hour >= 13: return 'EU' return None # Only show ticks where the score changed, with one label per timeslot # A timeslot instance is identified by (date, region) e.g. ("02/15", "EU") # The label goes on the first change within that timeslot filtered_ticks = [] # date_num values for tick placement filtered_labels = [] # label strings ('' for unlabeled ticks) filtered_scores = [] # scores at each filtered tick (for annotations) labeled_timeslots = set() for i, (dt, date_num, score) in enumerate(zip(dates, dates_numeric, total_scores)): # Always include the first data point as a tick if i == 0: region = get_timeslot_region(dt) if region: date_str = dt.strftime('%m/%d') filtered_ticks.append(date_num) filtered_labels.append(f'{date_str} {region}') filtered_scores.append(score) labeled_timeslots.add((dt.strftime('%Y-%m-%d'), region)) else: filtered_ticks.append(date_num) filtered_labels.append('') filtered_scores.append(score) continue # Skip points where the score didn't change if score == total_scores[i - 1]: continue filtered_ticks.append(date_num) filtered_scores.append(score) # Check if this timeslot instance needs a label region = get_timeslot_region(dt) if region: timeslot_key = (dt.strftime('%Y-%m-%d'), region) if timeslot_key not in labeled_timeslots: date_str = dt.strftime('%m/%d') filtered_labels.append(f'{date_str} {region}') labeled_timeslots.add(timeslot_key) else: filtered_labels.append('') else: filtered_labels.append('') # Set x-axis ticks to only show where changes occurred ax.set_xticks(filtered_ticks) ax.set_xticklabels(filtered_labels, rotation=45, ha='right', color=SQ_STATS_GRAPH_COLORS['text']) # Add score annotations on labeled tick points (first change per timeslot) # Skip annotations that would overlap with any previously placed annotation labeled_annotations = [(tick, score) for tick, label, score in zip(filtered_ticks, filtered_labels, filtered_scores) if label] # Only annotate points that have a visible label if labeled_annotations: # Convert axis limits to figure out data-per-pixel ratios x_range = ax.get_xlim()[1] - ax.get_xlim()[0] y_range_axis = ax.get_ylim()[1] - ax.get_ylim()[0] fig_width, fig_height = fig.get_size_inches() dpi = fig.dpi # Approximate text box size in data coords (font 11 ~ 15px height, ~60px width) text_h = y_range_axis * 40 / (fig_height * dpi) text_w = x_range * 80 / (fig_width * dpi) placed = [] # list of (text_x, text_y) in data coords for placed annotations for ann_idx, (date_num, score) in enumerate(labeled_annotations): # First annotation goes upper-right to avoid clipping against y-axis if ann_idx == 0: x_offset, y_offset, h_align = 20, 30, 'left' else: x_offset, y_offset, h_align = -20, 30, 'right' # Approximate where the text will land in data coords text_x = date_num + (x_range * x_offset / (fig_width * dpi)) text_y = score + (y_range_axis * y_offset / (fig_height * dpi)) # Check if this would overlap with any previously placed annotation too_close = False for px, py in placed: if abs(text_x - px) < text_w and abs(text_y - py) < text_h: too_close = True break if too_close: continue ax.annotate(f'{score:,}', xy=(float(date_num), float(score)), xytext=(x_offset, y_offset), textcoords='offset points', ha=h_align, fontsize=11, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text'], alpha=1.0, zorder=10, arrowprops=dict( arrowstyle='->', color=SQ_STATS_GRAPH_COLORS['text'], alpha=0.9, lw=1.5 )) placed.append((text_x, text_y)) plt.tight_layout() # Save to temporary file temp_path = Path(f"/tmp/sq_stats_{squadron_name.replace(' ', '_')}_{int(time_module.time())}.png") plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) plt.close(fig) # Send the chart embed = discord.Embed( title=t(lang, "sq_stats.title", squadron=squadron_name), description=t(lang, "sq_stats.desc", count=len(timestamps)), color=discord.Color.blue() ) embed.add_field(name=t(lang, "sq_stats.previous_score_field"), value=f"{total_scores[0]:,}", inline=True) embed.add_field(name=t(lang, "sq_stats.current_score_field"), value=f"{total_scores[-1]:,}", inline=True) change = total_scores[-1] - total_scores[0] embed.add_field(name=t(lang, "sq_stats.change_field"), value=f"{change:+,}", inline=True) embed.set_footer(text=DEFAULT_FOOTER_CAT) embed.set_image(url=f"attachment://{temp_path.name}") # Create view with button for player stats view = SquadronStatsView(squadron_name, timestamps, player_histories, dates_numeric, filtered_ticks, filtered_labels, lang=lang) view.message = await interaction.followup.send(embed=embed, file=discord.File(temp_path), view=view, wait=True) # Clean up temporary file try: temp_path.unlink() except Exception: pass @sq_stats.error async def sq_stats_error(interaction, error): await permission_fail(interaction, error) async def load_leaderboard_readonly(db_path: Path) -> tuple[list[tuple[str, str, float]], list[float]]: """ Async read-only snapshot of the leaderboard from squadrons.db. Returns: rows: [(long_name, short_name, clanrating), ...] sorted DESC by clanrating ratings_desc: [clanrating, ...] sorted DESC """ rows: list[tuple[str, str, float]] = [] async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as con: con.row_factory = aiosqlite.Row async with con.execute( """ SELECT long_name, short_name, clanrating FROM squadrons_data WHERE clanrating IS NOT NULL AND clanrating > 0 ORDER BY clanrating DESC """ ) as cur: async for r in cur: rows.append((r["long_name"], r["short_name"], float(r["clanrating"]))) ratings_desc = [r[2] for r in rows] return rows, ratings_desc def find_current_rank( rows: list[tuple[str, str, float]], squadron_long: str, squadron_short: str, ) -> tuple[Optional[float], Optional[int]]: """Find this squadron's current rating and 1-based rank in the rows snapshot.""" long_lower = (squadron_long or "").lower() short_lower = (squadron_short or "").lower() for idx, (long_name, short_name, rating) in enumerate(rows, start=1): if ((long_name and long_name.lower() == long_lower) or (short_name and short_name.lower() == short_lower)): return float(rating), idx return None, None async def get_current_squadron_placement( squadron_long: str, squadron_short: str ) -> tuple[Optional[int], Optional[float]]: """Return (1-based rank, rating) for a squadron, or (None, None) if not found.""" rows, _ = await load_leaderboard_readonly(SQUADRONS_DB_PATH) rating, rank = find_current_rank(rows, squadron_long, squadron_short) return rank, rating def project_rank(ratings_desc: list[float], projected_rating: float) -> int: """Return 1-based projected rank for a DESC list of ratings.""" for idx, rating in enumerate(ratings_desc, start=1): if projected_rating >= rating: return idx return len(ratings_desc) + 1 # below all current entries async def _sq_player_autocomplete( interaction: discord.Interaction, current: str, ) -> List[discord.app_commands.Choice[str]]: """Autocomplete player names from the player_games_hist table, scoped to the selected squadron.""" sq_short = getattr(interaction.namespace, "squadron_short", "") or "" # Resolve squadron to clan_id clan_id: Optional[int] = None try: if sq_short: async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: async with db.execute( "SELECT clan_id FROM squadrons_data WHERE LOWER(short_name) = ? LIMIT 1", (sq_short.lower(),), ) as cur: row = await cur.fetchone() if row: clan_id = row[0] else: # Try guild default guild_id = str(interaction.guild_id) if interaction.guild_id else "" try: sq_cfg = await load_json(STORAGE_DIR / "SQUADRONS.json", {}) sq_short_default = sq_cfg.get(guild_id, {}).get("SQ_ShortHand_Name", "") if sq_short_default: async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: async with db.execute( "SELECT clan_id FROM squadrons_data WHERE LOWER(short_name) = ? LIMIT 1", (sq_short_default.lower(),), ) as cur: row = await cur.fetchone() if row: clan_id = row[0] except Exception: pass if clan_id is None: return [] async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: await db.create_function("ulower", 1, str.lower) if not current or len(current) < 1: # Show top members by points async with db.execute( "SELECT nick FROM squadron_members WHERE clan_id = ? ORDER BY points DESC LIMIT 25", (clan_id,), ) as cur: rows = await cur.fetchall() else: async with db.execute( """ SELECT nick FROM squadron_members WHERE clan_id = ? AND ulower(nick) LIKE ulower(?) ORDER BY CASE WHEN ulower(nick) = ulower(?) THEN 0 WHEN ulower(nick) LIKE ulower(?) THEN 1 ELSE 2 END, points DESC LIMIT 25 """, (clan_id, f"%{current}%", current, f"{current}%"), ) as cur: rows = await cur.fetchall() return [ discord.app_commands.Choice(name=row[0][:100], value=row[0][:100]) for row in rows ] except Exception: return [] # ============================= # /loss-calculator COMMAND # ============================= @is_blacklisted() @bot.tree.command( name="loss-calculator", description=command_locale("Calculate the point loss if players leave a squadron", "commands.loss_calculator.description") ) @app_commands.describe( squadron_short=command_locale("The short name of the squadron", "commands.common.squadron_short"), player1=command_locale("Player leaving", "commands.loss_calculator.player1"), player2=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), player3=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), player4=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), player5=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), player6=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), player7=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), ) @discord.app_commands.autocomplete( squadron_short=squadron_autocomplete, player1=_sq_player_autocomplete, player2=_sq_player_autocomplete, player3=_sq_player_autocomplete, player4=_sq_player_autocomplete, player5=_sq_player_autocomplete, player6=_sq_player_autocomplete, player7=_sq_player_autocomplete, ) async def loss_calculator( interaction: discord.Interaction, player1: str, squadron_short: str = "", player2: str = "", player3: str = "", player4: str = "", player5: str = "", player6: str = "", player7: str = "", ): """Calculate projected point loss and rank change if players leave a squadron. Fetches live squad data, matches player names to UIDs, computes effective point contributions using the top-20 rate multiplier, and projects the new leaderboard rank after removal. Args: interaction: The Discord interaction. player1: Required player name leaving the squadron. squadron_short: Short name of the squadron (falls back to guild default). player2-player7: Optional additional player names leaving. """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=True) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' try: clan = await get_guild_squadron(interaction.guild_id, squadron_short) except ValueError as e: return await interaction.followup.send(str(e), ephemeral=True) squadron_name = clan["long_name"] squadron_short_key = clan["short_name"] # --- Fetch live squad data --- try: sq_data, sq_total = await obtain_clan_new_points(squadron_name) except Exception as e: await interaction.followup.send(t(lang, "loss_calc.fetch_failed", error=str(e)), ephemeral=True) return if not sq_data or sq_total <= 0: await interaction.followup.send(t(lang, "loss_calc.no_point_data"), ephemeral=True) return sorted_players = sorted(sq_data.items(), key=lambda kv: kv[1]["points"], reverse=True) # --- Match provided player names to UIDs in sq_data --- player_names = [p for p in [player1, player2, player3, player4, player5, player6, player7] if p] selected_ids: list[str] = [] not_found: list[str] = [] nick_to_uid = {d["nick"].lower(): uid for uid, d in sq_data.items()} for name in player_names: uid = nick_to_uid.get(name.lower()) if uid and uid not in selected_ids: selected_ids.append(uid) else: # Fuzzy fallback: partial match matches = [u for nick, u in nick_to_uid.items() if name.lower() in nick] if len(matches) == 1 and matches[0] not in selected_ids: selected_ids.append(matches[0]) elif not uid: not_found.append(name) if not selected_ids: await interaction.followup.send( t(lang, "loss_calc.no_matching_players", squadron=squadron_name), ephemeral=True) return # --- Compute rate_x and loss --- TOP_N = 20 top20_sum = sum(d["points"] for _, d in sorted_players[:TOP_N]) other_sum = sum(d["points"] for _, d in sorted_players[TOP_N:]) rate_x = (sq_total - top20_sum) / other_sum if other_sum > 0 else 0.0 total_eff = 0.0 for pid in selected_ids: raw = sq_data[pid]["points"] idx = next(i for i, (p, _) in enumerate(sorted_players) if p == pid) eff = raw if idx < TOP_N else raw * rate_x total_eff += eff remaining = [(pid, d) for pid, d in sorted_players if pid not in selected_ids] new_top20 = sum(d["points"] for _, d in remaining[:TOP_N]) new_other = sum(d["points"] for _, d in remaining[TOP_N:]) new_total = new_top20 + rate_x * new_other real_loss = sq_total - new_total # --- Leaderboard projection --- current_rank, current_rating = await get_current_squadron_placement(squadron_name, squadron_short_key) _, ratings_desc = await load_leaderboard_readonly(SQUADRONS_DB_PATH) # --- Build embed --- e = discord.Embed( title=t(lang, "loss_calc.title", squadron=esc(squadron_name)), color=discord.Color.blurple(), ) e.add_field( name=t(lang, "loss_calc.players_leaving_field"), value=", ".join( f"{esc(sq_data[p]['nick'])} ({sq_data[p]['points']} pts)" for p in selected_ids ), inline=False, ) e.add_field(name=t(lang, "loss_calc.share_of_total_field"), value=f"{(total_eff / sq_total * 100):.3f}%", inline=True) e.add_field(name=t(lang, "loss_calc.points_lost_real_field"), value=f"{real_loss:.1f}", inline=True) e.add_field(name=t(lang, "loss_calc.points_lost_raw_field"), value=f"{total_eff:.1f}", inline=True) if current_rating is not None and current_rank is not None: projected_rating = max(0.0, current_rating - real_loss) # Exclude our own rating so we rank against other squadrons only other_ratings = [r for i, r in enumerate(ratings_desc) if i != current_rank - 1] projected_rank = project_rank(other_ratings, projected_rating) positions_lost = max(0, projected_rank - current_rank) e.add_field(name=t(lang, "loss_calc.squadron_rating_field"), value=f"{current_rating:.0f} -> {projected_rating:.0f}", inline=True) e.add_field(name=t(lang, "loss_calc.squadron_position_field"), value=f"#{current_rank} -> #{projected_rank}", inline=True) e.add_field(name=t(lang, "loss_calc.positions_lost_field"), value=str(positions_lost), inline=True) if not_found: e.set_footer(text=t(lang, "loss_calc.not_found_footer", players=", ".join(not_found))) await interaction.followup.send(embed=e, ephemeral=True) @loss_calculator.error async def loss_calculator_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @bot.tree.command(name="website", description=command_locale("Get a link to the SRE Bot website", "commands.website.description")) async def website(interaction: discord.Interaction): """Send the SRE Bot website URL.""" await collect_command_stats(interaction) await interaction.response.send_message("https://sre.pawjob.us/") @website.error async def website_perm_error(interaction, error): await permission_fail(interaction, error) # ═══════════════════════════════════════════════════════════════════════════ # PLAYER AUTOCOMPLETE (shared by /player-stats and /compare) # ═══════════════════════════════════════════════════════════════════════════ async def player_autocomplete( interaction: discord.Interaction, current: str, ) -> List[discord.app_commands.Choice[str]]: """Autocomplete for player nicknames from the battle history DB.""" if not current or len(current) < 2: return [] try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: await db.create_function("ulower", 1, str.lower) async with db.execute( """ SELECT nick, UID FROM ( SELECT nick, UID, MAX(session_id) as last_seen FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE GROUP BY UID ORDER BY CASE WHEN ulower(nick) = ulower(?) THEN 0 WHEN ulower(nick) LIKE ulower(?) THEN 1 ELSE 2 END, last_seen DESC LIMIT 25 ) """, (f"{current}%", current, f"{current}%"), ) as cursor: rows = await cursor.fetchall() return [ discord.app_commands.Choice(name=row[0][:100], value=row[0][:100]) for row in rows ] except Exception as e: print(f"[AUTOCOMPLETE ERROR] player_autocomplete failed for '{current}': {e}\n{traceback.format_exc()}", flush=True) return [] # ═══════════════════════════════════════════════════════════════════════════ # PLAYER SEASON RECAP CARD (/card) # ═══════════════════════════════════════════════════════════════════════════ async def _send_player_card( interaction: discord.Interaction, uid: int, nick: str, season: str, theme: str, lang: str, *, followup: bool, ): """Render and send a player card; shared by /card and the disambiguation view.""" try: path = await get_player_recap(uid, season, theme, _recap_lang(lang)) except RecapError: embed = discord.Embed( title=t(lang, "common.error_title"), description=t(lang, "recap_card.render_failed"), color=discord.Color.red(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) if followup: await interaction.followup.send(embed=embed, ephemeral=True) else: await interaction.edit_original_response(embed=embed, view=None) return safe_name = re.sub(r'[^A-Za-z0-9_-]+', '_', nick) or str(uid) filename = f"{safe_name}-{season}.png" file = discord.File(path, filename=filename) if followup: await interaction.followup.send(file=file) else: await interaction.edit_original_response( content=None, attachments=[file], view=None ) class CardPlayerSelectView(View): """Disambiguation dropdown when a nick resolves to multiple UIDs.""" def __init__(self, results, author: discord.abc.User, season: str, theme: str, lang: str = "en"): super().__init__(timeout=60) self.author = author self.results = results self.season = season self.theme = theme self.lang = lang options = [ discord.SelectOption( label=row["nick"][:100], description=f"UID: {row['UID']}"[:100], value=str(row["UID"])[:100], ) for row in results[:25] ] self.select = Select( placeholder=t(lang, "player.select_player_placeholder"), options=options, min_values=1, max_values=1, ) self.select.callback = self.select_callback self.add_item(self.select) async def interaction_check(self, interaction: discord.Interaction) -> bool: return interaction.user.id == self.author.id async def select_callback(self, interaction: discord.Interaction): await interaction.response.defer() uid = int(self.select.values[0]) nick = next( (r["nick"] for r in self.results if str(r["UID"]) == self.select.values[0]), str(uid), ) await _send_player_card( interaction, uid, nick, self.season, self.theme, self.lang, followup=False ) @is_blacklisted() @gate_entitle("standard") @bot.tree.command(name="card", description=command_locale("Generate a season recap card for a player", "commands.card.description")) @app_commands.describe( season=command_locale("The season to generate the card for", "commands.common.season"), player=command_locale("The player's username", "commands.common.player_username"), theme=command_locale("Card color theme", "commands.common.theme"), ) @app_commands.choices(theme=RECAP_THEME_CHOICES) @discord.app_commands.autocomplete( season=seasons_autocomplete, player=player_autocomplete, ) async def card( interaction: discord.Interaction, season: str, player: str = "", theme: app_commands.Choice[str] | None = None, ): """Generate and send a season recap card PNG for a player. If multiple players share the nick, shows a disambiguation dropdown. Args: interaction: The Discord interaction. season: Season identifier (e.g. "2026-II"). player: Player's War Thunder username. theme: "dark" (default) or "light". """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' theme_value = theme.value if theme else 'dark' seasons = get_seasons() if season not in seasons: embed = discord.Embed( title=t(lang, "common.error_title"), description=t(lang, "recap_card.unknown_season", season=season), color=discord.Color.red(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) # No player given — fall back to the caller's linked account, if any. if not player: linked_uid = get_linked_uid(interaction.user.id) if not linked_uid: return await interaction.followup.send( t(lang, "player.must_provide_or_link"), ephemeral=True ) nick = await _latest_nick_for_uid(linked_uid) return await _send_player_card( interaction, int(linked_uid), nick, season, theme_value, lang, followup=True, ) try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row await db.create_function("ulower", 1, str.lower) async with db.execute( """ SELECT UID, MIN(nick) AS nick FROM player_games_hist WHERE ulower(nick) LIKE ulower(?) GROUP BY UID ORDER BY nick LIMIT 25 """, (f"%{player}%",), ) as cursor: results = list(await cursor.fetchall()) except Exception as e: error_str = str(e)[:1800] return await interaction.followup.send( t(lang, "common.database_error", error=error_str), ephemeral=True ) if not results: return await interaction.followup.send( t(lang, "player.no_players_found", username=player), ephemeral=True ) if len(results) > 1: return await interaction.followup.send( t(lang, "player.multiple_matches"), view=CardPlayerSelectView( results, interaction.user, season, theme_value, lang=lang ), ) await _send_player_card( interaction, int(results[0]["UID"]), results[0]["nick"], season, theme_value, lang, followup=True, ) @card.error async def card_error(interaction, error): await permission_fail(interaction, error) # ═══════════════════════════════════════════════════════════════════════════ # PLAYER STATS WITH VEHICLE BREAKDOWN # ═══════════════════════════════════════════════════════════════════════════ class PlayerSelectViewForStats(View): """View for selecting a player when multiple matches are found""" def __init__(self, results, author: discord.abc.User, lang: str = "en"): super().__init__(timeout=30) self.author = author self.results = results self.lang = lang options = [ discord.SelectOption( label=row["nick"][:100], description=f"UID: {row['UID']}"[:100], value=str(row["UID"])[:100] ) for row in results[:25] # Discord limit of 25 options ] self.select = Select( placeholder=t(lang, "player.select_player_placeholder"), options=options, min_values=1, max_values=1 ) self.select.callback = self.select_callback self.add_item(self.select) async def interaction_check(self, interaction: discord.Interaction) -> bool: return interaction.user.id == self.author.id async def select_callback(self, interaction: discord.Interaction): """Fetch aggregated vehicle stats for the selected player and show VehicleStatsView.""" uid = self.select.values[0] # Defer to show thinking state await interaction.response.defer() # Fetch vehicle stats for selected player try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row # Get player info async with db.execute( "SELECT nick, squadron_name FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1", (uid,) ) as cursor: player_row = await cursor.fetchone() if not player_row: await interaction.followup.send(t(self.lang, "player.no_stats_found", uid=uid), ephemeral=True) return # Get aggregated vehicle stats async with db.execute( """ SELECT vehicle_internal, vehicle, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses, COUNT(*) as total_battles FROM player_games_hist WHERE UID = ? GROUP BY vehicle_internal ORDER BY total_battles DESC """, (uid,) ) as cursor: vehicle_rows = await cursor.fetchall() except Exception as e: error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e) await interaction.followup.send(t(self.lang, "common.database_error", error=error_str), ephemeral=True) return if not vehicle_rows: await interaction.followup.send(t(self.lang, "player.no_vehicle_stats"), ephemeral=True) return # Convert rows to dicts with calculated win rate vehicle_stats = [] for row in vehicle_rows: wins = row['wins'] or 0 losses = row['losses'] or 0 total_battles = row['total_battles'] or 0 win_rate = '0.0' if total_battles > 0 and wins >= 0: win_rate = f"{(wins / total_battles * 100):.1f}" vehicle_name = utils.apply_vehicle_name_filters(row['vehicle']) if row['vehicle'] else "" vehicle_stats.append({ 'vehicle_internal': row['vehicle_internal'], 'vehicle': vehicle_name or row['vehicle_internal'], 'total_ground_kills': row['total_ground_kills'] or 0, 'total_air_kills': row['total_air_kills'] or 0, 'total_assists': row['total_assists'] or 0, 'total_captures': row['total_captures'] or 0, 'total_deaths': row['total_deaths'] or 0, 'wins': wins, 'losses': losses, 'total_battles': total_battles, 'win_rate': win_rate }) player_info = { 'nick': player_row['nick'], 'squadron': player_row['squadron_name'], 'uid': uid } # Update message with vehicle dropdown await interaction.edit_original_response( content=t(self.lang, "player.vehicles_found", count=len(vehicle_stats), nick=esc(player_info['nick'])), view=VehicleStatsView(vehicle_stats, player_info, self.author, lang=self.lang) ) class VehicleStatsView(View): """View for selecting a vehicle to see detailed stats with pagination""" def __init__(self, vehicle_stats: list, player_info: dict, author: discord.abc.User, page: int = 0, lang: str = "en"): super().__init__(timeout=60) self.author = author self.vehicle_stats = vehicle_stats self.player_info = player_info self.page = page self.lang = lang self.total_pages = (len(vehicle_stats) + 24) // 25 # Ceiling division # Get current page of vehicles start_idx = page * 25 end_idx = min(start_idx + 25, len(vehicle_stats)) current_vehicles = vehicle_stats[start_idx:end_idx] # Create dropdown options from vehicle stats options = [] for idx, vehicle in enumerate(current_vehicles): actual_idx = start_idx + idx # Calculate K/D ratio total_kills = vehicle['total_ground_kills'] + vehicle['total_air_kills'] deaths = vehicle['total_deaths'] if vehicle['total_deaths'] > 0 else 1 kd = round(total_kills / deaths, 2) # Create label with vehicle name and basic stats label = vehicle['vehicle'][:100] if vehicle['vehicle'] else f"Vehicle {actual_idx+1}" description = f"Battles: {vehicle['total_battles']} | K/D: {kd} | WR: {vehicle['win_rate']}%" options.append(discord.SelectOption( label=label[:100], description=description[:100], value=str(actual_idx) # Use actual index in full list )) self.select = Select( placeholder=t(lang, "player.vehicle_select_placeholder", page=page + 1, total=self.total_pages), options=options, min_values=1, max_values=1 ) self.select.callback = self.select_callback self.add_item(self.select) # Add pagination buttons if needed if self.total_pages > 1: prev_button = discord.ui.Button( label=t(lang, "buttons.prev_arrow"), style=discord.ButtonStyle.secondary, disabled=(page == 0) ) next_button = discord.ui.Button( label=t(lang, "buttons.next_arrow"), style=discord.ButtonStyle.secondary, disabled=(page >= self.total_pages - 1) ) async def prev_callback(interaction: discord.Interaction): await interaction.response.edit_message( content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])), view=VehicleStatsView(self.vehicle_stats, self.player_info, self.author, self.page - 1, lang=self.lang) ) async def next_callback(interaction: discord.Interaction): await interaction.response.edit_message( content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])), view=VehicleStatsView(self.vehicle_stats, self.player_info, self.author, self.page + 1, lang=self.lang) ) prev_button.callback = prev_callback next_button.callback = next_callback self.add_item(prev_button) self.add_item(next_button) async def interaction_check(self, interaction: discord.Interaction) -> bool: return interaction.user.id == self.author.id async def select_callback(self, interaction: discord.Interaction): """Build a detailed stats embed for the selected vehicle and refresh the same message.""" # Get selected vehicle stats vehicle_idx = int(self.select.values[0]) vehicle = self.vehicle_stats[vehicle_idx] # Calculate additional stats total_kills = vehicle['total_ground_kills'] + vehicle['total_air_kills'] deaths = vehicle['total_deaths'] if vehicle['total_deaths'] > 0 else 1 kd_ratio = round(total_kills / deaths, 2) win_rate = vehicle['win_rate'] # Find vehicle icon vehicle_internal = vehicle['vehicle_internal'] icon_filename = f"{vehicle_internal}.png" icon_path = ICONS_DIR / "VEHICLES" / icon_filename # Use not_found.png if vehicle icon doesn't exist if not icon_path.exists(): icon_path = ICONS_DIR / "not_found.png" icon_filename = "not_found.png" # Create embed with vehicle stats embed = discord.Embed( title=f"{vehicle['vehicle']}", description=t(self.lang, "player.stats_desc", nick=esc(self.player_info['nick']), squadron=self.player_info['squadron'], uid=self.player_info['uid']), color=discord.Color.blue() ) # Set vehicle icon as thumbnail embed.set_thumbnail(url=f"attachment://{icon_filename}") # Combat stats gk = f"{vehicle['total_ground_kills']:,}" ak = f"{vehicle['total_air_kills']:,}" tk = f"{total_kills:,}" ast = f"{vehicle['total_assists']:,}" dth = f"{vehicle['total_deaths']:,}" cap = f"{vehicle['total_captures']:,}" embed.add_field( name="\u200b", value=( f"{t(self.lang, 'player.combat_stats_header')}\n" f"{t(self.lang, 'player.ground_kills_label', value=gk)}\n" f"{t(self.lang, 'player.air_kills_label', value=ak)}\n" f"{t(self.lang, 'player.total_kills_label', value=tk)}\n" f"{t(self.lang, 'player.assists_label', value=ast)}\n" f"{t(self.lang, 'player.deaths_label', value=dth)}\n" f"{t(self.lang, 'player.kd_label', value=kd_ratio)}\n" f"{t(self.lang, 'player.captures_label', value=cap)}\n" ), inline=False ) # Battle record tb = f"{vehicle['total_battles']:,}" wn = f"{vehicle['wins']:,}" ls = f"{vehicle['losses']:,}" embed.add_field( name="\u200b", value=( f"{t(self.lang, 'player.battle_record_header')}\n" f"{t(self.lang, 'player.total_battles_label', value=tb)}\n" f"{t(self.lang, 'player.wins_label', value=wn)}\n" f"{t(self.lang, 'player.losses_label', value=ls)}\n" f"{t(self.lang, 'player.win_rate_label', value=win_rate)}" ), inline=False ) embed.set_footer(text=DEFAULT_FOOTER_CAT) # Create file object for the icon file = discord.File(fp=icon_path, filename=icon_filename) await interaction.response.edit_message( content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])), embed=embed, attachments=[file], view=self, ) @is_blacklisted() @bot.tree.command( name="player-stats", description=command_locale("View detailed vehicle statistics for a player", "commands.player_stats.description") ) @app_commands.describe( username=command_locale("The WT username for stats request", "commands.player_stats.username"), uid=command_locale("The WT UID for stats request", "commands.player_stats.uid") ) @discord.app_commands.autocomplete(username=player_autocomplete) async def player_stats(interaction: discord.Interaction, username: str = "", uid: str = ""): """View per-vehicle battle statistics for a player. Resolves the player by UID or username search. If multiple username matches are found, shows a PlayerSelectViewForStats dropdown. Otherwise fetches aggregated vehicle stats and presents a VehicleStatsView with paginated vehicle dropdown. Args: interaction: The Discord interaction. username: War Thunder username to search for. uid: War Thunder UID for direct lookup. """ await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' player_row = None vehicle_rows: list = [] _VEHICLE_STATS_SQL = """ SELECT vehicle_internal, vehicle, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses, COUNT(*) as total_battles FROM player_games_hist WHERE UID = ? GROUP BY vehicle_internal ORDER BY total_battles DESC """ # Handle UID lookup if uid: await interaction.response.defer(thinking=True) target_uid = uid elif username: await interaction.response.defer(thinking=True) # Search, then fetch all vehicle data in a single connection. try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row # Exact match uses idx_pgh_nick; fall back to substring LIKE only if needed. async with db.execute( """ SELECT UID, MIN(nick) AS nick FROM player_games_hist WHERE nick = ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25 """, (username,), ) as cursor: results = list(await cursor.fetchall()) if not results: async with db.execute( """ SELECT UID, MIN(nick) AS nick FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25 """, (f"%{username}%",), ) as cursor: results = list(await cursor.fetchall()) if not results: await interaction.followup.send( t(lang, "player.no_players_found", username=username), ephemeral=True ) return elif len(results) > 1: await interaction.followup.send( t(lang, "player.multiple_matches"), view=PlayerSelectViewForStats(results, interaction.user, lang=lang) ) return target_uid = results[0]["UID"] # Fetch vehicle stats in the same connection to avoid a second open/close. async with db.execute( "SELECT nick, squadron_name FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1", (target_uid,) ) as cursor: player_row = await cursor.fetchone() if not player_row: await interaction.followup.send(t(lang, "player.no_stats_found", uid=target_uid), ephemeral=True) return async with db.execute(_VEHICLE_STATS_SQL, (target_uid,)) as cursor: vehicle_rows = list(await cursor.fetchall()) except Exception as e: error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e) await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True) return else: # No explicit input — fall back to the caller's linked account, if any. linked_uid = get_linked_uid(interaction.user.id) if not linked_uid: await interaction.response.send_message( t(lang, "player.must_provide_or_link"), ephemeral=True ) return await interaction.response.defer(thinking=True) uid = linked_uid target_uid = linked_uid # UID path: fetch vehicle stats (username path already has them from above). if uid: try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT nick, squadron_name FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1", (target_uid,) ) as cursor: player_row = await cursor.fetchone() if not player_row: await interaction.followup.send(t(lang, "player.no_stats_found", uid=target_uid), ephemeral=True) return async with db.execute(_VEHICLE_STATS_SQL, (target_uid,)) as cursor: vehicle_rows = list(await cursor.fetchall()) except Exception as e: error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e) await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True) return if not vehicle_rows: await interaction.followup.send(t(lang, "player.no_vehicle_stats"), ephemeral=True) return # Convert rows to dicts with calculated win rate vehicle_stats = [] for row in vehicle_rows: wins = row['wins'] or 0 losses = row['losses'] or 0 total_battles = row['total_battles'] or 0 win_rate = '0.0' if total_battles > 0 and wins >= 0: win_rate = f"{(wins / total_battles * 100):.1f}" vehicle_name = utils.apply_vehicle_name_filters(row['vehicle']) if row['vehicle'] else "" vehicle_stats.append({ 'vehicle_internal': row['vehicle_internal'], 'vehicle': vehicle_name or row['vehicle_internal'], 'total_ground_kills': row['total_ground_kills'] or 0, 'total_air_kills': row['total_air_kills'] or 0, 'total_assists': row['total_assists'] or 0, 'total_captures': row['total_captures'] or 0, 'total_deaths': row['total_deaths'] or 0, 'wins': wins, 'losses': losses, 'total_battles': total_battles, 'win_rate': win_rate }) assert player_row is not None player_info = { 'nick': player_row['nick'], 'squadron': player_row['squadron_name'], 'uid': target_uid } # Send initial message with dropdown await interaction.followup.send( t(lang, "player.vehicles_found", count=len(vehicle_stats), nick=esc(player_info['nick'])), view=VehicleStatsView(vehicle_stats, player_info, interaction.user, lang=lang) ) @player_stats.error async def player_stats_perm_error(interaction, error): await permission_fail(interaction, error) async def _resolve_player_for_link(username: str) -> list: """Return [{UID, nick}] rows matching a username (exact, then substring).""" async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT UID, MIN(nick) AS nick FROM player_games_hist " "WHERE nick = ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25", (username,), ) as cursor: results = list(await cursor.fetchall()) if not results: async with db.execute( "SELECT UID, MIN(nick) AS nick FROM player_games_hist " "WHERE nick LIKE ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25", (f"%{username}%",), ) as cursor: results = list(await cursor.fetchall()) return results async def _latest_nick_for_uid(uid: str) -> str: """Best-effort latest nick for a UID; falls back to the UID string.""" try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT nick FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1", (uid,), ) as cursor: row = await cursor.fetchone() if row: return row["nick"] except Exception: pass return uid class SetPlayerSelectView(View): """Select which matched player to link to the invoking Discord user.""" def __init__(self, results, author: discord.abc.User, lang: str = "en"): super().__init__(timeout=30) self.author = author self.lang = lang options = [ discord.SelectOption( label=row["nick"][:100], description=f"UID: {row['UID']}"[:100], value=str(row["UID"])[:100], ) for row in results[:25] ] self.select = Select( placeholder=t(lang, "player.select_player_placeholder"), options=options, min_values=1, max_values=1, ) self.select.callback = self.select_callback self.add_item(self.select) async def interaction_check(self, interaction: discord.Interaction) -> bool: return interaction.user.id == self.author.id async def select_callback(self, interaction: discord.Interaction): uid = self.select.values[0] nick = await _latest_nick_for_uid(uid) save_player_link(interaction.user.id, uid) await interaction.response.edit_message( content=t(self.lang, "player.link_success", nick=esc(nick), uid=uid), view=None, ) @is_blacklisted() @bot.tree.command( name="set-player", description=command_locale("Link your Discord account to a War Thunder player", "commands.set_player.description"), ) @app_commands.describe( username=command_locale("The WT username to link", "commands.set_player.username"), uid=command_locale("The WT UID to link", "commands.set_player.uid"), ) @discord.app_commands.autocomplete(username=player_autocomplete) async def set_player(interaction: discord.Interaction, username: str = "", uid: str = ""): """Link the invoking Discord user to a WT account in the shared PLAYERS.json. Once linked, commands like /player-stats default to this account when run without an explicit username/uid. """ await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' if uid: await interaction.response.defer(thinking=True, ephemeral=True) nick = await _latest_nick_for_uid(uid) save_player_link(interaction.user.id, uid) await interaction.followup.send( t(lang, "player.link_success", nick=esc(nick), uid=uid), ephemeral=True ) return if username: await interaction.response.defer(thinking=True, ephemeral=True) try: results = await _resolve_player_for_link(username) except Exception as e: error_str = str(e)[:1800] await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True) return if not results: await interaction.followup.send( t(lang, "player.no_players_found", username=username), ephemeral=True ) return if len(results) > 1: await interaction.followup.send( t(lang, "player.link_select"), view=SetPlayerSelectView(results, interaction.user, lang=lang), ephemeral=True, ) return target_uid = str(results[0]["UID"]) save_player_link(interaction.user.id, target_uid) await interaction.followup.send( t(lang, "player.link_success", nick=esc(results[0]["nick"]), uid=target_uid), ephemeral=True, ) return await interaction.response.send_message(t(lang, "player.must_provide_input"), ephemeral=True) @set_player.error async def set_player_perm_error(interaction, error): await permission_fail(interaction, error) # ═══════════════════════════════════════════════════════════════════════════ # /view-player-games — Last 20 games for a player # ═══════════════════════════════════════════════════════════════════════════ class FindPlayerView(discord.ui.View): """Paginated embed view for displaying a player's recent game history. Each page contains embed fields for a subset of sessions, with previous/next buttons for navigation. """ def __init__(self, pages: list[list[tuple[str, str]]], summary_desc: str, player_nick: str, lang: str = "en"): super().__init__(timeout=120) self.message: Optional[discord.Message] = None self.pages = pages self.summary_desc = summary_desc self.player_nick = player_nick self.lang = lang self.page = 0 self._update_buttons() self.prev_btn.label = t(lang, "buttons.prev_arrow_only") self.next_btn.label = t(lang, "buttons.next_arrow_only") def _update_buttons(self) -> None: """Enable or disable prev/next buttons based on the current page index.""" self.prev_btn.disabled = self.page == 0 self.next_btn.disabled = self.page >= len(self.pages) - 1 def build_embed(self) -> discord.Embed: """Build the embed for the current page of game history fields. Returns: Discord Embed with session fields for the current page. """ embed = discord.Embed( title=self.player_nick, description=self.summary_desc, color=discord.Color.blurple(), ) for name, value in self.pages[self.page]: embed.add_field(name=name, value=value, inline=False) footer = f"Page {self.page + 1}/{len(self.pages)} • {DEFAULT_FOOTER_CAT}" if len(self.pages) > 1 else DEFAULT_FOOTER_CAT embed.set_footer(text=footer) return embed async def on_timeout(self): for item in self.children: item.disabled = True # type: ignore[attr-defined] try: if self.message: await self.message.edit(view=self) except Exception: pass @discord.ui.button(label="◀", style=discord.ButtonStyle.secondary) async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page -= 1 self._update_buttons() await interaction.response.edit_message(embed=self.build_embed(), view=self) @discord.ui.button(label="▶", style=discord.ButtonStyle.secondary) async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page += 1 self._update_buttons() await interaction.response.edit_message(embed=self.build_embed(), view=self) @is_blacklisted() @bot.tree.command( name="view-player-games", description=command_locale("View the last 20 games for a player", "commands.view_player_games.description") ) @app_commands.describe(player=command_locale("The player's username", "commands.common.player_username")) @discord.app_commands.autocomplete(player=player_autocomplete) async def view_player_games(interaction: discord.Interaction, player: str = ""): """Display a player's recent squadron battle sessions with win/loss, comps, and opponents. Resolves the player nickname to a UID, queries sessions from the last 8 hours in sq_battles.db, enriches each session with opponent squadron and match summary data, and presents the results in a paginated FindPlayerView. Args: interaction: The Discord interaction. player: The player's username (supports autocomplete). """ await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' await interaction.response.defer(ephemeral=False) if not player: # Fall back to the caller's linked account, if any. linked_uid = get_linked_uid(interaction.user.id) if not linked_uid: return await interaction.followup.send( t(lang, "player.must_provide_or_link"), ephemeral=True ) target_uid = linked_uid player_nick = esc(await _latest_nick_for_uid(linked_uid)) else: # Resolve player nick → most-recently-seen UID try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row await db.create_function("ulower", 1, str.lower) async with db.execute( """ SELECT UID, nick, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE ulower(nick) = ulower(?) GROUP BY UID ORDER BY last_seen DESC LIMIT 1 """, (player,), ) as cursor: uid_row = await cursor.fetchone() except Exception as e: return await interaction.followup.send(t(lang, "common.database_error", error=str(e)[:1800]), ephemeral=True) if not uid_row: return await interaction.followup.send( embed=discord.Embed( title=t(lang, "player.not_found_title"), description=t(lang, "player.not_found_desc", player=esc(player)), color=discord.Color.red(), ).set_footer(text=DEFAULT_FOOTER_CAT) ) target_uid = uid_row["UID"] player_nick = esc(uid_row["nick"]) # Query last 20 sessions try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT squadron_tagged FROM player_games_hist WHERE UID = ? ORDER BY endtime_unix DESC LIMIT 1", (target_uid,), ) as cursor: sq_row = await cursor.fetchone() sq_tag = sq_row["squadron_tagged"] if sq_row else "" cutoff = int(time_module.time()) - 8 * 3600 async with db.execute( """ SELECT session_id, MAX(endtime_unix) AS endtime_unix, MAX(victor_bool) AS victor_bool, GROUP_CONCAT(vehicle_internal, '||') AS vehicles FROM player_games_hist WHERE UID = ? AND endtime_unix >= ? GROUP BY session_id ORDER BY MAX(endtime_unix) DESC """, (target_uid, cutoff), ) as cursor: sessions = list(await cursor.fetchall()) except Exception as e: return await interaction.followup.send(t(lang, "common.database_error", error=str(e)[:1800]), ephemeral=True) if not sessions: return await interaction.followup.send( embed=discord.Embed( title=t(lang, "player_games.no_recent_title"), description=t(lang, "player_games.no_recent_desc", player=player_nick), color=discord.Color.red(), ).set_footer(text=DEFAULT_FOOTER_CAT) ) # Fetch match_summary for opponent, map, and full team JSON session_ids = [s["session_id"] for s in sessions] placeholders = ",".join("?" * len(session_ids)) try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( f"SELECT session_id, map_name, winning_sq, losing_sq, winning_team_json, losing_team_json " f"FROM match_summary WHERE session_id IN ({placeholders})", session_ids, ) as cursor: ms_rows = await cursor.fetchall() except Exception: ms_rows = [] ms_map = {r["session_id"]: r for r in ms_rows} _type_order = {"F": 0, "B": 1, "H": 2, "L": 3, "T": 4, "AA": 5, "?": 6} _comp_order = [("F",), ("B",), ("H",), ("L",), ("T",), ("AA",), ("?",)] def _comp_notation(veh_internals: list[str]) -> str: """Build a compact composition notation string (e.g. '2F / 1T / 1AA') from vehicle internals. Args: veh_internals: List of internal vehicle name strings. Returns: Formatted composition string, or '—' if empty. """ nd = count_unit_types(veh_internals) parts = [f"{nd[code]}{code}" for (code,) in _comp_order if nd.get(code, 0) > 0] return " / ".join(parts) or "—" def _team_block(players: list[dict]) -> str: """Format a team's player list into a fixed-width code block sorted by vehicle type. Args: players: List of player dicts with 'nick', 'vehicle', and 'vehicle_new' keys. Returns: Discord code block string with aligned nick | vehicle rows. """ sorted_players = sorted( players, key=lambda p: _type_order.get(get_unit_type_abbrev(p.get("vehicle")), 6) ) max_nick = max((len(p.get("nick", "?")) for p in sorted_players), default=1) lines = [ f"{p.get('nick', '?'):<{max_nick}} | {p.get('vehicle_new') or normalize_name(p.get('vehicle') or '') or '?'}" for p in sorted_players ] return "```\n" + "\n".join(lines) + "\n```" # Tally wins/losses and build a list of game groups # Each game group is 1–2 (field_name, field_value) tuples wins = 0 losses = 0 comp_counter: dict[str, int] = {} game_groups: list[list[tuple[str, str]]] = [] for s in sessions: is_win = (s["victor_bool"] or "").upper() == "WIN" wins += is_win losses += not is_win ms = ms_map.get(s["session_id"]) if ms: opponent = esc((ms["losing_sq"] if is_win else ms["winning_sq"]) or "?") raw_map = re.sub(r'\[.*?\]', '', ms["map_name"] or "").strip() map_name = esc(raw_map or "Unknown") my_json = ms["winning_team_json"] if is_win else ms["losing_team_json"] opp_json = ms["losing_team_json"] if is_win else ms["winning_team_json"] try: my_players = (decompress_json(my_json) if my_json else {}).get("players", []) except (json.JSONDecodeError, TypeError): my_players = [] try: opp_players = (decompress_json(opp_json) if opp_json else {}).get("players", []) except (json.JSONDecodeError, TypeError): opp_players = [] else: opponent = "?" map_name = "Unknown" my_players = [] opp_players = [] my_veh = [p.get("vehicle") for p in my_players if p.get("vehicle")] or \ [v for v in (s["vehicles"] or "").split("||") if v] my_comp = _comp_notation(my_veh) comp_counter[my_comp] = comp_counter.get(my_comp, 0) + 1 ts = f"" if s["endtime_unix"] else "" icon = "👑" if is_win else "💔" my_sq = esc(sq_tag[1:-1] if len(sq_tag) > 2 else sq_tag) if sq_tag else "?" header = f"{icon} {my_sq} vs {opponent} · {map_name} · {ts}" if my_players or opp_players: my_val = _team_block(my_players) if my_players else f"`{my_comp}`" opp_val = _team_block(opp_players) if opp_players else "`no data`" # Field names appear right above each block — use sq names game_groups.append([(header, my_val), (opponent, opp_val)]) else: game_groups.append([(header, f"`{my_comp}`")]) total = wins + losses wr = f"{wins / total * 100:.1f}" if total > 0 else "0.0" if comp_counter: sorted_comps = sorted(comp_counter.items(), key=lambda x: -x[1]) comps_text = t(lang, "player_games.comps_played_header") + "\n" + "\n".join(f"**×{c}** `{k}`" for k, c in sorted_comps) else: comps_text = "" sq_display = esc(sq_tag[1:-1] if len(sq_tag) > 2 else sq_tag) if sq_tag else 'N/A' summary_desc = ( t(lang, "player_games.squadron_label", squadron=sq_display) + "\n" + t(lang, "player_games.record_label", wins=wins, losses=losses, wr=wr) + comps_text ) # Pack game groups into pages: max 5 games per page, budget 5500 - desc chars field_budget = 5500 - len(summary_desc) pages: list[list[tuple[str, str]]] = [] current_page: list[tuple[str, str]] = [] current_games = 0 current_chars = 0 for group in game_groups: group_chars = sum(len(n) + len(v) for n, v in group) if current_page and (current_games >= 5 or current_chars + group_chars > field_budget): pages.append(current_page) current_page = [] current_games = 0 current_chars = 0 current_page.extend(group) current_games += 1 current_chars += group_chars if current_page: pages.append(current_page) view = FindPlayerView(pages, summary_desc, player_nick, lang=lang) view.message = await interaction.followup.send(embed=view.build_embed(), view=view, wait=True) @view_player_games.error # type: ignore[attr-defined] async def view_player_games_perm_error(interaction, error): await permission_fail(interaction, error) # ═══════════════════════════════════════════════════════════════════════════ # VIEW MATCH # ═══════════════════════════════════════════════════════════════════════════ class ViewMatchSelectView(discord.ui.View): """Dropdown of a player's recent games; selecting one generates the scoreboard.""" def __init__(self, games: list[dict], author: discord.abc.User, lang: str = "en"): super().__init__(timeout=120) self.message: Optional[discord.Message] = None self.author = author self.lang = lang options = [] for g in games[:25]: # Discord select max 25 ts_label = f"" if g["endtime_unix"] else "" icon = "\U0001f451" if (g["victor_bool"] or "").upper() == "WIN" else "\U0001f494" opponent = g.get("opponent") or "?" map_name = re.sub(r'\[.*?\]', '', g.get("map_name") or "").strip() or "Unknown" label = f"{icon} vs {opponent} \u00b7 {map_name}"[:100] options.append(discord.SelectOption( label=label, value=g["session_id"], description=f"ID: {g['session_id']}"[:100], )) select = Select(placeholder=t(lang, "match.select_match_placeholder"), options=options) select.callback = self._on_select self.add_item(select) async def on_timeout(self): for item in self.children: item.disabled = True # type: ignore[attr-defined] try: if self.message: await self.message.edit(view=self) except Exception: pass async def interaction_check(self, interaction: discord.Interaction) -> bool: return interaction.user.id == self.author.id async def _on_select(self, interaction: discord.Interaction): """Handle game selection from the dropdown and generate its scoreboard.""" values = (interaction.data or {}).get("values") or [] # type: ignore[union-attr] if not values: return await interaction.response.send_message(t(self.lang, "common.no_selection_received"), ephemeral=True) session_id: str = values[0] await interaction.response.defer(thinking=True) await _send_view_match_scoreboard(interaction, session_id, lang=self.lang) async def _send_view_match_scoreboard(interaction: discord.Interaction, session_id: str, lang: str = "en"): """Fetch a replay by ID (disk first, then API), build a scoreboard, and send it.""" session_id = session_id.lower() # 1. Try loading from disk replay_data = load_replay_data_from_disk(session_id) # 2. If not on disk, fetch from API (needs decimal ID, not hex) if not replay_data: try: decimal_id = str(int(session_id, 16)) except ValueError: decimal_id = session_id # Already decimal or invalid — let API handle it raw = await fetch_replay_by_id(decimal_id) if not raw: return await interaction.followup.send( embed=discord.Embed( title=t(lang, "match.not_found_title"), description=t(lang, "match.not_found_desc", match_id=esc(session_id)), color=discord.Color.red(), ).set_footer(text=DEFAULT_FOOTER_CAT), ephemeral=True, ) # The API may return {"completed": [...]} directly, {"data": {...}} wrapper, or just the replay dict if "completed" in raw: wrapped = raw elif "data" in raw and isinstance(raw["data"], dict): wrapped = {"completed": [raw["data"]]} else: wrapped = {"completed": [raw]} replay_data = utils.transform_to_local_format(wrapped) if not replay_data: return await interaction.followup.send( embed=discord.Embed( title=t(lang, "match.invalid_data_title"), description=t(lang, "match.invalid_data_desc"), color=discord.Color.red(), ).set_footer(text=DEFAULT_FOOTER_CAT), ephemeral=True, ) # Save to disk for buttons (Chat Log, Battle Log read from disk) replay_dir = replay_session_dir(session_id) replay_dir.mkdir(parents=True, exist_ok=True) raw = json.dumps(replay_data, ensure_ascii=False).encode("utf-8") compressed = await asyncio.to_thread(gzip.compress, raw) async with aiofiles.open(replay_dir / "replay_data.json.gz", "wb") as f: await f.write(compressed) # 3. Translate vehicles translate = LangTableReader("English") 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" # 4. Resolve clan long names squads = [t.get("squadron") for t in replay_data.get("teams", []) if t.get("squadron")] squads_tagged = [t.get("squadron_tagged") for t in replay_data.get("teams", []) if t.get("squadron_tagged")] resolved = await resolve_clans(shorts=squads, tags=squads_tagged) for team, clan_info in zip(replay_data.get("teams", []), resolved): if team and clan_info.get("long_name"): team["squadron_long"] = clan_info["long_name"] # 5. Build scoreboard winner = replay_data.get("winning_team_squadron", "") is_draw = replay_data.get("draw", False) teams = replay_data.get("teams", []) timestamp = replay_data.get("timestamp", 0) map_name = replay_data.get("map", "") match_details: dict = {"utc_timestamp": str(timestamp), "session_id": session_id} 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 output_dir = replay_session_dir(session_id) output_dir.mkdir(parents=True, exist_ok=True) output_path = output_dir / "game_result-not_involved-English.png" if not output_path.exists(): await create_scoreboard( match_details, winner, teams[0] if len(teams) > 0 else {}, teams[1] if len(teams) > 1 else {}, map_name, str(output_path), bar_color="not_involved", is_draw=is_draw, ) if not output_path.exists(): return await interaction.followup.send( embed=discord.Embed( title=t(lang, "match.scoreboard_error_title"), description=t(lang, "match.scoreboard_error_desc"), color=discord.Color.red(), ).set_footer(text=DEFAULT_FOOTER_CAT), ephemeral=True, ) # 6. Send with buttons view = build_scoreboard_view(interaction.guild_id or 0, session_id, lang=lang) with open(output_path, "rb") as f: await interaction.followup.send( file=discord.File(f, filename="game_result.png"), view=view, ) @is_blacklisted() @bot.tree.command( name="view-match", description=command_locale("View a match scoreboard by ID or player", "commands.view_match.description") ) @app_commands.describe( match_id=command_locale("The session hex ID of the match to view", "commands.view_match.match_id"), player_name=command_locale("A player's username to browse recent matches", "commands.view_match.player_name"), ) @discord.app_commands.autocomplete(player_name=player_autocomplete) async def view_match( interaction: discord.Interaction, match_id: Optional[str] = None, player_name: Optional[str] = None, ): """View a match scoreboard by direct session ID or by browsing a player's recent games. If match_id is provided, defers and renders the scoreboard directly via _send_view_match_scoreboard. If player_name is provided, resolves the player's UID, fetches their last 100 sessions with opponent/map info, and presents a ViewMatchSelectView dropdown for the user to pick a match. """ await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' if not match_id and not player_name: return await interaction.response.send_message( embed=discord.Embed( title=t(lang, "match.missing_input_title"), description=t(lang, "match.missing_input_desc"), color=discord.Color.red(), ).set_footer(text=DEFAULT_FOOTER_CAT), ephemeral=True, ) # Direct ID lookup if match_id: await interaction.response.defer(thinking=True) # Normalise: strip leading "0" prefix if provided, strip whitespace match_id = match_id.strip().lstrip("0") or match_id.strip() await _send_view_match_scoreboard(interaction, match_id, lang=lang) return # Player lookup → show dropdown of last 100 games await interaction.response.defer(thinking=True, ephemeral=True) try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: db.row_factory = aiosqlite.Row await db.create_function("ulower", 1, str.lower) # Resolve nick → UID async with db.execute( """ SELECT UID, nick, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE ulower(nick) = ulower(?) GROUP BY UID ORDER BY last_seen DESC LIMIT 1 """, (player_name,), ) as cursor: uid_row = await cursor.fetchone() if not uid_row: return await interaction.followup.send( embed=discord.Embed( title=t(lang, "player.not_found_title"), description=t(lang, "player.not_found_desc", player=esc(player_name or '')), color=discord.Color.red(), ).set_footer(text=DEFAULT_FOOTER_CAT), ephemeral=True, ) target_uid = uid_row["UID"] player_nick = uid_row["nick"] # Fetch last 100 sessions with opponent info async with db.execute( """ SELECT p.session_id, MAX(p.endtime_unix) AS endtime_unix, MAX(p.victor_bool) AS victor_bool, ms.map_name, CASE WHEN UPPER(MAX(p.victor_bool)) = 'WIN' THEN ms.losing_sq ELSE ms.winning_sq END AS opponent FROM player_games_hist p LEFT JOIN match_summary ms ON ms.session_id = p.session_id WHERE p.UID = ? GROUP BY p.session_id ORDER BY MAX(p.endtime_unix) DESC LIMIT 100 """, (target_uid,), ) as cursor: sessions = [dict(row) for row in await cursor.fetchall()] except Exception as e: return await interaction.followup.send( t(lang, "common.database_error", error=str(e)[:1800]), ephemeral=True, ) if not sessions: return await interaction.followup.send( embed=discord.Embed( title=t(lang, "match.no_games_title"), description=t(lang, "match.no_games_desc", player=esc(player_nick)), color=discord.Color.red(), ).set_footer(text=DEFAULT_FOOTER_CAT), ephemeral=True, ) view = ViewMatchSelectView(sessions, interaction.user, lang=lang) view.message = await interaction.followup.send( embed=discord.Embed( title=t(lang, "match.recent_matches_title", player=esc(player_nick)), description=t(lang, "match.recent_matches_desc", count=len(sessions)), color=discord.Color.blurple(), ).set_footer(text=DEFAULT_FOOTER_CAT), view=view, ephemeral=True, wait=True, ) @view_match.error async def view_match_perm_error(interaction, error): await permission_fail(interaction, error) # ═══════════════════════════════════════════════════════════════════════════ # PLAYER COMPARISON # ═══════════════════════════════════════════════════════════════════════════ _COMPARE_STATS_ORDER = [ ("total_battles", "compare.battles_label"), ("wins", "compare.wins_label"), ("losses", "compare.losses_label"), ("win_rate", "compare.win_rate_label"), ("ground_kills", "compare.ground_kills_label"), ("air_kills", "compare.air_kills_label"), ("total_kills", "compare.total_kills_label"), ("assists", "compare.assists_label"), ("deaths", "compare.deaths_label"), ("kd", "compare.kd_label"), ("captures", "compare.captures_label"), ] # Stats where lower is better _LOWER_IS_BETTER = {"deaths", "losses"} async def _resolve_player_uids_batch(db, usernames: List[str], lang: str = "en"): """Resolve multiple usernames in one pass. Returns (uid_list, error_msg) or (None, error_msg).""" uids: List[str] = [] for name in usernames: async with db.execute( """ SELECT UID, nick FROM ( SELECT UID, nick, ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn FROM player_games_hist WHERE ulower(nick) LIKE ulower(?) ) WHERE rn = 1 ORDER BY nick LIMIT 25 """, (f"%{name}%",), ) as cursor: results = list(await cursor.fetchall()) if not results: return None, t(lang, "compare.no_players_found", name=name) if len(results) > 1: matches = ", ".join(esc(r["nick"]) for r in results[:10]) return None, t(lang, "compare.multiple_matches", name=name, matches=matches) uids.append(results[0]["UID"]) return uids, None async def _fetch_players_aggregate_batch(db, uids: List[str]) -> List[Optional[dict]]: """Fetch aggregate stats for multiple UIDs in two batched queries.""" placeholders = ",".join("?" for _ in uids) # Batch 1: latest nick + squadron for each UID info_map: dict = {} async with db.execute( f""" SELECT UID, nick, squadron_name FROM ( SELECT UID, nick, squadron_name, ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn FROM player_games_hist WHERE UID IN ({placeholders}) ) WHERE rn = 1 """, uids, ) as cursor: async for row in cursor: info_map[row["UID"]] = row # Batch 2: aggregate stats for all UIDs at once stats_map: dict = {} async with db.execute( f""" SELECT UID, SUM(ground_kills) as ground_kills, SUM(air_kills) as air_kills, SUM(assists) as assists, SUM(captures) as captures, SUM(deaths) as deaths, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses, COUNT(*) as total_battles FROM player_games_hist WHERE UID IN ({placeholders}) GROUP BY UID """, uids, ) as cursor: async for row in cursor: stats_map[row["UID"]] = row # Assemble results in UID order results: List[Optional[dict]] = [] for uid in uids: info = info_map.get(uid) row = stats_map.get(uid) if not info or not row or not row["total_battles"]: results.append(None) continue total_kills = (row["ground_kills"] or 0) + (row["air_kills"] or 0) deaths = row["deaths"] or 0 total_battles = row["total_battles"] or 0 wins = row["wins"] or 0 losses = row["losses"] or 0 results.append({ "nick": info["nick"], "squadron": info["squadron_name"] or "None", "uid": uid, "ground_kills": row["ground_kills"] or 0, "air_kills": row["air_kills"] or 0, "total_kills": total_kills, "assists": row["assists"] or 0, "captures": row["captures"] or 0, "deaths": deaths, "kd": round(total_kills / deaths, 2) if deaths > 0 else float(total_kills), "wins": wins, "losses": losses, "total_battles": total_battles, "win_rate": f"{(wins / total_battles * 100):.1f}" if total_battles > 0 else "0.0", }) return results def _build_compare_embed(players: List[dict], lang: str = "en") -> Embed: """Build a side-by-side comparison embed for multiple players.""" # Pre-compute best value per stat best: dict = {} all_tied: dict = {} for key, _ in _COMPARE_STATS_ORDER: nums = [float(p[key]) for p in players] if key in _LOWER_IS_BETTER: best[key] = min(nums) else: best[key] = max(nums) all_tied[key] = len(set(nums)) == 1 # Count how many stats each player "wins" to find the overall best win_counts = [] for p in players: count = 0 for key, _ in _COMPARE_STATS_ORDER: if not all_tied[key] and float(p[key]) == best[key]: count += 1 win_counts.append(count) max_wins = max(win_counts) title = " vs ".join(esc(p["nick"]) for p in players) embed = Embed(title=title, color=Color.blue()) for i, p in enumerate(players): star = " \u2b50" if win_counts[i] == max_wins and max_wins > 0 else "" lines = [] for key, label_key in _COMPARE_STATS_ORDER: val = p[key] formatted = f"{val:,}" if isinstance(val, int) else str(val) suffix = "%" if key == "win_rate" else "" is_best = not all_tied[key] and float(p[key]) == best[key] label = t(lang, label_key) line = f"{label}: {formatted}{suffix}" if is_best: line = f"**{line}**" lines.append(line) embed.add_field( name=f"{esc(p['nick'])} [{esc(p['squadron'])}]{star}", value="\n".join(lines), inline=True, ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return embed async def _generate_compare_graph(players: List[dict], lang: str = "en") -> tuple[Optional[Path], List[str]]: """Generate a points-over-time graph for the given players over the last 90 days. Reads per-player points from the squadrons_points table (same source as /sq-stats). Returns (path, missing_nicks) where missing_nicks lists players with no graph data. """ cutoff = int(time_module.time()) - (90 * 86400) uid_set = {p["uid"] for p in players} uid_to_nick = {p["uid"]: p["nick"] for p in players} # player_points: {uid: [(unix_time, points), ...]} player_points: dict = {p["uid"]: [] for p in players} # --- Phase 1: fast indexed lookup via known squadron names --- squadrons_needed: set[str] = set() async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db: await db.execute("PRAGMA busy_timeout=30000;") # Current membership: squadron_members → squadrons_data uid_ph = ",".join("?" for _ in uid_set) async with db.execute( f""" SELECT DISTINCT sd.long_name FROM squadron_members sm JOIN squadrons_data sd ON sm.clan_id = sd.clan_id WHERE sm.uid IN ({uid_ph}) """, list(uid_set), ) as cursor: async for row in cursor: squadrons_needed.add(row[0]) # Also include the squadron from battle history (already in player dict) for p in players: if p["squadron"] and p["squadron"] != "None": squadrons_needed.add(p["squadron"]) # Fast per-squadron queries (uses idx_squadrons_points_longname_time) for sq_name in squadrons_needed: async with db.execute( """ SELECT unix_time, clan_pts FROM squadrons_points WHERE long_name = ? AND unix_time > ? ORDER BY unix_time ASC """, (sq_name, cutoff), ) as cursor: async for row in cursor: try: members_dict, _ = decompress_json(row[1]) for uid_str, pdata in members_dict.items(): if uid_str in uid_set: player_points[uid_str].append( (row[0], pdata["points"]) ) except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError): continue # --- Phase 2: lookup historical squadrons from battle records --- missing_uids = {p["uid"] for p in players if not player_points[p["uid"]]} if missing_uids: # Find squadrons these players played for via sq_battles.db extra_squads: set[str] = set() uid_ph2 = ",".join("?" for _ in missing_uids) async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as sqdb: async with sqdb.execute( f"SELECT DISTINCT squadron_name FROM player_games_hist WHERE UID IN ({uid_ph2})", list(missing_uids), ) as cursor: async for row in cursor: if row[0] and row[0] not in squadrons_needed: extra_squads.add(row[0]) # Query those squadrons using the same indexed path as Phase 1 for sq_name in extra_squads: async with db.execute( """ SELECT unix_time, clan_pts FROM squadrons_points WHERE long_name = ? AND unix_time > ? ORDER BY unix_time ASC """, (sq_name, cutoff), ) as cursor: async for row in cursor: try: members_dict, _ = decompress_json(row[1]) for uid_str, pdata in members_dict.items(): if uid_str in missing_uids: player_points[uid_str].append( (row[0], pdata["points"]) ) except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError): continue # Sort each player's points by timestamp (data may come from multiple squadrons) for uid in player_points: player_points[uid].sort(key=lambda x: x[0]) # Identify players with no data missing_nicks = [uid_to_nick[p["uid"]] for p in players if not player_points[p["uid"]]] # Check if we got any data at all if not any(pts for pts in player_points.values()): return None, missing_nicks # Plot fig, ax = plt.subplots(figsize=(12, 6), facecolor=SQ_STATS_GRAPH_COLORS['bg']) ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) colors = ['#12ed2f', '#ed4012', '#127eed', '#edd012', '#ed12c7', '#12edd0', '#ed8a12'] for idx, p in enumerate(players): pts = player_points[p["uid"]] if pts: times = [mdates.date2num(datetime.fromtimestamp(t)) for t, _ in pts] values = [v for _, v in pts] color = colors[idx % len(colors)] ax.plot(times, values, linewidth=2, color=color, label=uid_to_nick[p["uid"]]) ax.set_xlabel('Date', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) ax.set_ylabel('Points', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) ax.set_title(t(lang, "compare.graph_title"), fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text']) ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid']) ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) for spine in ax.spines.values(): spine.set_color(SQ_STATS_GRAPH_COLORS['text']) ax.legend(facecolor=SQ_STATS_GRAPH_COLORS['plot_bg'], edgecolor=SQ_STATS_GRAPH_COLORS['grid'], labelcolor=SQ_STATS_GRAPH_COLORS['text']) ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d')) fig.autofmt_xdate() plt.tight_layout() temp_path = Path(f"/tmp/compare_{'_'.join(p['uid'] for p in players[:3])}_{int(time_module.time())}.png") plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) plt.close(fig) return temp_path, missing_nicks class CompareGraphView(View): """View with a graph button and per-player website link buttons.""" def __init__(self, players: List[dict], lang: str = "en"): super().__init__(timeout=120) self.players = players self.lang = lang for i, p in enumerate(players): self.add_item(discord.ui.Button( label=p["nick"][:80], style=discord.ButtonStyle.link, url=f"https://sre.pawjob.us/players/{p['uid']}", row=0 if i < 4 else 1, )) self.show_graph.label = t(lang, "buttons.show_graph") @discord.ui.button(label="Show Graph", style=discord.ButtonStyle.blurple, row=2) async def show_graph(self, interaction: discord.Interaction, button: discord.ui.Button): """Fetch 90-day points graph for compared players and attach it to the message.""" await interaction.response.defer() temp_path, missing_nicks = await _generate_compare_graph(self.players, lang=self.lang) if not temp_path: await interaction.followup.send(t(self.lang, "compare.no_graph_data"), ephemeral=True) return button.disabled = True await interaction.edit_original_response(view=self) if missing_nicks: names = ", ".join(f"**{n}**" for n in missing_nicks) await interaction.followup.send( content=t(self.lang, "compare.no_squadron_points_data", names=names), file=discord.File(temp_path), ) else: await interaction.followup.send(file=discord.File(temp_path)) try: temp_path.unlink() except Exception: pass @is_blacklisted() @bot.tree.command(name="compare", description=command_locale("Compare aggregate SQB stats between players", "commands.compare.description")) @app_commands.describe( player1=command_locale("First player username", "commands.compare.player1"), player2=command_locale("Second player username", "commands.compare.player2"), player3=command_locale("Additional player username (optional)", "commands.compare.player_optional"), player4=command_locale("Additional player username (optional)", "commands.compare.player_optional"), player5=command_locale("Additional player username (optional)", "commands.compare.player_optional"), player6=command_locale("Additional player username (optional)", "commands.compare.player_optional"), player7=command_locale("Additional player username (optional)", "commands.compare.player_optional"), ) @discord.app_commands.autocomplete( player1=player_autocomplete, player2=player_autocomplete, player3=player_autocomplete, player4=player_autocomplete, player5=player_autocomplete, player6=player_autocomplete, player7=player_autocomplete, ) async def compare( interaction: discord.Interaction, player1: str, player2: str, player3: str = "", player4: str = "", player5: str = "", player6: str = "", player7: str = "", ): """Compare aggregate squadron battle stats between 2-7 players. Resolves each username to a UID, fetches aggregated battle stats (kills, deaths, win rate, etc.) from sq_battles.db, builds a side-by-side comparison embed, and attaches a CompareGraphView with a graph button and per-player website links. """ await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' await interaction.response.defer(thinking=True) usernames = [p for p in [player1, player2, player3, player4, player5, player6, player7] if p] try: async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: await db.execute("PRAGMA busy_timeout=30000;") await db.create_function("ulower", 1, str.lower) db.row_factory = aiosqlite.Row uids, err = await _resolve_player_uids_batch(db, usernames, lang=lang) if err or uids is None: await interaction.followup.send(err or t(lang, "compare.could_not_resolve"), ephemeral=True) return player_stats = await _fetch_players_aggregate_batch(db, uids) except Exception as e: error_str = str(e)[:1800] await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True) return # Check for any failed lookups players = [] for i, p in enumerate(player_stats): if not p: await interaction.followup.send( t(lang, "compare.could_not_fetch", name=usernames[i]), ephemeral=True) return players.append(p) view = CompareGraphView(players, lang=lang) await interaction.followup.send(embed=_build_compare_embed(players, lang=lang), view=view) @compare.error async def compare_perm_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @bot.tree.command(name="leaderboard", description=command_locale("Get the SRE Bot global leaderboard", "commands.leaderboard.description")) async def leaderboard(interaction: discord.Interaction): """Send the SRE Bot global leaderboard URL.""" await collect_command_stats(interaction) await interaction.response.send_message( "https://sre.pawjob.us/leaderboard/players" ) @leaderboard.error async def leaderboard_perm_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @is_admin() @bot.tree.command(name='set-squadron', description=command_locale('Set the squadron tag for this server', "commands.set_squadron.description")) @app_commands.describe(abbreviated_name=command_locale('The short name of the squadron to set', "commands.set_squadron.abbreviated_name")) @discord.app_commands.autocomplete(abbreviated_name=squadron_autocomplete) async def set_squadron(interaction: discord.Interaction, abbreviated_name: str): """Set or swap the squadron tag for this server. Resolves the abbreviated squadron name via resolve_clan, then either sets it directly in SQUADRONS.json or shows a ConfirmSwapView if a different squadron is already configured for this guild. """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=True) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' squadrons_path = STORAGE_DIR / "SQUADRONS.json" squadrons = await load_json(squadrons_path, {}) guild_id = str(interaction.guild_id) # Clean input new_short = re.sub(r'\W+', '', abbreviated_name) # Resolve using short name only try: clan = await resolve_clan(short=new_short) except Exception as e: logging.error(f"Error resolving squadron {new_short}: {e}") clan = None if not clan or clan["long_name"] == "": embed = Embed( title=t(lang, "common.error_title"), description=t(lang, "squadron.not_found_desc", squadron=new_short), color=Color.red() ) return await interaction.followup.send(embed=embed, ephemeral=True) new_long = clan["long_name"] # Same swap/confirmation logic as before if (guild_id in squadrons and squadrons[guild_id]['SQ_ShortHand_Name'] != new_short): old_long = squadrons[guild_id]['SQ_LongHandName'] class ConfirmSwapView(View): """Confirmation prompt to swap the server's squadron assignment.""" def __init__(self, lang): super().__init__(timeout=60) self.lang = lang self.confirm.label = t(lang, "buttons.confirm_swap") self.cancel.label = t(lang, "buttons.cancel_swap") @button(label="Yes, swap it", style=discord.ButtonStyle.green) async def confirm(self, button_interaction: discord.Interaction, button: discord.ui.Button): squadrons[guild_id] = { "SQ_ShortHand_Name": new_short, "SQ_LongHandName": new_long } await write_json(squadrons_path, squadrons) logging.info(f"Swapped squadron for guild {guild_id}: {old_long} → {new_long}") embed_swapped = Embed( title=t(self.lang, "squadron.swap_title"), description=t(self.lang, "squadron.swap_desc", old=old_long, new=new_long), color=Color.green() ) await button_interaction.response.edit_message(embed=embed_swapped, view=None) self.stop() @button(label="No, keep the old one", style=discord.ButtonStyle.red) async def cancel(self, button_interaction: discord.Interaction, button: discord.ui.Button): await button_interaction.response.edit_message( content=t(self.lang, "squadron.swap_cancelled"), embed=None, view=None ) self.stop() view = ConfirmSwapView(lang) embed_confirm = Embed( title=t(lang, "squadron.already_set_title"), description=t(lang, "squadron.already_set_desc", old=old_long, new=new_long), color=Color.gold() ) return await interaction.followup.send(embed=embed_confirm, view=view, ephemeral=True) # Otherwise set it fresh squadrons[guild_id] = { "SQ_ShortHand_Name": new_short, "SQ_LongHandName": new_long } await write_json(squadrons_path, squadrons) logging.info(f"Set squadron for guild {guild_id} → {new_long}") embed_set = Embed( title=t(lang, "squadron.set_title"), description=t(lang, "squadron.set_desc", squadron=new_long), color=Color.green() ) embed_set.add_field(name=t(lang, "squadron.short_name_field"), value=new_short, inline=True) embed_set.add_field(name=t(lang, "squadron.long_name_field"), value=new_long, inline=True) embed_set.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed_set, ephemeral=False) @set_squadron.error async def set_squadron_error(interaction, error): await permission_fail(interaction, error) # ============================================================================ # SETUP WIZARD # ============================================================================ @dataclass class SetupState: """State bag passed through all setup wizard steps.""" guild_id: str squadron_short: Optional[str] = None squadron_long: Optional[str] = None squadron_clan_id: Optional[int] = None logs_channel_id: Optional[int] = None points_channel_id: Optional[int] = None def _step1_embed(state: SetupState, lang: str = "en") -> Embed: """Build the Step 1 welcome embed.""" desc = t(lang, "setup.step1_desc") if state.squadron_short: desc += t(lang, "setup.step1_current_sq", short=state.squadron_short, long=state.squadron_long) embed = Embed(title=t(lang, "setup.step1_title"), description=desc, color=Color.blue()) embed.set_footer(text=DEFAULT_FOOTER_CAT) return embed def _step2_embed(state: SetupState, lang: str = "en") -> Embed: """Build the Step 2 logs channel embed.""" desc = t(lang, "setup.step2_desc", short=state.squadron_short, long=state.squadron_long) embed = Embed(title=t(lang, "setup.step2_title"), description=desc, color=Color.blue()) embed.set_footer(text=DEFAULT_FOOTER_CAT) return embed def _step3_embed(state: SetupState, lang: str = "en") -> Embed: """Build the Step 3 points channel embed.""" desc = t(lang, "setup.step3_desc") if state.logs_channel_id: desc += t(lang, "setup.step3_same_as_logs") embed = Embed(title=t(lang, "setup.step3_title"), description=desc, color=Color.blue()) embed.set_footer(text=DEFAULT_FOOTER_CAT) return embed async def _summary_embed(state: SetupState, lang: str = "en") -> Embed: """Build the summary embed.""" sq = f"[{state.squadron_short}] {state.squadron_long}" if state.squadron_short else t(lang, "common.not_configured") logs = f"<#{state.logs_channel_id}>" if state.logs_channel_id else t(lang, "common.not_configured") points = f"<#{state.points_channel_id}>" if state.points_channel_id else t(lang, "common.not_configured") embed = Embed( title=t(lang, "setup.summary_title"), description=t(lang, "setup.summary_desc"), color=Color.green(), ) embed.add_field(name=t(lang, "setup.squadron_field"), value=sq, inline=False) embed.add_field(name=t(lang, "setup.logs_channel_field"), value=logs, inline=True) embed.add_field(name=t(lang, "setup.points_channel_field"), value=points, inline=True) if state.logs_channel_id and not await is_guild_entitled(int(state.guild_id)): embed.add_field( name=t(lang, "setup.premium_required_field"), value=t(lang, "setup.premium_required_value"), inline=False, ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return embed class SetupSquadronModal(discord.ui.Modal): """Modal for entering the squadron short name.""" def __init__(self, state: SetupState, lang: str = "en"): super().__init__(title=t(lang, "setup.modal_title")) self.state = state self.lang = lang self.squadron_input = discord.ui.TextInput( label=t(lang, "setup.modal_label"), placeholder=t(lang, "setup.modal_placeholder"), required=True, max_length=50, ) self.add_item(self.squadron_input) async def on_submit(self, interaction: discord.Interaction): raw = self.squadron_input.value.strip() short = re.sub(r'\W+', '', raw) try: clan = await resolve_clan(short=short) except Exception as e: logging.error(f"Error resolving squadron {short} in setup wizard: {e}") clan = None if not clan or clan["long_name"] == "": await interaction.response.send_message( t(self.lang, "setup.squadron_not_found", squadron=raw), ephemeral=True, ) return new_long = clan["long_name"] new_short = clan["short_name"] # Save to SQUADRONS.json (same pattern as /set-squadron) squadrons_path = STORAGE_DIR / "SQUADRONS.json" squadrons = await load_json(squadrons_path, {}) # Only write if value actually changed current = squadrons.get(self.state.guild_id) if not current or current.get("SQ_ShortHand_Name") != new_short: squadrons[self.state.guild_id] = { "SQ_ShortHand_Name": new_short, "SQ_LongHandName": new_long, } await write_json(squadrons_path, squadrons) logging.info(f"Setup wizard: set squadron for guild {self.state.guild_id} → {new_long}") self.state.squadron_short = new_short self.state.squadron_long = new_long clan_id_val = clan.get("clan_id") if isinstance(clan, dict) else None self.state.squadron_clan_id = int(clan_id_val) if clan_id_val else None # Advance to Step 2 view = SetupLogsView(self.state, self.lang) await interaction.response.edit_message(embed=_step2_embed(self.state, self.lang), view=view) class SetupWelcomeView(discord.ui.View): """Step 1 — Welcome + Set Squadron.""" def __init__(self, state: SetupState, lang: str = "en"): super().__init__(timeout=300) self.state = state self.lang = lang self.set_squadron_btn.label = t(lang, "buttons.set_squadron") self.skip_step.label = t(lang, "buttons.skip") # Only show Skip if squadron is already configured if not state.squadron_short: self.skip_step.disabled = True self.remove_item(self.skip_step) @discord.ui.button(label="Set Squadron", style=discord.ButtonStyle.green) async def set_squadron_btn(self, interaction: discord.Interaction, button: discord.ui.Button): modal = SetupSquadronModal(self.state, self.lang) await interaction.response.send_modal(modal) @discord.ui.button(label="Skip", style=discord.ButtonStyle.grey) async def skip_step(self, interaction: discord.Interaction, button: discord.ui.Button): view = SetupLogsView(self.state, self.lang) await interaction.response.edit_message(embed=_step2_embed(self.state, self.lang), view=view) class SetupLogsChannelSelect(discord.ui.ChannelSelect): """Channel select for the logs channel (Step 2).""" def __init__(self, state: SetupState, lang: str = "en"): super().__init__( placeholder=t(lang, "setup.logs_channel_placeholder"), channel_types=[discord.ChannelType.text, discord.ChannelType.news], min_values=1, max_values=1, ) self.state = state self.lang = lang async def callback(self, interaction: discord.Interaction): channel = self.values[0] self.state.logs_channel_id = channel.id # Save to preferences await _save_channel_pref(self.state, "Logs", channel.id) # Advance to Step 3 view = SetupPointsView(self.state, self.lang) await interaction.response.edit_message(embed=_step3_embed(self.state, self.lang), view=view) class SetupLogsView(discord.ui.View): """Step 2 — Set Logs Channel.""" def __init__(self, state: SetupState, lang: str = "en"): super().__init__(timeout=300) self.state = state self.lang = lang self.add_item(SetupLogsChannelSelect(state, lang)) self.skip_step.label = t(lang, "buttons.skip") @discord.ui.button(label="Skip", style=discord.ButtonStyle.grey) async def skip_step(self, interaction: discord.Interaction, button: discord.ui.Button): view = SetupPointsView(self.state, self.lang) await interaction.response.edit_message(embed=_step3_embed(self.state, self.lang), view=view) class SetupPointsChannelSelect(discord.ui.ChannelSelect): """Channel select for the points channel (Step 3).""" def __init__(self, state: SetupState, lang: str = "en"): super().__init__( placeholder=t(lang, "setup.points_channel_placeholder"), channel_types=[discord.ChannelType.text, discord.ChannelType.news], min_values=1, max_values=1, ) self.state = state self.lang = lang async def callback(self, interaction: discord.Interaction): channel = self.values[0] self.state.points_channel_id = channel.id # Save to preferences await _save_channel_pref(self.state, "Points", channel.id) # Advance to Summary await interaction.response.edit_message(embed=await _summary_embed(self.state, self.lang), view=None) class SetupPointsView(discord.ui.View): """Step 3 — Set Points Channel.""" def __init__(self, state: SetupState, lang: str = "en"): super().__init__(timeout=300) self.state = state self.lang = lang self.add_item(SetupPointsChannelSelect(state, lang)) self.same_as_logs_btn.label = t(lang, "buttons.same_as_logs") self.skip_step.label = t(lang, "buttons.skip") # Only show "Same as Logs" if logs channel was set if not state.logs_channel_id: self.remove_item(self.same_as_logs_btn) @discord.ui.button(label="Same as Logs", style=discord.ButtonStyle.blurple) async def same_as_logs_btn(self, interaction: discord.Interaction, button: discord.ui.Button): assert self.state.logs_channel_id is not None # button only shown when logs is set self.state.points_channel_id = self.state.logs_channel_id # Save to preferences await _save_channel_pref(self.state, "Points", self.state.logs_channel_id) await interaction.response.edit_message(embed=await _summary_embed(self.state, self.lang), view=None) @discord.ui.button(label="Skip", style=discord.ButtonStyle.grey) async def skip_step(self, interaction: discord.Interaction, button: discord.ui.Button): await interaction.response.edit_message(embed=await _summary_embed(self.state, self.lang), view=None) async def _save_channel_pref(state: SetupState, alarm_type: str, channel_id: int): """Save a Logs or Points channel preference (same format as quick-log).""" if not state.squadron_long: return guild_id = int(state.guild_id) prefs = await load_guild_preferences(guild_id) # Prefer the stable clan_id as the prefs key (matches quick-log writes). pref_key = str(state.squadron_clan_id) if state.squadron_clan_id else state.squadron_long entry = prefs.setdefault(pref_key, {}) entry[alarm_type] = f"<#{channel_id}>" if state.squadron_long: entry["Long"] = state.squadron_long if state.squadron_short: entry["Short"] = state.squadron_short await save_guild_preferences(guild_id, prefs) logging.info( f"Setup wizard: saved {alarm_type} channel {channel_id} for " f"{state.squadron_long} (key={pref_key}) in guild {state.guild_id}" ) @is_blacklisted() @is_admin() @bot.tree.command(name='setup', description=command_locale('Set up the bot for this server', "commands.setup.description")) async def setup(interaction: discord.Interaction): """Launch the server setup wizard. Pre-fills state from existing SQUADRONS.json config, then presents a three-step wizard: (1) set squadron, (2) choose logs channel, (3) choose points channel. Each step is a Discord UI view with skip/continue buttons. """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=True) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' guild_id = str(interaction.guild_id) # Pre-fill state from existing config state = SetupState(guild_id=guild_id) squadrons_path = STORAGE_DIR / "SQUADRONS.json" squadrons = await load_json(squadrons_path, {}) if guild_id in squadrons: state.squadron_short = squadrons[guild_id].get("SQ_ShortHand_Name") state.squadron_long = squadrons[guild_id].get("SQ_LongHandName") # Resolve clan_id so subsequent prefs writes use the canonical key. if state.squadron_short: try: clan = await resolve_clan(short=state.squadron_short) if clan and clan.get("clan_id"): state.squadron_clan_id = int(clan["clan_id"]) except Exception: pass view = SetupWelcomeView(state, lang) await interaction.followup.send(embed=_step1_embed(state, lang), view=view, ephemeral=True) @setup.error async def setup_error(interaction, error): await permission_fail(interaction, error) class PasswordModal(discord.ui.Modal): """Modal for entering a squadron's access password to authenticate or claim ownership.""" def __init__(self, squadron_clanID: str, squadron_name: str, guild_id: str, is_owner: bool = False, password_hint: str = "", lang: str = "en"): self.lang = lang super().__init__(title=t(lang, "meta_management.password_modal_title")) self.squadron_clanID = squadron_clanID self.squadron_name = squadron_name self.guild_id = guild_id self.is_owner = is_owner placeholder = f"Hint: {password_hint}" if password_hint else t(lang, "meta_management.password_modal_placeholder") self.password_input = discord.ui.TextInput( label=t(lang, "meta_management.password_modal_label"), placeholder=placeholder, required=True, max_length=50 ) self.add_item(self.password_input) async def on_submit(self, interaction: discord.Interaction): """Validate the password, optionally transfer the squadron, and show MetaManagementView. If the submitting server already owns the squadron, re-authenticates without transfer. Otherwise checks the squadron data lock before transferring ownership to the new guild. On success, displays the management settings panel. """ # Validate password password_provided = self.password_input.value.strip() logging.info(f"[PASSWORD CHECK] squadron_clanID: {self.squadron_clanID}, is_owner: {self.is_owner}") is_valid = await validate_squadron_password(self.squadron_clanID, password_provided) if not is_valid: logging.warning(f"[PASSWORD CHECK] FAILED for squadron {self.squadron_clanID}") embed = discord.Embed( title=t(self.lang, "meta_management.access_denied_title"), description=t(self.lang, "meta_management.access_denied_desc"), color=discord.Color.red() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.response.send_message(embed=embed, ephemeral=True) return logging.info(f"[PASSWORD CHECK] SUCCESS for squadron {self.squadron_clanID}") # Get squadron owner settings squadron_owner = await get_squadron_owner(self.squadron_clanID) if self.is_owner: # This server owns the squadron, they're just re-authenticating # No transfer needed, just show settings guild_settings = await get_guild_settings(self.guild_id) else: # Different server trying to claim the squadron # Check if squadron data is locked if squadron_owner and squadron_owner.get("lock_squadron_data", False): logging.warning(f"[PASSWORD CHECK] Transfer BLOCKED - squadron {self.squadron_clanID} is locked") embed = discord.Embed( title=t(self.lang, "meta_management.data_locked_title"), description=t(self.lang, "meta_management.data_locked_desc", squadron=self.squadron_name), color=discord.Color.orange() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.response.send_message(embed=embed, ephemeral=True) return # Password correct and not locked - transfer squadron to this server await transfer_squadron_to_guild(self.squadron_clanID, self.guild_id, self.squadron_name) # Get updated settings after transfer guild_settings = await get_guild_settings(self.guild_id) if not guild_settings: await interaction.response.send_message( t(self.lang, "meta_management.error_retrieving_settings"), ephemeral=True ) return # Show success message with management view if self.is_owner: embed = discord.Embed( title=t(self.lang, "meta_management.authenticated_title"), description=t(self.lang, "meta_management.authenticated_desc", squadron=self.squadron_name), color=discord.Color.green() ) else: embed = discord.Embed( title=t(self.lang, "meta_management.claimed_title"), description=t(self.lang, "meta_management.claimed_desc", squadron=self.squadron_name), color=discord.Color.green() ) embed.add_field( name=t(self.lang, "meta_management.password_requirement_field"), value=t(self.lang, "meta_management.enabled_value") if guild_settings["require_password"] else t(self.lang, "meta_management.disabled_value"), inline=True ) embed.add_field( name=t(self.lang, "meta_management.data_lock_field"), value=t(self.lang, "meta_management.enabled_value") if guild_settings["lock_squadron_data"] else t(self.lang, "meta_management.disabled_value"), inline=True ) embed.add_field( name=t(self.lang, "meta_management.public_meta_field"), value=t(self.lang, "meta_management.enabled_value") if guild_settings["allow_public_meta"] else t(self.lang, "meta_management.disabled_value"), inline=True ) if interaction.guild and interaction.user.id == interaction.guild.owner_id: embed.add_field( name=t(self.lang, "meta_management.access_password_field"), value=f"`{guild_settings['access_password']}`", inline=False ) embed.set_footer(text=DEFAULT_FOOTER_CAT) # Create management view view = MetaManagementView( guild_id=self.guild_id, squadron_name=self.squadron_name, squadron_clanID=self.squadron_clanID, require_password=guild_settings["require_password"], lock_squadron_data=guild_settings["lock_squadron_data"], allow_public_meta=guild_settings["allow_public_meta"], lang=self.lang ) # Update button styles based on current state if guild_settings["require_password"]: button = view.children[0] if isinstance(button, discord.ui.Button): button.style = discord.ButtonStyle.success button.label = t(self.lang, "buttons.password_required") if guild_settings["lock_squadron_data"]: button = view.children[1] if isinstance(button, discord.ui.Button): button.style = discord.ButtonStyle.success button.label = t(self.lang, "buttons.data_locked") if guild_settings["allow_public_meta"]: button = view.children[2] if isinstance(button, discord.ui.Button): button.style = discord.ButtonStyle.success button.label = t(self.lang, "buttons.public_enabled") await interaction.response.send_message(embed=embed, view=view, ephemeral=True) view.message = await interaction.original_response() class ChangePasswordModal(discord.ui.Modal): """Modal for changing a squadron's access password (server owner only).""" def __init__(self, squadron_clanID: str, squadron_name: str, guild_id: str, lang: str = "en"): self.lang = lang super().__init__(title=t(lang, "meta_management.change_pw_modal_title")) self.squadron_clanID = squadron_clanID self.squadron_name = squadron_name self.guild_id = guild_id self.current_password = discord.ui.TextInput( label=t(lang, "meta_management.current_password_label"), placeholder=t(lang, "meta_management.current_password_placeholder"), required=True, max_length=50 ) self.new_password = discord.ui.TextInput( label=t(lang, "meta_management.new_password_label"), placeholder=t(lang, "meta_management.new_password_placeholder"), required=True, max_length=50 ) self.confirm_password = discord.ui.TextInput( label=t(lang, "meta_management.confirm_password_label"), placeholder=t(lang, "meta_management.confirm_password_placeholder"), required=True, max_length=50 ) self.hint_input = discord.ui.TextInput( label=t(lang, "meta_management.hint_label"), placeholder=t(lang, "meta_management.hint_placeholder"), required=False, max_length=100 ) self.add_item(self.current_password) self.add_item(self.new_password) self.add_item(self.confirm_password) self.add_item(self.hint_input) async def on_submit(self, interaction: discord.Interaction): """Validate the current password, check new passwords match, then update. Sends a confirmation message with the new password on success. """ await interaction.response.defer(ephemeral=True) current = self.current_password.value.strip() new_pw = self.new_password.value.strip() confirm = self.confirm_password.value.strip() hint = self.hint_input.value.strip() # Validate current password is_valid = await validate_squadron_password(self.squadron_clanID, current) if not is_valid: await interaction.followup.send( t(self.lang, "meta_management.pw_incorrect"), ephemeral=True ) return # Validate new passwords match if new_pw != confirm: await interaction.followup.send( t(self.lang, "meta_management.pw_mismatch"), ephemeral=True ) return # Validate new password isn't empty after strip if not new_pw: await interaction.followup.send( t(self.lang, "meta_management.pw_empty"), ephemeral=True ) return # Update password and hint await update_squadron_password( self.squadron_clanID, new_pw, password_hint=hint if hint else "" ) logging.info(f"[PASSWORD CHANGE] Password changed for squadron {self.squadron_clanID} by guild {self.guild_id}") hint_suffix = t(self.lang, "meta_management.pw_changed_hint", hint=hint) if hint else "" await interaction.followup.send( t(self.lang, "meta_management.pw_changed", squadron=self.squadron_name, password=new_pw) + hint_suffix, ephemeral=True ) class PlayerAddModal(discord.ui.Modal): """Modal for adding a single player to the guild's meta roster by UID or nickname.""" def __init__(self, guild_id: str, squadron_clanID: str, parent_view, lang: str = "en"): self.lang = lang super().__init__(title=t(lang, "meta_management.player_add_modal_title")) self.guild_id = guild_id self.squadron_clanID = squadron_clanID self.parent_view = parent_view self.player_input = discord.ui.TextInput( label=t(lang, "meta_management.player_add_label"), placeholder=t(lang, "meta_management.player_add_placeholder"), required=True, max_length=100 ) self.add_item(self.player_input) async def on_submit(self, interaction: discord.Interaction): """Look up the player in Players_Global by UID or nick, then add to guild meta. Refreshes the parent PlayerManagementView on success. """ await interaction.response.defer(ephemeral=True) player_input = self.player_input.value.strip() # Try to find player by UID or nickname in Players_Global meta_db_path = STORAGE_DIR / "Meta.db" async with aiosqlite.connect(meta_db_path) as db: # Try by UID first cursor = await db.execute(""" SELECT DISTINCT userID, nick, clanTag, clanName, clanID FROM Players_Global WHERE userID = ? LIMIT 1 """, (player_input,)) row = await cursor.fetchone() # If not found, try by nickname if not row: cursor = await db.execute(""" SELECT DISTINCT userID, nick, clanTag, clanName, clanID FROM Players_Global WHERE LOWER(nick) = ? LIMIT 1 """, (player_input.lower(),)) row = await cursor.fetchone() if not row: await interaction.followup.send(t(self.lang, "meta_management.player_not_found", player=player_input), ephemeral=True) return user_id = str(row[0]) nick = str(row[1]) # Add player to guild meta success, message = await add_player_to_guild_meta(self.guild_id, user_id, self.squadron_clanID) if success: await interaction.followup.send(f"✅ {message}", ephemeral=True) # Refresh the parent view await self.parent_view.refresh_display(interaction) else: await interaction.followup.send(f"❌ {message}", ephemeral=True) class PlayerManagementView(discord.ui.View): """Paginated view for managing a squadron's meta roster (add/remove players).""" def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, lang: str = "en"): super().__init__(timeout=300) # 5 minutes self.guild_id = guild_id self.squadron_name = squadron_name self.squadron_clanID = squadron_clanID self.lang = lang self.current_page = 0 self.players_per_page = 10 async def refresh_display(self, interaction: discord.Interaction): """Rebuild and re-render the player roster embed with pagination and action buttons. Fetches the current player list from the meta database, paginates it, and updates the message with add/remove controls and navigation buttons. Args: interaction: The Discord interaction to edit the message on. """ # Get all players in guild meta players = await get_guild_meta_players(self.guild_id) # Create embed embed = discord.Embed( title=t(self.lang, "meta_management.roster_title", squadron=self.squadron_name), description=t(self.lang, "meta_management.roster_desc", clan_id=self.squadron_clanID, count=len(players)), color=discord.Color.blue() ) if players: # Paginate players start_idx = self.current_page * self.players_per_page end_idx = start_idx + self.players_per_page page_players = players[start_idx:end_idx] player_list = [] for i, player in enumerate(page_players, start=start_idx + 1): player_list.append( f"{i}. **{esc(player['nick'])}** (`{player['userID']}`)" ) total_pages = (len(players) - 1) // self.players_per_page + 1 embed.add_field( name=t(self.lang, "meta_management.roster_page_field", page=self.current_page + 1, total=total_pages), value="\n".join(player_list), inline=False ) else: embed.add_field( name=t(self.lang, "meta_management.no_players_field"), value=t(self.lang, "meta_management.no_players_hint"), inline=False ) embed.set_footer(text=DEFAULT_FOOTER_CAT) # Update buttons self.clear_items() self.add_item(AddPlayerButton(self.guild_id, self.squadron_clanID, self, self.lang)) self.add_item(AddAllSquadronButton(self.guild_id, self.squadron_name, self.squadron_clanID, self, self.lang)) if players: # Calculate indices for current page start_idx = self.current_page * self.players_per_page end_idx = start_idx + self.players_per_page self.add_item(RemovePlayerSelect(self.guild_id, players[start_idx:end_idx], self, self.lang)) # Add pagination buttons if needed total_pages = (len(players) - 1) // self.players_per_page + 1 if total_pages > 1: if self.current_page > 0: self.add_item(PlayerPrevPageButton(self, self.lang)) if self.current_page < total_pages - 1: self.add_item(PlayerNextPageButton(self, self.lang)) self.add_item(BackToSettingsButton(self.guild_id, self.squadron_name, self.squadron_clanID, self.lang)) # Edit or send message try: await interaction.response.edit_message(embed=embed, view=self) except discord.errors.InteractionResponded: await interaction.edit_original_response(embed=embed, view=self) class AddPlayerButton(discord.ui.Button): """Button that opens the PlayerAddModal to add a single player to the meta roster.""" def __init__(self, guild_id: str, squadron_clanID: str, parent_view, lang: str = "en"): super().__init__(label=t(lang, "buttons.add_player"), style=discord.ButtonStyle.success, row=0) self.guild_id = guild_id self.squadron_clanID = squadron_clanID self.parent_view = parent_view self.lang = lang async def callback(self, interaction: discord.Interaction): modal = PlayerAddModal(self.guild_id, self.squadron_clanID, self.parent_view, self.lang) await interaction.response.send_modal(modal) class AddAllSquadronButton(discord.ui.Button): """Button that syncs the entire squadron roster into the guild's meta player list.""" def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, parent_view, lang: str = "en"): super().__init__(label=t(lang, "buttons.update_all"), style=discord.ButtonStyle.primary, row=0) self.guild_id = guild_id self.squadron_name = squadron_name self.squadron_clanID = squadron_clanID self.parent_view = parent_view self.lang = lang async def callback(self, interaction: discord.Interaction): """Fetch all squadron members via API, bulk-sync the roster, and refresh vehicles. Resolves the clan tag from squadrons_data, fetches current members via obtain_clan_new_points, bulk-adds new / removes departed members, then kicks off a background task to refresh vehicle data for the guild. """ await interaction.response.defer(ephemeral=True) # Get squadron tag from squadrons_data clan_tag = "" try: async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: cursor = await db.execute( "SELECT tag_name FROM squadrons_data WHERE clan_id = ? LIMIT 1", (self.squadron_clanID,) ) row = await cursor.fetchone() if row: clan_tag = row[0] or "" except Exception: pass # Fetch all squadron members from API try: members, _total_points = await obtain_clan_new_points(self.squadron_name) except Exception as e: await interaction.followup.send( t(self.lang, "meta_management.fetch_members_failed", error=e), ephemeral=True ) return if not members: await interaction.followup.send( t(self.lang, "meta_management.no_members_found"), ephemeral=True ) return # Sync all members (add new, remove departed) added, removed, skipped = await bulk_add_squadron_players_to_guild_meta( self.guild_id, self.squadron_clanID, self.squadron_name, clan_tag, members ) parts = [t(self.lang, "meta_management.roster_synced")] if added: parts.append(t(self.lang, "meta_management.roster_added", count=added)) if removed: parts.append(t(self.lang, "meta_management.roster_removed", count=removed)) if skipped: parts.append(t(self.lang, "meta_management.roster_up_to_date", count=skipped)) parts.append(t(self.lang, "meta_management.refreshing_vehicles")) await interaction.followup.send(" · ".join(parts), ephemeral=True) # Refresh vehicle data for all guild members in the background asyncio.create_task(refresh_guild_player_vehicles(self.guild_id)) # Refresh parent view await self.parent_view.refresh_display(interaction) class RemovePlayerSelect(discord.ui.Select): """Dropdown select to remove a player from the guild's meta roster.""" def __init__(self, guild_id: str, players: list, parent_view, lang: str = "en"): options = [ discord.SelectOption( label=f"{player['nick'][:50]}", # Truncate if too long description=f"UID: {player['userID']}", value=player['userID'] ) for player in players[:25] # Discord limit ] super().__init__(placeholder=t(lang, "meta_management.remove_player_placeholder"), options=options, row=1) self.guild_id = guild_id self.parent_view = parent_view self.lang = lang async def callback(self, interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) user_id = self.values[0] success, message = await remove_player_from_guild_meta(self.guild_id, user_id) if success: await interaction.followup.send(f"✅ {message}", ephemeral=True) # Refresh the parent view await self.parent_view.refresh_display(interaction) else: await interaction.followup.send(f"❌ {message}", ephemeral=True) class PlayerPrevPageButton(discord.ui.Button): """Previous-page button for PlayerManagementView pagination.""" def __init__(self, parent_view, lang: str = "en"): super().__init__(label=t(lang, "buttons.prev_arrow"), style=discord.ButtonStyle.secondary, row=2) self.parent_view = parent_view async def callback(self, interaction: discord.Interaction): self.parent_view.current_page -= 1 await self.parent_view.refresh_display(interaction) class PlayerNextPageButton(discord.ui.Button): """Next-page button for PlayerManagementView pagination.""" def __init__(self, parent_view, lang: str = "en"): super().__init__(label=t(lang, "buttons.next_arrow"), style=discord.ButtonStyle.secondary, row=2) self.parent_view = parent_view async def callback(self, interaction: discord.Interaction): self.parent_view.current_page += 1 await self.parent_view.refresh_display(interaction) class BackToSettingsButton(discord.ui.Button): """Button that navigates back from player management to the MetaManagementView settings panel.""" def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, lang: str = "en"): super().__init__(label=t(lang, "buttons.back_to_settings"), style=discord.ButtonStyle.secondary, row=3) self.guild_id = guild_id self.squadron_name = squadron_name self.squadron_clanID = squadron_clanID self.lang = lang async def callback(self, interaction: discord.Interaction): """Reload guild settings and rebuild the MetaManagementView with current toggle states.""" await interaction.response.defer(ephemeral=True) # Get current settings guild_settings = await get_guild_settings(self.guild_id) if not guild_settings: await interaction.followup.send( t(self.lang, "meta_management.error_retrieving_settings_retry"), ephemeral=True ) return # Show settings embed embed = discord.Embed( title=t(self.lang, "meta_management.settings_title"), description=t(self.lang, "meta_management.settings_desc", squadron=self.squadron_name, clan_id=self.squadron_clanID), color=discord.Color.blue() ) embed.add_field( name=t(self.lang, "meta_management.password_requirement_field"), value=t(self.lang, "meta_management.enabled_value") if guild_settings["require_password"] else t(self.lang, "meta_management.disabled_value"), inline=True ) embed.add_field( name=t(self.lang, "meta_management.data_lock_field"), value=t(self.lang, "meta_management.enabled_value") if guild_settings["lock_squadron_data"] else t(self.lang, "meta_management.disabled_value"), inline=True ) embed.add_field( name=t(self.lang, "meta_management.public_meta_field"), value=t(self.lang, "meta_management.enabled_value") if guild_settings["allow_public_meta"] else t(self.lang, "meta_management.disabled_value"), inline=True ) if interaction.guild and interaction.user.id == interaction.guild.owner_id: embed.add_field( name=t(self.lang, "meta_management.access_password_field"), value=f"`{guild_settings['access_password']}`", inline=False ) embed.set_footer(text=DEFAULT_FOOTER_CAT) # Create view with buttons view = MetaManagementView( guild_id=self.guild_id, squadron_name=self.squadron_name, squadron_clanID=self.squadron_clanID, require_password=guild_settings["require_password"], lock_squadron_data=guild_settings["lock_squadron_data"], allow_public_meta=guild_settings["allow_public_meta"], lang=self.lang ) # Update button styles based on current state if guild_settings["require_password"]: button = view.children[0] if isinstance(button, discord.ui.Button): button.style = discord.ButtonStyle.success button.label = t(self.lang, "buttons.password_required") if guild_settings["lock_squadron_data"]: button = view.children[1] if isinstance(button, discord.ui.Button): button.style = discord.ButtonStyle.success button.label = t(self.lang, "buttons.data_locked") if guild_settings["allow_public_meta"]: button = view.children[2] if isinstance(button, discord.ui.Button): button.style = discord.ButtonStyle.success button.label = t(self.lang, "buttons.public_enabled") view.message = await interaction.edit_original_response(embed=embed, view=view) class MetaManagementView(discord.ui.View): """Settings panel for managing squadron meta configuration. Provides toggle buttons for password requirement, data lock, and public meta access, plus navigation to player roster management and password change. """ def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, require_password: bool, lock_squadron_data: bool, allow_public_meta: bool, lang: str = "en"): super().__init__(timeout=300) # 5 minutes self.message: Optional[discord.Message] = None self.guild_id = guild_id self.squadron_name = squadron_name self.squadron_clanID = squadron_clanID self.require_password = require_password self.lock_squadron_data = lock_squadron_data self.allow_public_meta = allow_public_meta self.lang = lang self.toggle_password.label = t(lang, "buttons.require_password") self.toggle_lock.label = t(lang, "buttons.lock_data") self.toggle_public_meta.label = t(lang, "buttons.allow_public") self.update_meta_accounts.label = t(lang, "buttons.update_accounts") self.change_password.label = t(lang, "buttons.change_password") self.show_help.label = t(lang, "buttons.help") async def on_timeout(self): for item in self.children: item.disabled = True # type: ignore[attr-defined] try: if self.message: await self.message.edit(view=self) except Exception: pass @discord.ui.button(label="🔒 Require Password", style=discord.ButtonStyle.secondary, row=0) async def toggle_password(self, interaction: discord.Interaction, button: discord.ui.Button): """Toggle whether a password is required to access meta management on this server.""" # Toggle the setting new_value = not self.require_password success = await update_guild_settings(self.guild_id, require_password=new_value) if success: self.require_password = new_value # Update button style button.style = discord.ButtonStyle.success if new_value else discord.ButtonStyle.secondary button.label = t(self.lang, "buttons.password_required") if new_value else t(self.lang, "buttons.require_password") await interaction.response.edit_message(view=self) state_str = t(self.lang, "common.enabled") if new_value else t(self.lang, "common.disabled") await interaction.followup.send( t(self.lang, "meta_management.password_toggled", state=state_str), ephemeral=True ) else: await interaction.response.send_message(t(self.lang, "common.failed_update_setting"), ephemeral=True) @discord.ui.button(label="🔐 Lock Squadron Data", style=discord.ButtonStyle.secondary, row=0) async def toggle_lock(self, interaction: discord.Interaction, button: discord.ui.Button): """Toggle whether squadron data is locked to this server (prevents transfers).""" # Toggle the setting new_value = not self.lock_squadron_data success = await update_guild_settings(self.guild_id, lock_squadron_data=new_value) if success: self.lock_squadron_data = new_value # Update button style button.style = discord.ButtonStyle.success if new_value else discord.ButtonStyle.secondary button.label = t(self.lang, "buttons.data_locked") if new_value else t(self.lang, "buttons.lock_data") await interaction.response.edit_message(view=self) state_str = t(self.lang, "common.enabled") if new_value else t(self.lang, "common.disabled") await interaction.followup.send( t(self.lang, "meta_management.lock_toggled", state=state_str), ephemeral=True ) else: await interaction.response.send_message(t(self.lang, "common.failed_update_setting"), ephemeral=True) @discord.ui.button(label="👥 Allow Public Meta", style=discord.ButtonStyle.secondary, row=1) async def toggle_public_meta(self, interaction: discord.Interaction, button: discord.ui.Button): """Toggle whether non-admin members can use the /meta command.""" # Toggle the setting new_value = not self.allow_public_meta success = await update_guild_settings(self.guild_id, allow_public_meta=new_value) if success: self.allow_public_meta = new_value # Update button style button.style = discord.ButtonStyle.success if new_value else discord.ButtonStyle.secondary button.label = t(self.lang, "buttons.public_enabled") if new_value else t(self.lang, "buttons.allow_public") await interaction.response.edit_message(view=self) state_str = t(self.lang, "common.enabled") if new_value else t(self.lang, "common.disabled") detail = t(self.lang, "meta_management.public_meta_enabled_detail") if new_value else t(self.lang, "meta_management.public_meta_disabled_detail") await interaction.followup.send( t(self.lang, "meta_management.public_meta_toggled", state=state_str, detail=detail), ephemeral=True ) else: await interaction.response.send_message(t(self.lang, "common.failed_update_setting"), ephemeral=True) @discord.ui.button(label="📋 Update Meta Accounts", style=discord.ButtonStyle.primary, row=1) async def update_meta_accounts(self, interaction: discord.Interaction, button: discord.ui.Button): """Open the PlayerManagementView for adding/removing players from the meta roster.""" # Show player management view view = PlayerManagementView(self.guild_id, self.squadron_name, self.squadron_clanID, self.lang) await view.refresh_display(interaction) @discord.ui.button(label="🔑 Change Password", style=discord.ButtonStyle.secondary, row=2) async def change_password(self, interaction: discord.Interaction, button: discord.ui.Button): """Open the ChangePasswordModal (restricted to the server owner).""" if not interaction.guild or interaction.user.id != interaction.guild.owner_id: await interaction.response.send_message( t(self.lang, "meta_management.owner_only_password"), ephemeral=True ) return modal = ChangePasswordModal(self.squadron_clanID, self.squadron_name, self.guild_id, self.lang) await interaction.response.send_modal(modal) @discord.ui.button(label="❓ Help", style=discord.ButtonStyle.secondary, row=3) async def show_help(self, interaction: discord.Interaction, button: discord.ui.Button): """Display an ephemeral embed explaining each meta management setting.""" embed = discord.Embed( title=t(self.lang, "meta_management.help_title"), description=t(self.lang, "meta_management.help_desc"), color=discord.Color.blue() ) embed.add_field( name=t(self.lang, "meta_management.help_password_field"), value=t(self.lang, "meta_management.help_password_value"), inline=False ) embed.add_field( name=t(self.lang, "meta_management.help_require_field"), value=t(self.lang, "meta_management.help_require_value"), inline=False ) embed.add_field( name=t(self.lang, "meta_management.help_lock_field"), value=t(self.lang, "meta_management.help_lock_value"), inline=False ) embed.add_field( name=t(self.lang, "meta_management.help_public_field"), value=t(self.lang, "meta_management.help_public_value"), inline=False ) embed.add_field( name=t(self.lang, "meta_management.help_accounts_field"), value=t(self.lang, "meta_management.help_accounts_value"), inline=False ) embed.add_field( name=t(self.lang, "meta_management.help_change_pw_field"), value=t(self.lang, "meta_management.help_change_pw_value"), inline=False ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.response.send_message(embed=embed, ephemeral=True) @is_blacklisted() @is_admin() @gate_entitle("standard") @bot.tree.command(name='meta-management', description=command_locale('Manage meta data access settings for this server', "commands.meta_management.description")) async def meta_management(interaction: discord.Interaction): """Manage meta data access settings for the guild's squadron. Resolves the guild's squadron and clan ID, then determines the setup state: first-time setup (generates password and creates guild entry), owner access (bypasses or prompts password based on require_password setting), or foreign-server access (always requires password). Displays a settings panel with toggle buttons for password requirement, data lock, and public meta access. """ await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' guild_id = str(interaction.guild_id) try: clan = await get_guild_squadron(interaction.guild_id) except ValueError as e: await interaction.response.defer(ephemeral=True) embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed, ephemeral=True) squadron_name = clan["long_name"] # Get clanID from squadrons.db async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: cursor = await db.execute(""" SELECT clan_id FROM squadrons_data WHERE LOWER(long_name) = ? LIMIT 1 """, (squadron_name.lower(),)) row = await cursor.fetchone() if not row: # Try Players_Global table from Meta.db meta_db_path = STORAGE_DIR / "Meta.db" async with aiosqlite.connect(meta_db_path) as meta_db: cursor = await meta_db.execute(""" SELECT DISTINCT clanID FROM Players_Global WHERE LOWER(clanName) = ? LIMIT 1 """, (squadron_name.lower(),)) row = await cursor.fetchone() if not row or not row[0]: await interaction.response.defer(ephemeral=True) embed = discord.Embed( title=t(lang, "meta_management.squadron_not_found_title"), description=t(lang, "meta_management.squadron_not_found_desc", squadron=squadron_name), color=discord.Color.red() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed, ephemeral=True) return squadron_clanID = str(row[0]) # Check if squadron is claimed by ANY server squadron_owner = await get_squadron_owner(squadron_clanID) # Check if THIS server has settings for this squadron guild_settings = await get_guild_settings(guild_id) # Determine state: # 1. Squadron not claimed by anyone → First time setup # 2. Squadron claimed by THIS server → Check require_password setting # - If require_password enabled → Show password modal # - If require_password disabled → Show settings directly # 3. Squadron claimed by DIFFERENT server → Require password if squadron_owner is None: # First time setup - no one has claimed this squadron yet is_first_time = True elif squadron_owner["guild_id"] == guild_id: # This server owns this squadron # Check if require_password is enabled if squadron_owner.get("require_password", False): # Password required even for owner hint = squadron_owner.get("password_hint", "") modal = PasswordModal(squadron_clanID, squadron_name, guild_id, is_owner=True, password_hint=hint, lang=lang) await interaction.response.send_modal(modal) return else: # No password required, proceed to settings is_first_time = False else: # Squadron claimed by a different server - require password # Show password modal (can't defer before modal) hint = squadron_owner.get("password_hint", "") if squadron_owner else "" modal = PasswordModal(squadron_clanID, squadron_name, guild_id, is_owner=False, password_hint=hint, lang=lang) await interaction.response.send_modal(modal) return # Now we can defer for the rest await interaction.response.defer(ephemeral=True) if is_first_time: # First time setup - create guild entry password = await create_or_update_guild( guild_id=guild_id, squadron_clanID=squadron_clanID, squadron_name=squadron_name, require_password=False, lock_squadron_data=False ) # Show password to user (only server owner sees the password) is_guild_owner = interaction.guild and interaction.user.id == interaction.guild.owner_id if is_guild_owner: description = t(lang, "meta_management.first_time_owner_desc", squadron=squadron_name, clan_id=squadron_clanID, password=password) else: description = t(lang, "meta_management.first_time_non_owner_desc", squadron=squadron_name, clan_id=squadron_clanID) embed = discord.Embed( title=t(lang, "meta_management.first_time_title"), description=description, color=discord.Color.blue() ) embed.add_field( name=t(lang, "meta_management.settings_field"), value=t(lang, "meta_management.settings_hint"), inline=False ) embed.set_footer(text=DEFAULT_FOOTER_CAT) # Create view with buttons view = MetaManagementView( guild_id=guild_id, squadron_name=squadron_name, squadron_clanID=squadron_clanID, require_password=False, lock_squadron_data=False, allow_public_meta=False, lang=lang ) view.message = await interaction.followup.send(embed=embed, view=view, ephemeral=True, wait=True) else: # Already set up - show current settings if not guild_settings: await interaction.followup.send( t(lang, "meta_management.error_retrieving_settings_retry"), ephemeral=True ) return embed = discord.Embed( title=t(lang, "meta_management.settings_title"), description=t(lang, "meta_management.settings_desc", squadron=squadron_name, clan_id=squadron_clanID), color=discord.Color.blue() ) embed.add_field( name=t(lang, "meta_management.password_requirement_field"), value=t(lang, "meta_management.enabled_value") if guild_settings["require_password"] else t(lang, "meta_management.disabled_value"), inline=True ) embed.add_field( name=t(lang, "meta_management.data_lock_field"), value=t(lang, "meta_management.enabled_value") if guild_settings["lock_squadron_data"] else t(lang, "meta_management.disabled_value"), inline=True ) embed.add_field( name=t(lang, "meta_management.public_meta_field"), value=t(lang, "meta_management.enabled_value") if guild_settings["allow_public_meta"] else t(lang, "meta_management.disabled_value"), inline=True ) if interaction.guild and interaction.user.id == interaction.guild.owner_id: embed.add_field( name=t(lang, "meta_management.access_password_field"), value=f"`{guild_settings['access_password']}`", inline=False ) embed.set_footer(text=DEFAULT_FOOTER_CAT) # Create view with buttons (set initial states) view = MetaManagementView( guild_id=guild_id, squadron_name=squadron_name, squadron_clanID=squadron_clanID, require_password=guild_settings["require_password"], lock_squadron_data=guild_settings["lock_squadron_data"], allow_public_meta=guild_settings["allow_public_meta"], lang=lang ) # Update button styles based on current state if guild_settings["require_password"]: button = view.children[0] if isinstance(button, discord.ui.Button): button.style = discord.ButtonStyle.success button.label = t(lang, "buttons.password_required") if guild_settings["lock_squadron_data"]: button = view.children[1] if isinstance(button, discord.ui.Button): button.style = discord.ButtonStyle.success button.label = t(lang, "buttons.data_locked") if guild_settings["allow_public_meta"]: button = view.children[2] if isinstance(button, discord.ui.Button): button.style = discord.ButtonStyle.success button.label = t(lang, "buttons.public_enabled") view.message = await interaction.followup.send(embed=embed, view=view, ephemeral=True, wait=True) @meta_management.error async def meta_management_error(interaction, error): await permission_fail(interaction, error) async def meta_vehicle_autocomplete( interaction: discord.Interaction, current: str, ) -> List[discord.app_commands.Choice[str]]: """Autocomplete for vehicle names in /meta command. Shows all game vehicles (minus naval).""" cache = _utils.game_data_cache if not cache: return [] # game_data_cache entries: [cdk, human, icon, misc_params] (only vehicles with icons) # Filter out naval vehicles vehicles = [ {"vehicle_name": entry[0], "vehicle_human": entry[1]} for entry in cache if not entry[3].get("ship", False) and not entry[3].get("boat", False) ] if not current: # Return first 25 vehicles alphabetically by human name sorted_vehicles = sorted(vehicles, key=lambda v: (v.get("vehicle_human") or v.get("vehicle_name", "")).lower()) return [ discord.app_commands.Choice( name=v.get("vehicle_human") or v.get("vehicle_name", "Unknown")[:100], value=v.get("vehicle_name", "") ) for v in sorted_vehicles[:25] ] # Filter vehicles matching the current input current_lower = current.lower() search_terms = [t for t in current_lower.split() if t] matches = [] for v in vehicles: internal = v.get("vehicle_name", "") human = v.get("vehicle_human", "") or internal combined = f"{internal} {human}".lower() # Score the match score = 0 # Check if all search terms appear if all(term in combined for term in search_terms): score = 10 # Bonus for exact substring match if current_lower in combined: score += 15 # Bonus for starting with search term if human.lower().startswith(current_lower) or internal.lower().startswith(current_lower): score += 20 if score > 0: matches.append((score, human, internal)) # Sort by score (descending), then alphabetically matches.sort(key=lambda x: (-x[0], x[1].lower())) return [ discord.app_commands.Choice(name=human[:100], value=internal) for score, human, internal in matches[:25] ] class MetaResultsView(discord.ui.View): """Paginated view for displaying meta vehicle search results across multiple embeds.""" def __init__(self, pages: list, title: str, total_players: int, lang: str = "en"): super().__init__(timeout=300) self.message: Optional[discord.Message] = None self.pages = pages self.title = title self.total_players = total_players self.current_page = 0 self.lang = lang self.prev_page.label = t(lang, "buttons.prev_arrow") self.next_page.label = t(lang, "buttons.next_arrow") async def on_timeout(self): for item in self.children: item.disabled = True # type: ignore[attr-defined] try: if self.message: await self.message.edit(view=self) except Exception: pass def build_embed(self) -> discord.Embed: """Build the embed for the current page of meta search results. Returns: discord.Embed: Embed containing player-vehicle matches for the current page. """ embed = discord.Embed( title=self.title, description=t(self.lang, "meta.matches_found", count=self.total_players) + "\n\n" + "\n".join(self.pages[self.current_page]), color=discord.Color.green() ) embed.set_footer(text=f"Page {self.current_page + 1}/{len(self.pages)} • {DEFAULT_FOOTER_CAT}") return embed @discord.ui.button(label="◀ Previous", style=discord.ButtonStyle.secondary) async def prev_page(self, interaction: discord.Interaction, button: discord.ui.Button): if self.current_page > 0: self.current_page -= 1 await interaction.response.edit_message(embed=self.build_embed(), view=self) @discord.ui.button(label="Next ▶", style=discord.ButtonStyle.secondary) async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): if self.current_page < len(self.pages) - 1: self.current_page += 1 await interaction.response.edit_message(embed=self.build_embed(), view=self) @is_blacklisted() @gate_entitle("standard") @bot.tree.command(name='meta', description=command_locale('Search squadron meta roster by vehicle name', "commands.meta.description")) @discord.app_commands.describe(vehicle=command_locale("Vehicle name to search for", "commands.meta.vehicle")) @discord.app_commands.autocomplete(vehicle=meta_vehicle_autocomplete) async def meta(interaction: discord.Interaction, vehicle: str): """Search the squadron's meta roster for players who own a given vehicle. Checks guild settings and permissions (admin or public meta access), resolves the vehicle name via autocomplete or partial match, queries the meta database, aggregates stats across game modes and vehicle variants, then displays results in a paginated embed showing spawns, deaths, and kills per player. """ await collect_command_stats(interaction) guild_id = str(interaction.guild_id) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' # Get guild settings guild_settings = await get_guild_settings(guild_id) if not guild_settings: await interaction.response.send_message( t(lang, "meta.not_configured"), ephemeral=True ) return # Check permissions: must be admin OR allow_public_meta must be enabled is_admin = isinstance(interaction.user, discord.Member) and interaction.user.guild_permissions.administrator allow_public = guild_settings.get("allow_public_meta", False) if not is_admin and not allow_public: await interaction.response.send_message( t(lang, "meta.no_permission"), ephemeral=True ) return try: clan = await get_guild_squadron(interaction.guild_id) except ValueError as e: return await interaction.response.send_message(str(e), ephemeral=True) squadron_name = clan["long_name"] # The meta roster is bound to whichever squadron was registered via # /meta-management (stored in Guilds.squadron_name). SQUADRONS.json (used by # get_guild_squadron) can drift from that, which would make the points API # call return members of the wrong squadron and zero uid overlap. Prefer # the meta roster's own squadron name for the points lookup. points_squadron_name = guild_settings.get("squadron_name") or squadron_name await interaction.response.defer(ephemeral=True) vehicle_search = vehicle.strip() # Translate internal name to human-readable for display # If translation succeeds, input is a known internal name (from autocomplete) translator = LangTableReader("English") vehicle_display = translator.get_translate(vehicle_search) is_exact = vehicle_display is not None if vehicle_display: vehicle_display = normalize_name(vehicle_display) else: vehicle_display = vehicle_search # Search for vehicles matching the input results = await search_guild_meta_by_vehicle(guild_id, vehicle_search, exact_only=is_exact) if not results: msg = t(lang, "meta.no_results", vehicle=vehicle_display) if is_admin: msg += t(lang, "meta.no_results_admin_hint") await interaction.followup.send(msg, ephemeral=True) return # Group results by player, aggregate by human-readable name to combine variants # (e.g., germ_leopard_2a4m and germ_leopard_2a4m_can both display as "Leopard 2A4M") translator = LangTableReader("English") player_vehicles = {} for result in results: user_id = result['userID'] if user_id not in player_vehicles: clan_tag = result['clanTag'] or '' if len(clan_tag) > 2: clan_tag = clan_tag[1:-1] player_vehicles[user_id] = { 'nick': result['nick'], 'clanTag': clan_tag, 'vehicles': {} # Dict keyed by human name to aggregate modes AND variants } intname = result['intname'] # Translate to human-readable name for aggregation key readable_name = translator.get_translate(intname) if readable_name: readable_name = normalize_name(readable_name) else: readable_name = intname if readable_name not in player_vehicles[user_id]['vehicles']: player_vehicles[user_id]['vehicles'][readable_name] = { 'readable_name': readable_name, 'flyouts': 0, 'deaths': 0, 'ground_kills': 0, 'air_kills': 0, 'games': 0 } # Aggregate stats across modes AND vehicle variants with same display name player_vehicles[user_id]['vehicles'][readable_name]['flyouts'] += result['flyouts'] player_vehicles[user_id]['vehicles'][readable_name]['deaths'] += result['deaths'] player_vehicles[user_id]['vehicles'][readable_name]['ground_kills'] += result['ground_kills'] player_vehicles[user_id]['vehicles'][readable_name]['air_kills'] += result['air_kills'] player_vehicles[user_id]['vehicles'][readable_name]['games'] += result.get('was_in_session', 0) # Fetch live squadron points so we can show each player's current rating. # Players whose current squadron differs from the guild's squadron won't appear # in this map and will fall back to a placeholder. # Retry once: obtain_clan_new_points returns ({}, 0) silently on JWT refresh, # so a second call after the refresh succeeds. points_map: dict[str, int] = {} for _ in range(2): try: sq_members, _total = await obtain_clan_new_points(points_squadron_name) except (ClanInfoError, OSError, ValueError) as e: logging.warning("meta: obtain_clan_new_points failed for %s: %s", points_squadron_name, e) break if sq_members: points_map = {str(uid): int(info.get("points", 0)) for uid, info in sq_members.items()} break if not points_map: logging.warning("meta: empty points_map for squadron '%s'", points_squadron_name) # Build player entries as text lines points_lbl = t(lang, "meta.points_label") kdr_lbl = t(lang, "meta.kdr_label") games_lbl = t(lang, "meta.games_label") no_points = t(lang, "meta.no_points") all_lines = [] for user_id, data in player_vehicles.items(): player_name = esc(str(data['nick'])) pts = points_map.get(str(user_id)) pts_str = f"{pts:,}" if pts is not None else no_points for veh in data['vehicles'].values(): readable_name = veh['readable_name'] kills = veh['ground_kills'] + veh['air_kills'] deaths = veh['deaths'] if deaths > 0: kdr_str = f"{kills / deaths:.2f}" elif kills > 0: kdr_str = "∞" else: kdr_str = "0.00" all_lines.append( f"**{player_name}** — {readable_name}\n" f"└─ {points_lbl}: {pts_str} | {kdr_lbl}: {kdr_str} | " f"{games_lbl}: {veh['games']}" ) # Paginate: group lines into pages that fit within embed limits pages = [] current_page_lines = [] # title + description + footer overhead ~200 chars, keep page content under 5000 current_size = 0 for line in all_lines: line_size = len(line) + 1 # +1 for newline if current_page_lines and current_size + line_size > 4000: pages.append(current_page_lines) current_page_lines = [] current_size = 0 current_page_lines.append(line) current_size += line_size if current_page_lines: pages.append(current_page_lines) total_players = len(player_vehicles) search_title = t(lang, "meta.search_title", vehicle=vehicle_display) if len(pages) == 1: # Single page, no pagination needed embed = discord.Embed( title=search_title, description=t(lang, "meta.matches_found", count=total_players) + "\n\n" + "\n".join(pages[0]), color=discord.Color.green() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed, ephemeral=True) else: # Multiple pages, use pagination view view = MetaResultsView(pages, search_title, total_players, lang) embed = view.build_embed() view.message = await interaction.followup.send(embed=embed, view=view, ephemeral=True, wait=True) @meta.error async def meta_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @bot.tree.command(name='top', description=command_locale('Get the top 20 squadrons with detailed stats', "commands.top.description")) async def top(interaction: discord.Interaction): """Display the top 20 squadrons with rating, kills, K/D, win rate, and playtime.""" await collect_command_stats(interaction) await interaction.response.defer() lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' squadron_data = await obtain_clans_leaderboard(start=0, count=20) if not squadron_data: await interaction.followup.send(t(lang, "top.fetch_failed"), ephemeral=True) return embed = discord.Embed(title=t(lang, "top.title"), color=discord.Color.purple()) for idx, squadron in enumerate(squadron_data, start=1): ground_kills = squadron.get("g_kills", 0) air_kills = squadron.get("a_kills", 0) total_kills = ground_kills + air_kills deaths = squadron.get("deaths", 1) # Avoid division by zero kd_ratio = round(total_kills / deaths, 2) if deaths else "N/A" # Calculate win rate (ensure battles is not zero to avoid division errors) wins = squadron.get("wins", 0) battles = squadron.get("battles", 0) win_rate = round((wins / battles) * 100, 2) if battles else "N/A" playtime_minutes = squadron.get("playtime", 0) days = playtime_minutes // 1440 hours = (playtime_minutes % 1440) // 60 minutes = playtime_minutes % 60 formatted_playtime = f"{days}d {hours}h {minutes}m" short_name = squadron['short_name'] win_rate_display = "N/A" if win_rate == "N/A" else str(win_rate) + "%" embed.add_field( name=f"**{idx} - {short_name}**", value=( f"{t(lang, 'top.rating_label', value=squadron.get('clanrating', 'N/A'))}\n" f"{t(lang, 'top.air_kills_label', value=air_kills)}\n" f"{t(lang, 'top.ground_kills_label', value=ground_kills)}\n" f"{t(lang, 'top.deaths_label', value=deaths)}\n" f"{t(lang, 'top.kd_label', value=kd_ratio)}\n" f"{t(lang, 'top.win_rate_label', value=win_rate_display)}\n" f"{t(lang, 'top.playtime_label', value=formatted_playtime)}\n" "\u200b" # Adds spacing ), inline=True # Each squadron appears on a new line ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed, ephemeral=False) @top.error async def top_perm_error(interaction, error): await permission_fail(interaction, error) class LanguageListSelect(discord.ui.Select): """Dropdown select for choosing the bot's display language for a server.""" def __init__(self, lang: str = 'en'): self.lang = lang # Mapping of displayed language names to canonical stored values self.language_mapping = { "English": "", "Français": "", "Italiano": "", "Deutsch": "", "Español": "", "Русский": "", "Polski": "", "Čeština": "", "简体中文": "", "Português": "", "Українська": "", } options = [ discord.SelectOption(label=label, value=label) for label in self.language_mapping ] super().__init__( placeholder=t(lang, "language.select_placeholder"), min_values=1, max_values=1, options=options ) async def callback(self, interaction: discord.Interaction): guild_id = interaction.guild.id # type: ignore guild_name = interaction.guild.name # type: ignore features = await load_features(guild_id) selected_display = self.values[0] canonical_value = self.language_mapping.get(selected_display, f"<{selected_display}>") features["Language"] = canonical_value await save_features(guild_id, features) await interaction.response.send_message(t(self.lang, "language.language_set", language=selected_display), ephemeral=True) logging.info(f"Guild {guild_name} ({guild_id}) set their language to {canonical_value}") class LanguageListView(discord.ui.View): """View wrapper containing the LanguageListSelect dropdown for /language.""" def __init__(self, lang: str = 'en'): super().__init__() self.add_item(LanguageListSelect(lang)) @is_blacklisted() @is_admin() @bot.tree.command(name="language",description=command_locale("Change the bot's language.", "commands.language.description")) async def language(interaction: discord.Interaction): """Present a dropdown to change the bot's display language for this server.""" await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' view = LanguageListView(lang) await interaction.response.send_message(t(lang, "language.prompt"), view=view, ephemeral=True) @language.error async def language_error(interaction, error): await permission_fail(interaction, error) # Map target‐codes → (native name, English name) LANGUAGE_OPTIONS = { "RU": ("Русский", "Russian"), #"EN-US": ("English (US)", "English (US)"), "EN-GB": ("English", "English"), "UK": ("Українська", "Ukrainian"), "ES": ("Español", "Spanish"), "FR": ("Français", "French"), "DE": ("Deutsch", "German"), "ZH-HANS": ("简体中文", "Chinese"), "JA": ("日本語", "Japanese"), "KO": ("한국어", "Korean"), "IT": ("Italiano", "Italian"), "PT-PT": ("Português", "Portuguese"), #"PT-BR": ("Português (Brasil)","Portuguese (Brazil)"), "PL": ("Polski", "Polish"), "LT": ("Lietuvių", "Lithuanian"), "LV": ("Latviešu", "Latvian"), "ET": ("Eesti", "Estonian"), #"DA": ("Dansk", "Danish"), "FI": ("Suomi", "Finnish"), #"ID": ("Bahasa Indonesia", "Indonesian"), #"NB": ("Norsk bokmål", "Norwegian"), "NL": ("Nederlands", "Dutch"), "SV": ("Svenska", "Swedish"), "CS": ("Čeština", "Czech"), #"SK": ("Slovenčina", "Slovak"), #"SL": ("Slovenščina", "Slovenian"), "RO": ("Română", "Romanian"), "BG": ("Български", "Bulgarian"), "EL": ("Ελληνικά", "Greek"), "HU": ("Magyar", "Hungarian"), "AR": ("العربية", "Arabic"), "TR": ("Türkçe", "Turkish"), } DEEPL_API_KEY = os.environ.get("DEEPL_KEY") translator = deepl.Translator(DEEPL_API_KEY) if DEEPL_API_KEY else None def perform_translation(text: str, target_language: str) -> str: """Translate text to the target language using the DeepL API. Args: text: The text to translate. target_language: DeepL target language code (e.g. "DE", "FR"). Returns: Translated text, or an error message if translation fails. """ if not translator: return "Translation unavailable (DeepL not configured)" try: result = translator.translate_text(text, target_lang=target_language.upper()) return result.text # type: ignore except Exception as e: logging.error(f"Translation failed: {e}") return "Translation failed" # ── UI Components ───────────────────────────────────────────────────────────── class LanguageSelect(discord.ui.Select): """Dropdown for selecting a target language to translate a message via DeepL.""" def __init__(self, original_message: discord.Message, lang: str = "en"): options = [ discord.SelectOption( label=f"{native} ({english})", value=code ) for code, (native, english) in LANGUAGE_OPTIONS.items() ] super().__init__( placeholder=t(lang, "language.translate_placeholder"), min_values=1, max_values=1, options=options ) self._msg = original_message async def callback(self, interaction: discord.Interaction): target = self.values[0] text = self._msg.content translated = await asyncio.to_thread(perform_translation, text, target) await interaction.response.send_message( f"**{self._msg.author.display_name} → {LANGUAGE_OPTIONS[target][1]}:**\n{translated}", ephemeral=True ) self.view.stop() # type: ignore class LanguageView(discord.ui.View): """View wrapper containing the LanguageSelect dropdown for the Translate Message context menu.""" def __init__(self, message: discord.Message, lang: str = "en"): super().__init__(timeout=1200) self.add_item(LanguageSelect(message, lang=lang)) # ── Replace your TranslatorCog/slash-command with this context menu ───────── @bot.tree.context_menu(name=command_locale("Translate Message", "commands.translate_message.name")) async def translate_message( interaction: discord.Interaction, message: discord.Message ): """Right-click any message → Apps → Translate Message.""" lang = await guild_lang(interaction.guild.id) if interaction.guild else "en" view = LanguageView(message, lang=lang) await interaction.response.send_message( t(lang, "language.translate_prompt"), view=view, ephemeral=True ) @is_blacklisted() @gate_entitle("standard") @bot.tree.command(name="sq-track", description=command_locale("Track a squadron and compare stats against the last check", "commands.sq_track.description")) @app_commands.describe( squadron_short_name=command_locale("Short name of the squadron to track", "commands.sq_track.squadron_short_name") ) @discord.app_commands.autocomplete(squadron_short_name=squadron_autocomplete) async def track_squadron( interaction: discord.Interaction, squadron_short_name: str ): """Track a squadron's stats and show deltas since the last check. Fetches current squadron stats (rating, kills, wins, K/D, etc.), loads the previous snapshot from disk, computes and displays diffs for each stat, then saves the current snapshot for the next comparison. """ await collect_command_stats(interaction) await interaction.response.defer() logging.info("Running /sq-track") lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' clan = await resolve_clan(short=squadron_short_name.lower()) if not clan or clan["long_name"] == "": await interaction.followup.send(t(lang, "track.squadron_not_found"), ephemeral=True) return squadron_long = clan["long_name"] # Now fetch full clan info from your API/db using the long name clan_data = await obtain_clan_info_api(squadron_long, "clanName") if not clan_data: await interaction.followup.send(t(lang, "track.fetch_failed"), ephemeral=True) return # Extract stats clan_tag = clan_data.get("tag", squadron_short_name) clan_tag = clan_tag[1:-1] clan_long = clan_data.get("tag", squadron_short_name.upper()) astat = clan_data.get("astat", {}) points = int(astat.get("dr_era5_hist", 0)) ground_kills = int(astat.get("gkills_hist", 0)) air_kills = int(astat.get("akills_hist", 0)) deaths = int(astat.get("deaths_hist", 0)) battles = int(astat.get("battles_hist", 0)) wins = int(astat.get("wins_hist", 0)) members = clan_data.get("members", []) member_count = len(members) if isinstance(members, (list, dict)) else int(members or 0) total_kills = ground_kills + air_kills kd_ratio = total_kills / deaths if deaths > 0 else total_kills kd_ratio_percentage = f"{kd_ratio:.2f}" losses = battles - wins win_rate = (wins / battles) * 100 if battles > 0 else 0 win_rate_percentage = f"{win_rate:.2f}%" # --- Placement --- placement, _ = await get_current_squadron_placement(squadron_long, squadron_short_name) # --- Snapshot tracking --- guild_id = str(interaction.guild_id if interaction.guild_id else interaction.user.id) sq_key = clan_tag.upper() tracks_dir = STORAGE_DIR / "TRACKS" / guild_id tracks_dir.mkdir(parents=True, exist_ok=True) snapshot_path = tracks_dir / f"{sq_key}.json" current_snapshot = { "placement": placement, "points": points, "members": member_count, "battles": battles, "wins": wins, "losses": losses, "total_kills": total_kills, "ground_kills": ground_kills, "air_kills": air_kills, "deaths": deaths, "kd_ratio": kd_ratio, "win_rate": win_rate, } # Load previous snapshot if it exists prev = await load_json(snapshot_path, None) # Save current snapshot for next time await write_json(snapshot_path, current_snapshot) # Helper to format a value with its diff def _diff(label, cur, old_val, fmt="d", suffix=""): """Return display string. fmt='d' for int, 'f2' for 2-decimal float.""" if fmt == "f2": display = f"{cur:.2f}{suffix}" else: display = f"{cur:,}{suffix}" if old_val is None: return display delta = cur - old_val if delta == 0: return display if fmt == "f2": sign = "+" if delta > 0 else "" return f"{display} ({sign}{delta:.2f})" else: sign = "+" if delta > 0 else "" return f"{display} ({sign}{delta:,})" p = prev or {} # Format placement diff (lower rank = better, so invert sign display) if placement is not None: placement_str = f"#{placement}" old_placement = p.get("placement") if old_placement is not None: delta = placement - old_placement if delta != 0: # negative delta = moved up = good, show as positive sign = "+" if delta < 0 else "" placement_str += f" ({sign}{-delta:,})" else: placement_str = "N/A" # Build embed embed = discord.Embed(title=f"**{clan_tag}**", color=discord.Color.green()) embed.add_field(name=t(lang, "common.placement_field"), value=placement_str, inline=True) embed.add_field(name=t(lang, "common.points_field"), value=_diff("Points", points, p.get("points")), inline=True) embed.add_field(name=t(lang, "common.members_field"), value=_diff("Members", member_count, p.get("members")), inline=True) embed.add_field(name=t(lang, "common.win_rate_field"), value=_diff("Win Rate", win_rate, p.get("win_rate"), fmt="f2", suffix="%"), inline=True) embed.add_field(name=t(lang, "common.battles_field"), value=_diff("Battles", battles, p.get("battles")), inline=True) embed.add_field(name=t(lang, "common.wins_field"), value=_diff("Wins", wins, p.get("wins")), inline=True) embed.add_field(name=t(lang, "common.losses_field"), value=_diff("Losses", losses, p.get("losses")), inline=True) embed.add_field(name=t(lang, "common.total_kills_field"), value=_diff("Total Kills", total_kills, p.get("total_kills")), inline=True) embed.add_field(name=t(lang, "common.ground_kills_field"), value=_diff("Ground Kills", ground_kills, p.get("ground_kills")), inline=True) embed.add_field(name=t(lang, "common.air_kills_field"), value=_diff("Air Kills", air_kills, p.get("air_kills")), inline=True) embed.add_field(name=t(lang, "common.deaths_field"), value=_diff("Deaths", deaths, p.get("deaths")), inline=True) embed.add_field(name=t(lang, "common.kd_field"), value=_diff("KD Ratio", kd_ratio, p.get("kd_ratio"), fmt="f2"), inline=True) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed, ephemeral=False) @track_squadron.error async def track_perm_error(interaction, error): await permission_fail(interaction, error) # ============================================================================ # ============================================================================ # /analytics — Advanced SQB analytics # ============================================================================ CONSISTENCY_PAGE_SIZE = 10 MAP_PAGE_SIZE = 15 def _build_map_embed(data: list[dict], sq_short: str, page: int, lang: str = 'en') -> Embed: """Build one page of the map win-rates embed with visual bar charts. Args: data: List of map stat dicts with 'map_name', 'wins', 'losses', 'win_rate'. sq_short: Squadron short name for the embed title. page: Zero-indexed page number. lang: Locale code for translations. Returns: Embed displaying map win rates for the requested page. """ total_pages = math.ceil(len(data) / MAP_PAGE_SIZE) start = page * MAP_PAGE_SIZE page_data = data[start:start + MAP_PAGE_SIZE] lines = [] for r in page_data: filled = max(1, round(r["win_rate"] / 10)) bar = "\u2588" * filled + "\u2591" * (10 - filled) lines.append(f"**{esc(r['map_name'])}** — {r['wins']}W / {r['losses']}L ({r['win_rate']}%)\n> {bar}") embed = Embed(title=t(lang, "analytics.map_title", squadron=sq_short), description="\n".join(lines), color=Color.green()) embed.set_footer(text=f"Page {page + 1}/{total_pages} • {len(data)} maps • {DEFAULT_FOOTER_CAT}") return embed class MapStatsPaginatorView(discord.ui.View): """Paginated view for browsing map win-rate statistics with Prev/Next buttons.""" def __init__(self, data: list[dict], sq_short: str, page: int = 0, lang: str = 'en'): super().__init__(timeout=300) self.data = data self.sq_short = sq_short self.page = page self.lang = lang self.total_pages = math.ceil(len(data) / MAP_PAGE_SIZE) self._update_buttons() self.prev_btn.label = t(lang, "buttons.prev") self.next_btn.label = t(lang, "buttons.next") def _update_buttons(self): self.prev_btn.disabled = self.page <= 0 self.next_btn.disabled = self.page >= self.total_pages - 1 @discord.ui.button(label="Prev", style=discord.ButtonStyle.grey) async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page = max(0, self.page - 1) self._update_buttons() await interaction.response.edit_message(embed=_build_map_embed(self.data, self.sq_short, self.page, self.lang), view=self) @discord.ui.button(label="Next", style=discord.ButtonStyle.grey) async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page = min(self.total_pages - 1, self.page + 1) self._update_buttons() await interaction.response.edit_message(embed=_build_map_embed(self.data, self.sq_short, self.page, self.lang), view=self) def _build_consistency_embed(data: list[dict], sq_short: str, page: int, lang: str = 'en') -> Embed: """Build one page of the consistency embed.""" total_pages = math.ceil(len(data) / CONSISTENCY_PAGE_SIZE) start = page * CONSISTENCY_PAGE_SIZE end = start + CONSISTENCY_PAGE_SIZE page_data = data[start:end] # Scale bars relative to the full dataset range all_scores = [r["consistency_score"] for r in data] lo, hi = min(all_scores), max(all_scores) spread = hi - lo if hi > lo else 1 lines = [] for i, r in enumerate(page_data, start + 1): nick = esc(r["nick"]) normalized = 1 - (r["consistency_score"] - lo) / spread filled = max(1, round(normalized * 10)) bar = "\u2588" * filled + "\u2591" * (10 - filled) lines.append( f"**{i}.** {nick} — {r['avg_kills']} avg kills / {r['avg_deaths']} avg deaths " f"({r['games']} matches)\n> {bar}" ) embed = Embed( title=t(lang, "analytics.consistency_title", squadron=sq_short), description=t(lang, "analytics.consistency_desc") + "\n\n" + "\n".join(lines), color=Color.green(), ) embed.set_footer(text=f"Page {page + 1}/{total_pages} • {len(data)} players • {DEFAULT_FOOTER_CAT}") return embed class ConsistencyPaginatorView(discord.ui.View): """Paginated view for browsing player consistency rankings with Prev/Next buttons.""" def __init__(self, data: list[dict], sq_short: str, page: int = 0, lang: str = 'en'): super().__init__(timeout=300) self.data = data self.sq_short = sq_short self.page = page self.lang = lang self.total_pages = math.ceil(len(data) / CONSISTENCY_PAGE_SIZE) self._update_buttons() self.prev_btn.label = t(lang, "buttons.prev") self.next_btn.label = t(lang, "buttons.next") def _update_buttons(self): self.prev_btn.disabled = self.page <= 0 self.next_btn.disabled = self.page >= self.total_pages - 1 @discord.ui.button(label="Prev", style=discord.ButtonStyle.grey) async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page = max(0, self.page - 1) self._update_buttons() await interaction.response.edit_message( embed=_build_consistency_embed(self.data, self.sq_short, self.page, self.lang), view=self, ) @discord.ui.button(label="Next", style=discord.ButtonStyle.grey) async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page = min(self.total_pages - 1, self.page + 1) self._update_buttons() await interaction.response.edit_message( embed=_build_consistency_embed(self.data, self.sq_short, self.page, self.lang), view=self, ) @is_blacklisted() @gate_entitle("standard") @bot.tree.command(name="analytics", description=command_locale("View advanced SQB analytics for a squadron", "commands.analytics.description")) @app_commands.describe( squadron=command_locale("Squadron short name", "commands.common.squadron_short"), view=command_locale("Which analytics view to show", "commands.analytics.view") ) @app_commands.choices(view=[ app_commands.Choice(name=command_locale("Map Win Rates", "commands.analytics.choice_maps"), value="maps"), app_commands.Choice(name=command_locale("Team Compositions", "commands.analytics.choice_comps"), value="comps"), app_commands.Choice(name=command_locale("Player Consistency", "commands.analytics.choice_consistency"), value="consistency"), app_commands.Choice(name=command_locale("Time of Day", "commands.analytics.choice_time"), value="time"), app_commands.Choice(name=command_locale("Matchup History", "commands.analytics.choice_matchups"), value="matchups"), ]) @discord.app_commands.autocomplete(squadron=squadron_autocomplete) async def analytics_cmd(interaction: discord.Interaction, view: str = "maps", squadron: str = ""): """Display advanced SQB analytics for a squadron. Supports four views: map win rates (paginated), team compositions (top 10), player consistency (paginated, sorted by K/D), and time-of-day performance with EU/NA timeslot dividers and Discord-localized timestamps. """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' try: clan = await get_guild_squadron(interaction.guild_id, squadron) except ValueError as e: return await interaction.followup.send( embed=Embed(title=t(lang, "common.error_title"), description=str(e), color=Color.red()) ) sq_short = clan["short_name"] sq_long = clan["long_name"] if view == "maps": data = await get_map_stats(sq_short) if not data: return await interaction.followup.send( embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_matches_desc"), color=Color.blue()) ) embed = _build_map_embed(data, sq_short, 0, lang) paginator = MapStatsPaginatorView(data, sq_short, lang=lang) return await interaction.followup.send(embed=embed, view=paginator) elif view == "comps": data = await get_comp_analysis(sq_short) if not data: return await interaction.followup.send( embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_comp_desc"), color=Color.blue()) ) lines = [] for r in data[:10]: filled = max(1, round(r["win_rate"] / 10)) bar = "\u2588" * filled + "\u2591" * (10 - filled) lines.append(f"`{r['comp_signature']}` — {r['wins']}W / {r['losses']}L ({r['win_rate']}%) [{r['total']} games]\n> {bar}") embed = Embed(title=t(lang, "analytics.comp_title", squadron=sq_short), description="\n".join(lines), color=Color.green()) elif view == "consistency": data = await get_player_consistency(sq_short) if not data: return await interaction.followup.send( embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_consistency_desc"), color=Color.blue()) ) embed = _build_consistency_embed(data, sq_short, 0, lang) paginator = ConsistencyPaginatorView(data, sq_short, lang=lang) return await interaction.followup.send(embed=embed, view=paginator) elif view == "time": data = await get_time_performance(sq_short) if not data: return await interaction.followup.send( embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_time_desc"), color=Color.blue()) ) # Use a reference date (epoch day 0) to build Discord timestamps for each hour # Discord renders in the viewer's local timezone ref = datetime(2025, 1, 1, tzinfo=timezone.utc) # SQB timeslot regions (from tasks.py: EU 13:55-22:10, NA 00:55-07:10 UTC) eu_hours = set(range(14, 23)) # 14:00-22:00 UTC na_hours = set(range(1, 8)) # 01:00-07:00 UTC lines = [] prev_region = None for hour in range(24): if hour not in data: continue stats = data[hour] # Insert region dividers if hour in eu_hours: region = "eu" elif hour in na_hours: region = "na" else: region = "off" if region != prev_region: if region == "eu": lines.append(t(lang, "analytics.eu_timeslot")) elif region == "na": lines.append(t(lang, "analytics.na_timeslot")) elif prev_region is not None: lines.append(t(lang, "analytics.off_peak")) prev_region = region ts = int(ref.replace(hour=hour).timestamp()) filled = max(1, round(stats["win_rate"] / 10)) bar = "\u2588" * filled + "\u2591" * (10 - filled) lines.append(f" {stats['wins']}W / {stats['losses']}L ({stats['win_rate']}%)\n> {bar}") embed = Embed(title=t(lang, "analytics.time_title", squadron=sq_short), description="\n".join(lines), color=Color.green()) elif view == "matchups": data = await get_matchup_history(sq_short) if not data["won_against"] and not data["lost_against"]: return await interaction.followup.send( embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_matchups_desc"), color=Color.blue()) ) def _fmt(entries: list[dict]) -> str: if not entries: return "—" return "\n".join( f"**{esc(e['opponent'])}** — {e['wins']}W / {e['losses']}L ({e['total']} games)" for e in entries ) embed = Embed( title=t(lang, "analytics.matchups_title", squadron=sq_short), color=Color.green(), ) embed.add_field(name=t(lang, "analytics.matchups_won_field"), value=_fmt(data["won_against"]), inline=True) embed.add_field(name=t(lang, "analytics.matchups_lost_field"), value=_fmt(data["lost_against"]), inline=True) embed.set_footer(text=f"{data['total_opponents']} unique opponents • {DEFAULT_FOOTER_CAT}") return await interaction.followup.send(embed=embed) else: return await interaction.followup.send(t(lang, "analytics.unknown_view"), ephemeral=True) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed) # /recent — Last 5 matches for a squadron # ============================================================================ @is_blacklisted() @bot.tree.command( name="recent", description=command_locale("Show recent squadron battles for a squadron", "commands.recent.description") ) @app_commands.describe( squadron=command_locale("Short name of the squadron", "commands.common.squadron_short"), length=command_locale("Number of matches to show", "commands.recent.length") ) @discord.app_commands.autocomplete(squadron=squadron_autocomplete) async def recent(interaction: discord.Interaction, squadron: str = "", length: int = 10): """Show recent SQB match history for a squadron. Queries match_summary from SQLite for the squadron's recent wins/losses, formats each match with opponent, map, and relative timestamp, then splits results across multiple embeds if the content exceeds Discord's character limit. """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' length = max(1, min(length, 100)) # Resolve squadron try: clan = await get_guild_squadron(interaction.guild_id, squadron) except ValueError as e: return await interaction.followup.send( embed=Embed(title=t(lang, "common.error_title"), description=str(e), color=Color.red()) ) squadron_long = clan["long_name"] squadron_short = clan["short_name"] clan_id_val = clan.get("clan_id") if isinstance(clan, dict) else None # Prefer clan_id so post-rename history stays attached. Fall back to # short_name lookup for any rows that didn't backfill (orphans). async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: await db.execute("PRAGMA busy_timeout=30000;") if clan_id_val: async with db.execute( """SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq FROM match_summary WHERE winning_clan_id = ? OR losing_clan_id = ? OR ((winning_clan_id IS NULL OR losing_clan_id IS NULL) AND (winning_sq = ? OR losing_sq = ?)) ORDER BY endtime_unix DESC LIMIT ?""", (clan_id_val, clan_id_val, squadron_short, squadron_short, length) ) as cursor: rows = await cursor.fetchall() else: async with db.execute( """SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq FROM match_summary WHERE winning_sq = ? OR losing_sq = ? ORDER BY endtime_unix DESC LIMIT ?""", (squadron_short, squadron_short, length) ) as cursor: rows = await cursor.fetchall() if not rows: embed = Embed( title=t(lang, "recent.title", squadron=squadron_short), description=t(lang, "recent.no_matches_desc"), color=Color.blue() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return await interaction.followup.send(embed=embed) lines = [] for session_id, map_name, endtime_unix, winning_sq, losing_sq in rows: is_win = (winning_sq == squadron_short) opponent = losing_sq if is_win else winning_sq if is_win: entry = f"\U0001f451 **{squadron_short}** vs {opponent} \U0001f494" else: entry = f"\U0001f494 **{squadron_short}** vs {opponent} \U0001f451" ts = f"" if endtime_unix else "Unknown" entry += f"\n> **Map:** {map_name or 'Unknown'} | {ts}" lines.append(entry) # Split into multiple embeds if description exceeds 4096 chars embeds = [] current_lines: list[str] = [] current_len = 0 for line in lines: if current_len + len(line) + 1 > 4000 and current_lines: embed = Embed( title=t(lang, "recent.title", squadron=squadron_short) if not embeds else "", description="\n".join(current_lines), color=Color.blue() ) embeds.append(embed) current_lines = [] current_len = 0 current_lines.append(line) current_len += len(line) + 1 if current_lines: embed = Embed( title=t(lang, "recent.title", squadron=squadron_short) if not embeds else "", description="\n".join(current_lines), color=Color.blue() ) embeds.append(embed) embeds[-1].set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embeds=embeds) @recent.error async def recent_error(interaction, error): await permission_fail(interaction, error) # ============================================================================ # /vs — Head-to-head record against another squadron # ============================================================================ @is_blacklisted() @bot.tree.command( name="vs", description=command_locale("Head-to-head record between two squadrons", "commands.vs.description") ) @app_commands.describe( squadron_a=command_locale("First squadron", "commands.vs.squadron_a"), squadron_b=command_locale("Second squadron", "commands.vs.squadron_b") ) @discord.app_commands.autocomplete(squadron_a=squadron_autocomplete, squadron_b=squadron_autocomplete) async def vs(interaction: discord.Interaction, squadron_a: str = "", squadron_b: str = ""): """Display the head-to-head SQB record between two squadrons. Resolves both squadron names (falling back to the server default), queries match_summary for all games between them, computes the win/loss record, and shows the most recent 5 encounters with map and timestamp details. """ await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' # Fill in blanks from server default if not squadron_a or not squadron_b: if not squadron_a and not squadron_b: return await interaction.followup.send( embed=Embed( title=t(lang, "h2h.two_required_title"), description=t(lang, "h2h.two_required_desc"), color=Color.red() ) ) try: default_clan = await get_guild_squadron(interaction.guild_id) default_short = default_clan["short_name"] except ValueError: default_short = "" if not squadron_a: if not default_short: return await interaction.followup.send( embed=Embed(title=t(lang, "h2h.two_required_title"), description=t(lang, "h2h.provide_a_desc"), color=Color.red()) ) squadron_a = default_short elif not squadron_b: if not default_short: return await interaction.followup.send( embed=Embed(title=t(lang, "h2h.two_required_title"), description=t(lang, "h2h.provide_b_desc"), color=Color.red()) ) squadron_b = default_short # Resolve squadron A try: clan_a = await get_guild_squadron(interaction.guild_id, squadron_a) except ValueError as e: return await interaction.followup.send( embed=Embed(title=t(lang, "h2h.squadron_not_found_title"), description=str(e), color=Color.red()) ) a_long = clan_a["long_name"] a_short = clan_a["short_name"] # Resolve squadron B try: clan_b = await get_guild_squadron(interaction.guild_id, squadron_b) except ValueError as e: return await interaction.followup.send( embed=Embed(title=t(lang, "h2h.squadron_not_found_title"), description=str(e), color=Color.red()) ) b_long = clan_b["long_name"] b_short = clan_b["short_name"] if a_short == b_short: return await interaction.followup.send( embed=Embed( title=t(lang, "h2h.same_squadron_title"), description=t(lang, "h2h.same_squadron_desc"), color=Color.orange() ) ) # Query head-to-head matches (match_summary stores short squadron tags) async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: await db.execute("PRAGMA busy_timeout=30000;") async with db.execute( """SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq FROM match_summary WHERE (winning_sq = ? AND losing_sq = ?) OR (winning_sq = ? AND losing_sq = ?) ORDER BY endtime_unix DESC""", (a_short, b_short, b_short, a_short) ) as cursor: all_matches = await cursor.fetchall() all_matches = list(all_matches) a_wins = sum(1 for r in all_matches if r[3] == a_short) b_wins = sum(1 for r in all_matches if r[3] == b_short) total = len(all_matches) if a_wins > b_wins: color = Color.green() elif b_wins > a_wins: color = Color.red() else: color = Color.gold() embed = Embed( title=f"{a_short} vs {b_short}", description=t(lang, "h2h.record_desc", a_wins=a_wins, b_wins=b_wins, total=total), color=color ) if not all_matches: embed.description = t(lang, "h2h.no_matches_desc", a=a_short, b=b_short) else: for session_id, map_name, endtime_unix, winning_sq, losing_sq in all_matches[:5]: a_won = (winning_sq == a_short) if a_won: title = f"\U0001f451 {a_short} vs {b_short} \U0001f494" else: title = f"\U0001f494 {a_short} vs {b_short} \U0001f451" ts = f"" if endtime_unix else "Unknown" embed.add_field( name=title, value=f"**Map:** {map_name or 'Unknown'} | {ts}", inline=False ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed) @vs.error async def vs_error(interaction, error): await permission_fail(interaction, error) class NotificationTypeSelect(discord.ui.Select): """Dropdown to choose a notification management category.""" def __init__(self, lang: str = 'en'): self.lang = lang options = [ discord.SelectOption(label=t(lang, "autolog.logs_option"), description=t(lang, "autolog.logs_option_desc")), discord.SelectOption(label=t(lang, "autolog.points_option"), description=t(lang, "autolog.points_option_desc")), discord.SelectOption(label="Reports", description="Manage leaderboard and Weekly BR report routes."), ] super().__init__( placeholder=t(lang, "autolog.select_notif_placeholder"), min_values=1, max_values=1, options=options ) async def callback(self, interaction: discord.Interaction): notif_type = self.values[0] guild_id = interaction.guild.id # type: ignore # Proceed to Step 2: Squadron selection. view = await create_squadron_select_view(guild_id, notif_type, self.lang) header = t(self.lang, "autolog.selected_type", type=_notif_type_label(notif_type)) # Append a tier-cap usage header so the user knows their limits upfront. # Skip for Free users (no tier) and for uncapped categories. if tier_enforcement_active() and notif_type not in ("Leaderboard", REPORTS_NOTIF_TYPE): tier = await get_guild_tier(guild_id) if tier is not None: prefs = await load_guild_preferences(guild_id) used = len(enabled_non_wildcard_keys_for(prefs, notif_type)) cap = tier_cap(tier, notif_type) cap_str = "∞" if cap is None else str(cap) header += "\n" + t(self.lang, "autolog.cap_header", used=used, cap=cap_str, notif=_notif_type_label(notif_type), tier=tier.title()) await interaction.response.send_message(header, view=view, ephemeral=True) class NotificationManagementView(discord.ui.View): """Top-level view containing the notification type selector.""" def __init__(self, lang: str = 'en'): super().__init__(timeout=1200) self.message: Optional[discord.Message] = None self.add_item(NotificationTypeSelect(lang)) async def on_timeout(self): for item in self.children: item.disabled = True # type: ignore[attr-defined] try: if self.message: await self.message.edit(view=self) except Exception: pass # Basic (non-paginated) squadron dropdown (if 25 or fewer squadrons) class SquadronSelect(discord.ui.Select): """Dropdown listing squadrons with a specific notification type configured (<=25).""" def __init__(self, guild_id, notif_type, option_rows, lang: str = 'en', over_cap_keys: Optional[set[str]] = None, wildcard_blocked: bool = False): self.guild_id = guild_id self.notif_type = notif_type self.option_rows = option_rows self.lang = lang enabled_label = t(lang, "common.enabled") disabled_label = t(lang, "common.disabled") over_cap_keys = over_cap_keys or set() options = [] for row in option_rows: squadron = row["squadron"] settings = row["settings"] actual_notif_type = row["notif_type"] channel_val = settings[actual_notif_type] is_disabled = channel_val.startswith("<#DISABLED-") if is_disabled: prefix = "❌" state = disabled_label elif wildcard_blocked and squadron.lower() in WILDCARD_KEYS: prefix = "🚫" state = enabled_label elif squadron in over_cap_keys: prefix = "⚠️" state = enabled_label else: prefix = "✅" state = enabled_label display_label = settings.get("Long") if isinstance(settings, dict) else None display_label = display_label or squadron type_suffix = f" [{_notif_type_label(actual_notif_type)}]" if notif_type == REPORTS_NOTIF_TYPE else "" options.append( discord.SelectOption( label=f"{prefix} {display_label}{type_suffix}", value=_encode_management_value(squadron, actual_notif_type), description=f"{_notif_type_label(actual_notif_type)} • {state}: {channel_val}")) if not options: options.append( discord.SelectOption(label=t(lang, "common.none_option"), description=t(lang, "autolog.no_squadrons_desc"), value="none")) super().__init__(placeholder=t(lang, "autolog.select_squadron_placeholder"), min_values=1, max_values=1, options=options) async def callback(self, interaction: discord.Interaction): selected_value = self.values[0] if selected_value == "none": await interaction.response.send_message( t(self.lang, "autolog.no_squadrons_available"), ephemeral=True) return selected_notif_type, selected_squadron = _decode_management_value(selected_value) # Retrieve the current channel value for the selected notification type. preferences = await load_guild_preferences(self.guild_id) squadron_settings = preferences.get(selected_squadron, {}) channel_value = squadron_settings.get(selected_notif_type, "Not configured") if channel_value != "Not configured": # Check if the value is wrapped in <#...> if channel_value.startswith("<#") and channel_value.endswith(">"): channel_id_str = channel_value[2:-1] else: channel_id_str = channel_value # Remove "DISABLED-" if present. if channel_id_str.startswith("DISABLED-"): channel_id_str = channel_id_str[len("DISABLED-"):] try: channel_id = int(channel_id_str) channel = interaction.guild.get_channel(channel_id) # type: ignore channel_name = channel.name if channel else t(self.lang, "common.unknown") except ValueError: channel_name = t(self.lang, "common.unknown") else: channel_name = t(self.lang, "common.not_configured") # Proceed to Step 3: Display toggle and change channel buttons. view = ToggleView(self.guild_id, selected_notif_type, selected_squadron, channel_value, self.lang) # Display name comes from the entry's cached "Long" (set by /quick_log # and refreshed by the autolog Short/Long sync). Falls back to the key # itself for orphan / pre-migration entries. display_squadron = ( squadron_settings.get("Long") if isinstance(squadron_settings, dict) else None ) or selected_squadron # Special messaging for Global (Leaderboard) if selected_squadron == "Global": message = t(self.lang, "autolog.managing_global", type=_notif_type_label(selected_notif_type), channel=channel_name) else: message = t(self.lang, "autolog.managing_squadron", type=_notif_type_label(selected_notif_type), squadron=display_squadron, channel=channel_name) await interaction.response.send_message( message, view=view, ephemeral=True) # New classes for paginated squadron selection (> 25 squadrons, somehow.) class PaginatedSquadronSelect(discord.ui.Select): """Paginated squadron dropdown for servers tracking more than 25 squadrons.""" def __init__(self, guild_id, notif_type, option_rows, page=0, lang: str = 'en', over_cap_keys: Optional[set[str]] = None, wildcard_blocked: bool = False): self.guild_id = guild_id self.notif_type = notif_type self.option_rows = option_rows self.page = page self.lang = lang self.over_cap_keys = over_cap_keys or set() self.wildcard_blocked = wildcard_blocked options = self.get_options(page) super().__init__(placeholder=t(lang, "autolog.select_squadron_page_placeholder", page=page+1), min_values=1, max_values=1, options=options) def get_options(self, page): start = page * 25 end = start + 25 options = [] enabled_label = t(self.lang, "common.enabled") disabled_label = t(self.lang, "common.disabled") for row in self.option_rows[start:end]: squadron = row["squadron"] settings = row["settings"] actual_notif_type = row["notif_type"] channel_val = settings[actual_notif_type] is_disabled = channel_val.startswith("<#DISABLED-") if is_disabled: prefix = "❌" state = disabled_label elif self.wildcard_blocked and squadron.lower() in WILDCARD_KEYS: prefix = "🚫" state = enabled_label elif squadron in self.over_cap_keys: prefix = "⚠️" state = enabled_label else: prefix = "✅" state = enabled_label display_label = settings.get("Long") if isinstance(settings, dict) else None display_label = display_label or squadron type_suffix = f" [{_notif_type_label(actual_notif_type)}]" if self.notif_type == REPORTS_NOTIF_TYPE else "" options.append( discord.SelectOption(label=f"{prefix} {display_label}{type_suffix}", value=_encode_management_value(squadron, actual_notif_type), description=f"{_notif_type_label(actual_notif_type)} • {state}: {channel_val}")) if not options: options.append( discord.SelectOption(label=t(self.lang, "common.none_option"), description=t(self.lang, "autolog.no_squadrons_desc"), value="none")) return options async def callback(self, interaction: discord.Interaction): selected_value = self.values[0] if selected_value == "none": await interaction.response.send_message( t(self.lang, "autolog.no_squadrons_available"), ephemeral=True) return selected_notif_type, selected_squadron = _decode_management_value(selected_value) preferences = await load_guild_preferences(self.guild_id) squadron_settings = preferences.get(selected_squadron, {}) channel_value = squadron_settings.get(selected_notif_type, "Not configured") if channel_value != "Not configured": # Check if the value is wrapped in <#...> if channel_value.startswith("<#") and channel_value.endswith(">"): channel_id_str = channel_value[2:-1] else: channel_id_str = channel_value # Remove "DISABLED-" if present. if channel_id_str.startswith("DISABLED-"): channel_id_str = channel_id_str[len("DISABLED-"):] try: channel_id = int(channel_id_str) channel = interaction.guild.get_channel(channel_id) # type: ignore channel_name = channel.name if channel else t(self.lang, "common.unknown") except ValueError: channel_name = t(self.lang, "common.unknown") else: channel_name = t(self.lang, "common.not_configured") view = ToggleView(self.guild_id, selected_notif_type, selected_squadron, channel_value, self.lang) display_squadron = ( squadron_settings.get("Long") if isinstance(squadron_settings, dict) else None ) or selected_squadron # Special messaging for Global (Leaderboard) if selected_squadron == "Global": message = t(self.lang, "autolog.managing_global", type=_notif_type_label(selected_notif_type), channel=channel_name) else: message = t(self.lang, "autolog.managing_squadron", type=_notif_type_label(selected_notif_type), squadron=display_squadron, channel=channel_name) await interaction.response.send_message( message, view=view, ephemeral=True) class PrevPageButton(discord.ui.Button): """Previous-page button for PaginatedSquadronSelectView.""" def __init__(self, lang: str = 'en'): super().__init__(label=t(lang, "buttons.previous"), style=discord.ButtonStyle.secondary) self.lang = lang async def callback(self, interaction: discord.Interaction): view: PaginatedSquadronSelectView = self.view # type: ignore if view.page > 0: view.page -= 1 view.select.page = view.page view.select.options = view.select.get_options(view.page) view.select.placeholder = t(self.lang, "autolog.select_squadron_page_placeholder", page=view.page+1) await interaction.response.edit_message(view=view) class NextPageButton(discord.ui.Button): """Next-page button for PaginatedSquadronSelectView.""" def __init__(self, lang: str = 'en'): super().__init__(label=t(lang, "buttons.next"), style=discord.ButtonStyle.secondary) self.lang = lang async def callback(self, interaction: discord.Interaction): view: PaginatedSquadronSelectView = self.view # type: ignore if view.page < view.total_pages - 1: view.page += 1 view.select.page = view.page view.select.options = view.select.get_options(view.page) view.select.placeholder = t(self.lang, "autolog.select_squadron_page_placeholder", page=view.page+1) await interaction.response.edit_message(view=view) class PaginatedSquadronSelectView(discord.ui.View): """View combining a paginated squadron select with prev/next navigation buttons.""" def __init__(self, guild_id, notif_type, option_rows, page=0, lang: str = 'en', over_cap_keys: Optional[set[str]] = None, wildcard_blocked: bool = False): super().__init__(timeout=1200) self.guild_id = guild_id self.notif_type = notif_type self.option_rows = option_rows self.page = page self.lang = lang self.total_pages = math.ceil(len(option_rows) / 25) self.select = PaginatedSquadronSelect(guild_id, notif_type, option_rows, page, lang, over_cap_keys=over_cap_keys, wildcard_blocked=wildcard_blocked) self.add_item(self.select) self.add_item(PrevPageButton(lang)) self.add_item(NextPageButton(lang)) async def create_squadron_select_view(guild_id, notif_type, lang: str = 'en'): """Return a View with either a paginated select or a basic select, based on number of squadrons. When tier enforcement is active, adds ⚠️ badges to squadrons enabled beyond the tier cap and 🚫 to wildcard entries on tiers that don't allow them. """ preferences = await load_guild_preferences(guild_id) option_rows = _management_pref_rows(preferences, notif_type) # Compute tier-related badge sets for the chosen notif type. # Free/unentitled (tier=None) skips badges — premium gate handles them upstream. over_cap_keys: set[str] = set() wildcard_blocked = False if tier_enforcement_active() and notif_type not in ("Leaderboard", REPORTS_NOTIF_TYPE): tier = await get_guild_tier(guild_id) if tier is not None: wildcard_blocked = not tier_allows_wildcard(tier) enabled = set(enabled_pref_keys_for(preferences, notif_type)) allowed = allowed_pref_keys_for(preferences, tier, notif_type) over_cap_keys = enabled - allowed if len(option_rows) > 25: return PaginatedSquadronSelectView( guild_id, notif_type, option_rows, lang=lang, over_cap_keys=over_cap_keys, wildcard_blocked=wildcard_blocked, ) else: view = discord.ui.View(timeout=180) view.add_item(SquadronSelect( guild_id, notif_type, option_rows, lang, over_cap_keys=over_cap_keys, wildcard_blocked=wildcard_blocked, )) return view class ToggleButton(discord.ui.Button): """Button that enables or disables a notification for a specific squadron.""" def __init__(self, guild_id, notif_type, squadron, channel_value, lang: str = 'en'): self.lang = lang # If it already has "DISABLED-", we're currently disabled → label should read "Enable" label = t(lang, "buttons.enable") if "DISABLED-" in channel_value else t(lang, "buttons.disable") super().__init__(label=label, style=discord.ButtonStyle.primary) self.guild_id = guild_id self.notif_type = notif_type self.squadron = squadron async def callback(self, interaction: discord.Interaction): # 1) reload prefs prefs = await load_guild_preferences(self.guild_id) current = prefs.get(self.squadron, {}).get(self.notif_type, "") # 2) extract the raw channel ID (handles bare digits, <#ID>, or <#DISABLED-ID>) m = re.search(r"(?:<#DISABLED-?)?(\d+)>?$", current) if not m: return await interaction.response.edit_message( content="⚠️ Couldn't parse stored channel ID.", view=self.view ) raw_id = m.group(1) # 3) flip state is_currently_disabled = "DISABLED-" in current # Tier enforcement — only runs when flipping DISABLED → ENABLED. # Leaderboard/Weekly BR routes are uncapped; disabling is always allowed. # Free/unentitled users skip this gate — the autologging premium gate blocks # dispatch for them anyway, and letting them configure prefs up-front is better UX. tier = await get_guild_tier(self.guild_id) if is_currently_disabled and tier is not None and tier_enforcement_active() and self.notif_type not in ("Leaderboard", "WeeklyBR"): if self.squadron.lower() in WILDCARD_KEYS and not tier_allows_wildcard(tier): wb_embed = discord.Embed( title=t(self.lang, "autolog.wildcard_blocked_title"), description=t(self.lang, "autolog.wildcard_blocked_desc", tier=(tier or "none").title(), notif=_notif_type_label(self.notif_type)), color=discord.Color.orange(), ) wb_embed.set_footer(text=t(self.lang, "autolog.over_cap_footer")) await interaction.response.send_message(embed=wb_embed, ephemeral=True) return cap = tier_cap(tier, self.notif_type) # Wildcards don't count toward the cap — skip the cap check when enabling one if cap is not None and self.squadron.lower() not in WILDCARD_KEYS: enabled_now = set(enabled_non_wildcard_keys_for(prefs, self.notif_type)) - {self.squadron} if len(enabled_now) >= cap: entry = prefs.get(self.squadron) or {} display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron oc_embed = discord.Embed( title=t(self.lang, "autolog.over_cap_title"), description=t(self.lang, "autolog.over_cap_desc", tier=(tier or "none").title(), notif=_notif_type_label(self.notif_type), cap=cap, squadron=display_squadron), color=discord.Color.orange(), ) oc_embed.set_footer(text=t(self.lang, "autolog.over_cap_footer")) await interaction.response.send_message(embed=oc_embed, ephemeral=True) return if is_currently_disabled: new_value = f"<#{raw_id}>" state_word = t(self.lang, "common.enabled") self.label = t(self.lang, "buttons.disable") else: new_value = f"<#DISABLED-{raw_id}>" state_word = t(self.lang, "common.disabled") self.label = t(self.lang, "buttons.enable") # 4) save and update UI prefs[self.squadron][self.notif_type] = new_value await save_guild_preferences(self.guild_id, prefs) entry = prefs.get(self.squadron) or {} display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron # Special messaging for Global (Leaderboard) if self.squadron == "Global": message = t(self.lang, "autolog.global_toggled", type=_notif_type_label(self.notif_type), state=state_word) else: message = t(self.lang, "autolog.squadron_toggled", type=_notif_type_label(self.notif_type), squadron=display_squadron, state=state_word) await interaction.response.edit_message( content=message, view=self.view ) class ChangeChannelButton(discord.ui.Button): """Button that opens a channel selector to reassign a notification's target channel.""" def __init__(self, guild_id, notif_type, squadron, lang: str = 'en'): super().__init__(label=t(lang, "buttons.change_channel"), style=discord.ButtonStyle.secondary) self.guild_id = guild_id self.notif_type = notif_type self.squadron = squadron self.lang = lang async def callback(self, interaction: discord.Interaction): guild = interaction.guild # If there are more than 25 channels, use the paginated view. if len(guild.text_channels) > 25: # type: ignore view = PaginatedChannelSelectView(guild, self.squadron, self.notif_type, lang=self.lang) else: view = ChannelSelectView(guild, self.notif_type, self.squadron, lang=self.lang) await interaction.response.send_message(t(self.lang, "autolog.select_channel"), view=view, ephemeral=True) # The existing ChannelSelect view (for servers with 25 or fewer text channels) class ChannelSelect(discord.ui.Select): """Dropdown listing all text channels in a guild (<=25 channels).""" def __init__(self, guild, notif_type, squadron, lang: str = 'en'): self.notif_type = notif_type self.squadron = squadron self.lang = lang options = [] for channel in guild.text_channels: options.append( discord.SelectOption(label=channel.name, value=str(channel.id))) super().__init__(placeholder=t(lang, "autolog.select_channel_placeholder"), min_values=1, max_values=1, options=options) async def callback(self, interaction: discord.Interaction): selected_channel_id = self.values[0] new_value = f"<#{selected_channel_id}>" preferences = await load_guild_preferences(interaction.guild.id) # type: ignore if self.squadron in preferences and self.notif_type in preferences[ self.squadron]: preferences[self.squadron][self.notif_type] = new_value await save_guild_preferences(interaction.guild.id, preferences) # type: ignore entry = preferences.get(self.squadron) or {} display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron # Special messaging for Global (Leaderboard) if self.squadron == "Global": message = t(self.lang, "autolog.channel_updated_global", type=_notif_type_label(self.notif_type), channel=new_value) else: message = t(self.lang, "autolog.channel_updated_squadron", type=_notif_type_label(self.notif_type), squadron=display_squadron, channel=new_value) await interaction.response.send_message( message, ephemeral=True) else: await interaction.response.send_message(t(self.lang, "common.configuration_not_found"), ephemeral=True) class ChannelSelectView(discord.ui.View): """Container view wrapping a single ChannelSelect dropdown.""" def __init__(self, guild, notif_type, squadron, lang: str = 'en'): super().__init__(timeout=1200) self.add_item(ChannelSelect(guild, notif_type, squadron, lang)) class ToggleView(discord.ui.View): """View combining a ToggleButton and ChangeChannelButton for a notification entry.""" def __init__(self, guild_id, notif_type, squadron, channel_value="Not configured", lang: str = 'en'): super().__init__(timeout=1200) self.add_item( ToggleButton(guild_id, notif_type, squadron, channel_value, lang)) self.add_item(ChangeChannelButton(guild_id, notif_type, squadron, lang)) # Paginated select menu for channels. class PaginatedChannelSelect(discord.ui.Select): """Paginated channel dropdown for servers with more than 25 text channels.""" def __init__(self, guild, squadron, notif_type, page=0, lang: str = 'en'): self.guild = guild self.squadron = squadron self.notif_type = notif_type self.page = page self.lang = lang self.channels = list(guild.text_channels) options = self.get_options(page) super().__init__(placeholder=t(lang, "autolog.select_channel_page_placeholder", page=page+1), min_values=1, max_values=1, options=options) def get_options(self, page): start = page * 25 end = start + 25 options = [] for channel in self.channels[start:end]: options.append( discord.SelectOption(label=channel.name, value=str(channel.id))) # If there are no channels for this page, provide a fallback option. if not options: options.append( discord.SelectOption(label=t(self.lang, "common.none_option"), description=t(self.lang, "autolog.no_channels_desc"), value="none")) return options async def callback(self, interaction: discord.Interaction): selected_channel_id = self.values[0] if selected_channel_id == "none": await interaction.response.send_message(t(self.lang, "common.no_channel_selected"), ephemeral=True) return new_value = f"<#{selected_channel_id}>" preferences = await load_guild_preferences(interaction.guild.id) # type: ignore if self.squadron in preferences and self.notif_type in preferences[ self.squadron]: preferences[self.squadron][self.notif_type] = new_value await save_guild_preferences(interaction.guild.id, preferences) # type: ignore entry = preferences.get(self.squadron) or {} display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron # Special messaging for Global (Leaderboard) if self.squadron == "Global": message = t(self.lang, "autolog.channel_updated_global", type=_notif_type_label(self.notif_type), channel=new_value) else: message = t(self.lang, "autolog.channel_updated_squadron", type=_notif_type_label(self.notif_type), squadron=display_squadron, channel=new_value) await interaction.response.send_message( message, ephemeral=True) else: await interaction.response.send_message(t(self.lang, "common.configuration_not_found"), ephemeral=True) # Button to go to the previous page. class PrevChannelPageButton(discord.ui.Button): """Previous-page button for PaginatedChannelSelectView.""" def __init__(self, lang: str = 'en'): super().__init__(label=t(lang, "buttons.previous"), style=discord.ButtonStyle.secondary) self.lang = lang async def callback(self, interaction: discord.Interaction): view: PaginatedChannelSelectView = self.view # type: ignore if view.page > 0: view.page -= 1 view.select.page = view.page view.select.options = view.select.get_options(view.page) view.select.placeholder = t(self.lang, "autolog.select_channel_page_placeholder", page=view.page+1) await interaction.response.edit_message(view=view) # Button to go to the next page. class NextChannelPageButton(discord.ui.Button): """Next-page button for PaginatedChannelSelectView.""" def __init__(self, lang: str = 'en'): super().__init__(label=t(lang, "buttons.next"), style=discord.ButtonStyle.secondary) self.lang = lang async def callback(self, interaction: discord.Interaction): view: PaginatedChannelSelectView = self.view # type: ignore if view.page < view.total_pages - 1: view.page += 1 view.select.page = view.page view.select.options = view.select.get_options(view.page) view.select.placeholder = t(self.lang, "autolog.select_channel_page_placeholder", page=view.page+1) await interaction.response.edit_message(view=view) # View that contains the paginated channel select and navigation buttons. class PaginatedChannelSelectView(discord.ui.View): """View combining a paginated channel select with prev/next navigation buttons.""" def __init__(self, guild, squadron, notif_type, page=0, lang: str = 'en'): super().__init__(timeout=1200) self.guild = guild self.squadron = squadron self.notif_type = notif_type self.channels = list(guild.text_channels) self.page = page self.lang = lang self.total_pages = math.ceil(len(self.channels) / 25) self.select = PaginatedChannelSelect(guild, squadron, notif_type, page, lang) self.add_item(self.select) self.add_item(PrevChannelPageButton(lang)) self.add_item(NextChannelPageButton(lang)) class DiagnoseChannelSelect(discord.ui.ChannelSelect): """Channel picker that triggers autolog permission diagnostics on the selected channel.""" def __init__(self, lang: str = 'en'): super().__init__( placeholder=t(lang, "autolog.diagnose_channel_placeholder"), channel_types=[discord.ChannelType.text, discord.ChannelType.news], min_values=1, max_values=1, ) async def callback(self, interaction: discord.Interaction): channel = self.values[0] resolved = interaction.guild.get_channel(channel.id) if interaction.guild else None await _diagnose_perms_logic(interaction, target_channel=resolved or channel) class DiagnoseChannelView(discord.ui.View): """Container view wrapping a DiagnoseChannelSelect dropdown.""" def __init__(self, lang: str = 'en'): super().__init__(timeout=1200) self.add_item(DiagnoseChannelSelect(lang)) class AutologManagementView(discord.ui.View): """Main autolog view with buttons for managing notifications and diagnosing permissions.""" def __init__(self, lang: str = 'en'): super().__init__(timeout=1200) self.lang = lang # Update button labels to translated values self.manage_notifications.label = t(lang, "buttons.manage_notifications") self.diagnose_permissions.label = t(lang, "buttons.diagnose_permissions") @discord.ui.button(label="Manage Notifications", style=discord.ButtonStyle.primary, emoji="🔔") async def manage_notifications(self, interaction: discord.Interaction, button: discord.ui.Button): view = NotificationManagementView(self.lang) await interaction.response.send_message( t(self.lang, "autolog.select_notif_type"), view=view, ephemeral=True) view.message = await interaction.original_response() @discord.ui.button(label="Diagnose Permissions", style=discord.ButtonStyle.secondary, emoji="🔧") async def diagnose_permissions(self, interaction: discord.Interaction, button: discord.ui.Button): view = DiagnoseChannelView(self.lang) await interaction.response.send_message( t(self.lang, "autolog.select_channel_diagnose"), view=view, ephemeral=True) @is_blacklisted() @is_admin() @bot.tree.command( name="autolog-management", description=command_locale("Manage autolog notifications and diagnose permissions", "commands.autolog_management.description") ) async def autolog_management(interaction: discord.Interaction): """Show premium status and present the autolog management panel.""" await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' if interaction.guild_id and await is_guild_entitled(interaction.guild_id): premium_line = t(lang, "autolog.premium_active_line") else: premium_line = t(lang, "autolog.premium_not_subscribed_line") view = AutologManagementView(lang) await interaction.response.send_message( f"{premium_line}{t(lang, 'autolog.what_to_do')}", view=view, ephemeral=True) @autolog_management.error # type: ignore[attr-defined] async def autolog_management_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @is_admin() @bot.tree.command( name="diagnose-perms", description=command_locale("Diagnose autolog permissions for this channel", "commands.diagnose_perms.description") ) async def diagnose_perms(interaction: discord.Interaction): """Run autolog permission diagnostics on the current channel.""" await collect_command_stats(interaction) await _diagnose_perms_logic(interaction) @diagnose_perms.error async def diagnose_perms_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @is_admin() @bot.tree.command(name="unlock", description=command_locale("Unlock premium features for this server", "commands.unlock.description")) async def unlock_cmd(interaction: discord.Interaction): """Show premium info and subscription buttons, or active subscription details if already subscribed.""" await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' current_tier = await get_guild_tier(interaction.guild_id) if interaction.guild_id else None # Detect billing source so we surface the right upgrade/manage path. is_discord_sub = False is_website_sub = False if current_tier is not None: try: async for ent in bot.entitlements(exclude_ended=True): if ent.guild_id == interaction.guild_id: is_discord_sub = True break except Exception: pass try: async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: cursor = await db.execute( "SELECT 1 FROM guild_entitlements WHERE guild_id=? AND status='active'", [str(interaction.guild_id)], ) if await cursor.fetchone(): is_website_sub = True except Exception: pass # Build the view with appropriate buttons for each scenario. view = discord.ui.View() # SKU → Discord premium buttons, only added when the env var is set. def _maybe_add_sku(sku_env: Optional[str | int]) -> None: try: sku_int = int(sku_env) if sku_env else 0 except (TypeError, ValueError): return if sku_int: view.add_item(discord.ui.Button(style=discord.ButtonStyle.premium, sku_id=sku_int)) if current_tier is None: # Not subscribed — offer all 3 Discord SKUs plus a website "See plans" link. embed = discord.Embed( title=t(lang, "unlock.title"), description=t(lang, "unlock.desc"), color=discord.Color.gold(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) _maybe_add_sku(DISCORD_SKU_ID_STANDARD) _maybe_add_sku(DISCORD_SKU_ID_PRO) _maybe_add_sku(DISCORD_SKU_ID_MAX) view.add_item(discord.ui.Button( label=t(lang, "buttons.subscribe_website"), url="https://sre.pawjob.us/premium", style=discord.ButtonStyle.link, )) if not view.children: embed.add_field(name=t(lang, "unlock.coming_soon_field"), value=t(lang, "unlock.coming_soon_value"), inline=False) await interaction.response.send_message(embed=embed, ephemeral=True) return await interaction.response.send_message(embed=embed, view=view, ephemeral=True) return # Subscribed — show current tier, plus an upgrade path (SKU buttons for Discord # subs, website link for Whop subs) and a manage link. tier_label = current_tier.title() sub_embed = discord.Embed( title=t(lang, "unlock.already_subscribed_title"), description=t(lang, "unlock.current_tier", tier=tier_label), color=discord.Color.green(), ) higher_tiers = [t_ for t_ in ("pro", "max") if TIER_ORDER.index(t_) > TIER_ORDER.index(current_tier)] if higher_tiers: # Show upgrade buttons. Prefer Discord SKU button for Discord subs; fallback # to website anchor for Whop subs (Discord subs can also deep-link to website). sub_embed.add_field( name=t(lang, "unlock.upgrade_to", tier="/".join(ht.title() for ht in higher_tiers)), value=t(lang, "unlock.upgrade_to_value", tier="/".join(ht.title() for ht in higher_tiers)), inline=False, ) if is_discord_sub: # Add SKU buttons for each higher tier for ht in higher_tiers: sku = DISCORD_SKU_ID_PRO if ht == "pro" else DISCORD_SKU_ID_MAX _maybe_add_sku(sku) # Always add a website upgrade link too — Whop users need it, Discord users # might prefer it, and it surfaces the full 4-card comparison. for ht in higher_tiers: view.add_item(discord.ui.Button( label=t(lang, "unlock.upgrade_to", tier=ht.title()), url=f"https://sre.pawjob.us/premium#{ht}", style=discord.ButtonStyle.link, )) if is_discord_sub: sub_embed.add_field( name=t(lang, "unlock.manage_discord_field"), value=t(lang, "unlock.manage_discord_value"), inline=False, ) elif is_website_sub: sub_embed.add_field( name=t(lang, "unlock.manage_website_field"), value=t(lang, "unlock.manage_website_value"), inline=False, ) sub_embed.set_footer(text=DEFAULT_FOOTER_CAT) if view.children: await interaction.response.send_message(embed=sub_embed, view=view, ephemeral=True) else: await interaction.response.send_message(embed=sub_embed, ephemeral=True) @unlock_cmd.error async def unlock_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @bot.tree.command(name="credits", description=command_locale("View the team credited for building this", "commands.credits.description")) async def credits(interaction: discord.Interaction): """Display the SRE Bot development team credits.""" await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' embed = discord.Embed( title=t(lang, "misc.credits_title"), description=t(lang, "misc.credits_desc"), color=discord.Color.gold() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.response.send_message(embed=embed, ephemeral=False) # ── /schedule config ── edit these when the season changes ───────────────── SCHEDULE_TITLE = "SEASON SCHEDULE" _SCHEDULE_JSON = Path(__file__).parent / "SCHEDULE.json" def _schedule_footer(lang: str) -> str: """Build timeslot footer with Discord timestamps for today's posted SQB slots.""" today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) lines = [] for name, posted_start, posted_end in SQB_SLOTS_POSTED: start_ts = int(today.replace(hour=posted_start.hour, minute=posted_start.minute).timestamp()) end_ts = int(today.replace(hour=posted_end.hour, minute=posted_end.minute).timestamp()) label = t(lang, "misc.schedule_timeslot_label", region=name) lines.append(f"{label}: ** - **") return "\n".join(lines) # ──────────────────────────────────────────────────────────────────────────── @is_blacklisted() @bot.tree.command(name="schedule", description=command_locale("View the current season BR schedule", "commands.schedule.description")) async def schedule_cmd(interaction: discord.Interaction): """Load SCHEDULE.json and display BR tiers with dates, highlighting the current tier.""" await collect_command_stats(interaction) await interaction.response.defer() lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' if not _SCHEDULE_JSON.exists(): await interaction.followup.send( embed=discord.Embed( title=t(lang, "misc.schedule_not_found_title"), description=t(lang, "misc.schedule_not_found_desc"), color=discord.Color.red() ), ephemeral=True ) return entries = await load_json(_SCHEDULE_JSON, []) now = int(time_module.time()) lines = [] for entry in entries: br = entry["max_br"] start_ts = entry["start"] end_ts = entry["end"] br_str = f"{br:.1f}" date_str = f"" is_past = now >= end_ts is_current = start_ts <= now < end_ts if is_past: lines.append(f"> ~~**{br_str} ({date_str})**~~") elif is_current: lines.append("") lines.append(f"> ▶ **{br_str} ({date_str})**") lines.append("") else: lines.append(f"> **{br_str} ({date_str})**") description = "\n".join(lines) + f"\n\n{_schedule_footer(lang)}" embed = discord.Embed( title=t(lang, "misc.schedule_title"), description=description, color=discord.Color.gold() ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed) @schedule_cmd.error async def schedule_error(interaction, error): await permission_fail(interaction, error) # ── /news config ───────────────────────────────────────────────────────────── _NEWS_JSON = Path(__file__).parent / "NEWS.json" # ───────────────────────────────────────────────────────────────────────────── @is_blacklisted() @bot.tree.command(name="news", description=command_locale("View the latest SRE Bot news and announcements", "commands.news.description")) async def news_cmd(interaction: discord.Interaction): """Load NEWS.json and display up to 10 news entries as embeds.""" await collect_command_stats(interaction) await interaction.response.defer() lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' if not _NEWS_JSON.exists(): await interaction.followup.send( embed=discord.Embed( title=t(lang, "misc.news_no_news_title"), description=t(lang, "misc.news_no_news_desc"), color=discord.Color.blurple() ), ephemeral=True ) return all_entries = await load_json(_NEWS_JSON, []) # Filter out expired entries now_ts = int(time_module.time()) entries = [e for e in all_entries if now_ts < e.get("expires", float("inf"))] if not entries: await interaction.followup.send( embed=discord.Embed( title=t(lang, "misc.news_no_news_title"), description=t(lang, "misc.news_no_news_desc"), color=discord.Color.blurple() ), ephemeral=True ) return embeds = [] for i, item in enumerate(entries[:10]): # Discord max 10 embeds per message embed = discord.Embed( title=item.get("title", "Announcement"), description=item.get("body", ""), color=discord.Color.blurple() ) if i == len(entries) - 1 or i == 9: embed.set_footer(text=t(lang, "misc.news_footer")) embeds.append(embed) await interaction.followup.send(embeds=embeds) @news_cmd.error async def news_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @bot.tree.command(name="help", description=command_locale("View the guide, ToS, and support links", "commands.help.description")) async def help(interaction: discord.Interaction): """Display the full 29-command guide with documentation and support links.""" await collect_command_stats(interaction) support_server = "https://discord.gg/BCvkK8JhPe" documentation_link = "https://sre.pawjob.us/docs" tos_link = "https://sre.pawjob.us/terms" lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' command_groups = [ ("misc.help_group_admin", [ ("/setup", "commands.setup.description"), ("/quick-log", "commands.quick_log.description"), ("/autolog-management", "commands.autolog_management.description"), ("/set-squadron", "commands.set_squadron.description"), ("/diagnose-perms", "commands.diagnose_perms.description"), ]), ("misc.help_group_squadron", [ ("/sq-info", "commands.sq_info.description"), ("/sq-info-graph", "commands.sq_info_graph.description"), ("/sq-stats", "commands.sq_stats.description"), ("/sq-track", "commands.sq_track.description"), ("/sq-card", "commands.sq_card.description"), ("/comp", "commands.comp.description"), ("/vs", "commands.vs.description"), ("/recent", "commands.recent.description"), ]), ("misc.help_group_rankings", [ ("/top", "commands.top.description"), ("/leaderboard", "commands.leaderboard.description"), ("/analytics", "commands.analytics.description"), ("/loss-calculator", "commands.loss_calculator.description"), ]), ("misc.help_group_players", [ ("/player-stats", "commands.player_stats.description"), ("/view-player-games", "commands.view_player_games.description"), ("/view-match", "commands.view_match.description"), ("/compare", "commands.compare.description"), ("/card", "commands.card.description"), ("/set-player", "commands.set_player.description"), ]), ("misc.help_group_meta", [ ("/meta-management", "commands.meta_management.description"), ("/meta", "commands.meta.description"), ]), ("misc.help_group_stacks", [ ("/stack-create", "commands.stack_create.description"), ("/stack-manage", "commands.stack_manage.description"), ]), ("misc.help_group_settings", [ ("/language", "commands.language.description"), ("/schedule", "commands.schedule.description"), ("/website", "commands.website.description"), ("/credits", "commands.credits.description"), ("/news", "commands.news.description"), ("/donate", "commands.donate.description"), ("/bot-status", "commands.bot_status.description"), ("/unlock", "commands.unlock.description"), ("Translate Message", "misc.help_translate_hint"), ]), ] description = ( t(lang, "misc.help_links", docs=documentation_link, support=support_server) + "\n" + t(lang, "misc.help_terms", terms=tos_link) ) embed = discord.Embed( title=t(lang, "misc.help_title"), description=description, color=discord.Color.blue(), ) for group_key, rows in command_groups: value = "\n".join(f"`{name}` — {t(lang, key)}" for name, key in rows) embed.add_field(name=t(lang, group_key), value=value, inline=False) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.response.send_message(embed=embed, ephemeral=False) @is_blacklisted() @bot.tree.command(name="donate", description=command_locale("Support the development of SRE Bot", "commands.donate.description")) async def donate_cmd(interaction: discord.Interaction): """Show the Ko-fi donation link.""" await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' embed = discord.Embed( title=t(lang, "misc.donate_title"), description=t(lang, "misc.donate_desc"), color=discord.Color.from_rgb(255, 94, 91), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.response.send_message(embed=embed, ephemeral=False) @is_blacklisted() @bot.tree.command( name="bot-status", description=command_locale( "View bot status: last game received and average TTL", "commands.bot_status.description", ), ) async def bot_status_public(interaction: discord.Interaction): """Public-facing status: last received game timestamp + avg TTL across the last 30 games.""" await collect_command_stats(interaction) lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' await interaction.response.defer(ephemeral=False) last_received_ts: int | None = None avg_delay: int | None = None sample_size = 0 try: stats = await get_recent_ttl_stats(limit=30) avg_delay = stats["avg_delay"] sample_size = stats["sample_size"] last_received_ts = stats["last_received_ts"] except Exception: logging.exception("Failed to compute /bot-status TTL stats") embed = discord.Embed( title=t(lang, "misc.status_title"), color=discord.Color.green(), ) if last_received_ts: last_value = f" ()" else: last_value = t(lang, "misc.status_no_data") embed.add_field(name=t(lang, "misc.status_last_received"), value=last_value, inline=False) if avg_delay is not None: a_min, a_sec = divmod(avg_delay, 60) ttl_value = f"{a_min}m {a_sec:02d}s" embed.add_field(name=t(lang, "misc.status_avg_ttl"), value=ttl_value, inline=False) if avg_delay > 20 * 60: embed.add_field(name="​", value=t(lang, "misc.status_gaijin_slow"), inline=False) embed.color = discord.Color.orange() else: embed.add_field(name=t(lang, "misc.status_avg_ttl"), value=t(lang, "misc.status_no_data"), inline=False) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed, ephemeral=False) class EntitlementsPaginator(discord.ui.View): """Paginated view displaying active entitlements from Discord, Whop, and manual sources.""" ITEMS_PER_PAGE = 10 def __init__(self, discord_lines: list[str], whop_lines: list[str], owner_id: int, manual_lines: list[str] | None = None, lang: str = 'en'): super().__init__(timeout=300) self.discord_lines = discord_lines self.whop_lines = whop_lines self.manual_lines = manual_lines or [] self.owner_id = owner_id self.page = 0 self.lang = lang # Build flat list of (line, source) for unified pagination self.all_entries: list[tuple[str, str]] = [] for line in discord_lines: self.all_entries.append((line, "discord")) for line in whop_lines: self.all_entries.append((line, "whop")) for line in self.manual_lines: self.all_entries.append((line, "manual")) self.max_page = max(0, (len(self.all_entries) - 1) // self.ITEMS_PER_PAGE) self._update_buttons() self.prev_btn.label = t(lang, "buttons.previous") self.next_btn.label = t(lang, "buttons.next") def _update_buttons(self): self.prev_btn.disabled = self.page <= 0 self.next_btn.disabled = self.page >= self.max_page def build_embed(self) -> discord.Embed: """Build the embed for the current page of entitlement entries.""" start = self.page * self.ITEMS_PER_PAGE end = start + self.ITEMS_PER_PAGE page_entries = self.all_entries[start:end] description_parts = [] for line, source in page_entries: tag = {"discord": t(self.lang, "dev.entitlements_tag_discord"), "whop": t(self.lang, "dev.entitlements_tag_whop"), "manual": t(self.lang, "dev.entitlements_tag_manual")}[source] description_parts.append(f"[**{tag}**] {line}") embed = discord.Embed( title=t(self.lang, "dev.entitlements_title", count=len(self.all_entries)), description="\n\n".join(description_parts) if description_parts else t(self.lang, "dev.entitlements_no_entries"), color=discord.Color.blurple(), ) embed.set_footer(text=f"Page {self.page + 1}/{self.max_page + 1} · {len(self.discord_lines)} {t(self.lang, 'dev.entitlements_tag_discord')} · {len(self.whop_lines)} {t(self.lang, 'dev.entitlements_tag_whop')} · {len(self.manual_lines)} {t(self.lang, 'dev.entitlements_tag_manual')}") return embed @discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary) async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): if interaction.user.id != self.owner_id: return await interaction.response.defer() self.page = max(0, self.page - 1) self._update_buttons() await interaction.response.edit_message(embed=self.build_embed(), view=self) @discord.ui.button(label="Next", style=discord.ButtonStyle.secondary) async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): if interaction.user.id != self.owner_id: return await interaction.response.defer() self.page = min(self.max_page, self.page + 1) self._update_buttons() await interaction.response.edit_message(embed=self.build_embed(), view=self) @is_blacklisted() @bot.tree.command(name="bot-status-dev", description="[DEV] View bot health dashboard") async def bot_status(interaction: discord.Interaction): """Display the bot health dashboard: uptime, guilds, task statuses, WebSocket info, and avg TTL (last 30).""" await collect_command_stats(interaction) if not await is_dev_team(interaction): await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) return await interaction.response.defer(ephemeral=True) snapshot = await get_health_snapshot() # Uptime started = snapshot.get("bot_started_at", 0) uptime_s = int(time_module.time() - started) if started else 0 hours, rem = divmod(uptime_s, 3600) minutes, seconds = divmod(rem, 60) uptime_str = f"{hours}h {minutes}m {seconds}s" embed = discord.Embed( title=t("en", "dev.health_title"), color=discord.Color.green(), ) embed.add_field(name=t("en", "dev.health_uptime"), value=uptime_str, inline=True) embed.add_field(name=t("en", "dev.health_guilds"), value=str(snapshot.get("guild_count", 0)), inline=True) embed.add_field( name=t("en", "dev.health_games_processed"), value=f"1h: {snapshot.get('games_processed_1h', 0)} | 24h: {snapshot.get('games_processed_24h', 0)}", inline=False, ) # Task statuses tasks_info = snapshot.get("tasks", {}) if tasks_info: lines = [] for name, info in sorted(tasks_info.items()): status_icon = "\u2705" if info.get("status") == "ok" else "\u274c" last_run = info.get("last_run", 0) ago = f"" if last_run else t("en", "dev.health_never") errs = info.get("error_count", 0) line = f"{status_icon} **{name}** — {ago}" if errs: line += " " + t("en", "dev.health_errors", count=errs) lines.append(line) embed.add_field(name=t("en", "dev.health_tasks"), value="\n".join(lines), inline=False) # WebSocket statuses ws_info = snapshot.get("websocket", {}) if ws_info: ws_lines = [] for name, info in ws_info.items(): connected = "\u2705" if info.get("connected") else "\u274c" last_msg = info.get("last_message_at", 0) ago = f"" if last_msg else t("en", "dev.health_never") count = info.get("messages_processed", 0) ws_lines.append(f"{connected} **{name}** — " + t("en", "dev.health_last_msg", ago=ago, count=count)) embed.add_field(name=t("en", "dev.health_websocket"), value="\n".join(ws_lines), inline=False) # Avg TTL (Spectra receive delay) for the last 30 games try: ttl_stats = await get_recent_ttl_stats(limit=30) if ttl_stats["avg_delay"] is not None: a_min, a_sec = divmod(ttl_stats["avg_delay"], 60) mn_min, mn_sec = divmod(ttl_stats["min_delay"], 60) mx_min, mx_sec = divmod(ttl_stats["max_delay"], 60) ttl_value = ( f"**Avg:** {a_min}m{a_sec:02d}s • " f"**Min:** {mn_min}m{mn_sec:02d}s • " f"**Max:** {mx_min}m{mx_sec:02d}s • " f"**N:** {ttl_stats['sample_size']}" ) else: ttl_value = t("en", "dev.health_never") except Exception as e: ttl_value = f"⚠️ {e}" embed.add_field(name=t("en", "dev.health_avg_ttl"), value=ttl_value, inline=False) embed.set_footer(text=DEFAULT_FOOTER_CAT) await interaction.followup.send(embed=embed, ephemeral=True) @is_blacklisted() @bot.tree.command(name="view-entitlements", description="[DEV] View all active guild entitlements") async def view_entitlements_cmd(interaction: discord.Interaction): """Show a paginated list of all active entitlements (Discord, Whop, manual).""" await collect_command_stats(interaction) if not await is_dev_team(interaction): await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) return await interaction.response.defer(ephemeral=True) # --- Discord native entitlements --- from .utils import sku_id_to_tier discord_lines: list[str] = [] async for ent in bot.entitlements(guild=None, exclude_ended=True): guild_id = ent.guild_id or "—" guild = bot.get_guild(ent.guild_id) if ent.guild_id else None guild_name = guild.name if guild else "Unknown Server" starts = f"" if ent.starts_at else "—" ends = f"" if ent.ends_at else "—" tier = (sku_id_to_tier(str(ent.sku_id)) or "standard").title() discord_lines.append( f"**{guild_name}** (`{guild_id}`)\n" f"> Tier: `{tier}` · ID: `{ent.id}` · SKU: `{ent.sku_id}`\n" f"> Started: {starts} · Expires: {ends}" ) # --- Whop (website) entitlements from entitlements.db --- whop_lines: list[str] = [] manual_lines: list[str] = [] try: async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: async for row in await db.execute( "SELECT guild_id, whop_membership_id, status, renewed_at, tier FROM guild_entitlements WHERE status='active'" ): gid = int(row[0]) membership_id = row[1] or "—" renewed = f"" if row[3] else "—" tier = (row[4] or "standard").title() guild = bot.get_guild(gid) guild_name = guild.name if guild else "Unknown Server" whop_lines.append( f"**{guild_name}** (`{gid}`)\n" f"> Tier: `{tier}` · Membership: `{membership_id}`\n" f"> Renewed: {renewed}" ) # --- Manual entitlements --- now = int(time_module.time()) async for row in await db.execute( "SELECT guild_id, expires_at, created_at, tier FROM manual_entitlements WHERE expires_at > ?", (now,) ): gid = int(row[0]) expires = f"" if row[1] else "—" created = f"" if row[2] else "—" tier = (row[3] or "standard").title() guild = bot.get_guild(gid) guild_name = guild.name if guild else "Unknown Server" manual_lines.append( f"**{guild_name}** (`{gid}`)\n" f"> Tier: `{tier}`\n" f"> Expires: {expires}\n" f"> Created: {created}" ) except Exception as e: whop_lines.append(f"*Failed to read entitlements.db: {e}*") if not discord_lines and not whop_lines and not manual_lines: await interaction.followup.send( embed=discord.Embed( title=t("en", "dev.entitlements_empty_title"), description=t("en", "dev.entitlements_empty_desc"), color=discord.Color.blurple() ), ephemeral=True ) return paginator = EntitlementsPaginator(discord_lines, whop_lines, interaction.user.id, manual_lines=manual_lines) await interaction.followup.send(embed=paginator.build_embed(), view=paginator, ephemeral=True) @view_entitlements_cmd.error async def view_entitlements_error(interaction, error): await permission_fail(interaction, error) @is_blacklisted() @bot.tree.command(name="entitle", description="[BOT OWNER] Manually entitle a server until a given UNIX timestamp") @app_commands.describe( server_id="The Discord server (guild) ID to entitle", unix_ts="UNIX timestamp for when the entitlement expires (must be >1 month from now)", tier="Which tier to grant (standard / pro / max)", ) @app_commands.choices(tier=[ app_commands.Choice(name="Standard (10 squads)", value="standard"), app_commands.Choice(name="Pro (25 squads, wildcards)", value="pro"), app_commands.Choice(name="Max (unlimited)", value="max"), ]) async def entitle_cmd( interaction: discord.Interaction, server_id: str, unix_ts: int, tier: str = "max", ): """Manually create a premium entitlement for a guild until the given UNIX timestamp.""" await collect_command_stats(interaction) if not await bot.is_owner(interaction.user): await interaction.response.send_message(t("en", "dev.restricted_bot_owner"), ephemeral=True) return await interaction.response.defer(ephemeral=True) # Validate server_id format if not server_id.isdigit() or not (17 <= len(server_id) <= 19): await interaction.followup.send(t("en", "dev.invalid_server_id"), ephemeral=True) return if tier not in ("standard", "pro", "max"): await interaction.followup.send("Invalid tier. Must be standard/pro/max.", ephemeral=True) return # Validate unix_ts is at least 1 month ahead now = int(time_module.time()) one_month = 30 * 24 * 60 * 60 # 30 days in seconds if unix_ts <= now + one_month: await interaction.followup.send( t("en", "dev.expiry_too_soon", now=now, min=now + one_month, provided=unix_ts), ephemeral=True ) return # Insert into manual_entitlements try: async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: await db.execute( "CREATE TABLE IF NOT EXISTS manual_entitlements (" "guild_id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, " "created_at INTEGER DEFAULT (strftime('%s','now')), tier TEXT)" ) # Ensure tier column exists on pre-existing tables. try: await db.execute("ALTER TABLE manual_entitlements ADD COLUMN tier TEXT") except Exception: pass # column already exists await db.execute( "INSERT INTO manual_entitlements (guild_id, expires_at, created_at, tier) " "VALUES (?, ?, ?, ?) " "ON CONFLICT(guild_id) DO UPDATE SET " "expires_at=excluded.expires_at, created_at=excluded.created_at, tier=excluded.tier", (server_id, unix_ts, now, tier) ) await db.commit() except Exception as e: await interaction.followup.send(t("en", "dev.entitlement_write_failed", error=e), ephemeral=True) return # Invalidate entitlement cache so it takes effect immediately invalidate_entitled_guilds_cache() guild = bot.get_guild(int(server_id)) guild_name = guild.name if guild else "Unknown Server" embed = discord.Embed( title=t("en", "dev.entitlement_created_title"), description=t("en", "dev.entitlement_created_desc", guild_name=guild_name, server_id=server_id, unix_ts=unix_ts, now=now) + f"\nTier: **{tier.title()}**", color=discord.Color.green() ) await interaction.followup.send(embed=embed, ephemeral=True) @entitle_cmd.error async def entitle_error(interaction, error): await permission_fail(interaction, error) # ── /query — predefined admin queries ─────────────────────────────────── QUERY_CHOICES = [ # Squadrons (squadrons.db) app_commands.Choice(name="All squadrons with points", value="sq_with_points"), app_commands.Choice(name="Top 20 squadrons by rating", value="sq_top20"), app_commands.Choice(name="Squadron member count breakdown", value="sq_member_counts"), app_commands.Choice(name="Total squadrons stored", value="sq_total"), # SQB (sq_battles.db) app_commands.Choice(name="Total stored games", value="sqb_total_games"), app_commands.Choice(name="Games in last 24 hours", value="sqb_games_24h"), app_commands.Choice(name="Games in last 7 days", value="sqb_games_7d"), app_commands.Choice(name="Games in last 30 days", value="sqb_games_30d"), app_commands.Choice(name="Most active players (top 20)", value="sqb_active_players"), app_commands.Choice(name="Most played maps (top 15)", value="sqb_top_maps"), app_commands.Choice(name="Spectra receive delay (last 20 games)", value="sqb_receive_delay"), # Command usage (COMMAND_DATA.db) app_commands.Choice(name="Command usage (top 20)", value="cmd_top20"), app_commands.Choice(name="Command usage (last 24h)", value="cmd_last24h"), app_commands.Choice(name="Command usage by guild (top 15)", value="cmd_by_guild"), ] async def _run_query(query_key: str) -> tuple[str, str]: """Run a predefined query and return (title, body) strings.""" now = int(time_module.time()) # ── Squadrons DB ── if query_key == "sq_with_points": async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db: rows = list(await db.execute_fetchall( "SELECT short_name, long_name, clanrating FROM squadrons_data " "WHERE clanrating IS NOT NULL AND clanrating > 0 " "ORDER BY clanrating DESC" )) total = len(rows) lines = [f"**{i}.** {esc(r[0])} ({esc(r[1])}) — {int(r[2]):,}" for i, r in enumerate(rows[:50], 1)] if total > 50: lines.append(f"\n*… and {total - 50} more*") return f"Squadrons With Points ({total})", "\n".join(lines) or "None" if query_key == "sq_top20": async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db: rows = list(await db.execute_fetchall( "SELECT short_name, long_name, clanrating, members FROM squadrons_data " "WHERE clanrating IS NOT NULL AND clanrating > 0 " "ORDER BY clanrating DESC LIMIT 20" )) lines = [f"**{i}.** {esc(r[0])} ({esc(r[1])}) — {int(r[2]):,} pts, {r[3] or '?'} members" for i, r in enumerate(rows, 1)] return "Top 20 Squadrons by Rating", "\n".join(lines) or "No data" if query_key == "sq_member_counts": async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db: rows = list(await db.execute_fetchall( "SELECT sd.short_name, COUNT(sm.uid) as cnt " "FROM squadrons_data sd " "LEFT JOIN squadron_members sm ON sd.clan_id = sm.clan_id " "GROUP BY sd.clan_id " "ORDER BY cnt DESC LIMIT 25" )) lines = [f"**{i}.** {esc(r[0])} — {r[1]} members" for i, r in enumerate(rows, 1)] return "Squadron Member Counts (Top 25)", "\n".join(lines) or "No data" if query_key == "sq_total": async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db: row_total = list(await db.execute_fetchall("SELECT COUNT(*) FROM squadrons_data")) row_pts = list(await db.execute_fetchall( "SELECT COUNT(*) FROM squadrons_data WHERE clanrating IS NOT NULL AND clanrating > 0" )) row_members = list(await db.execute_fetchall("SELECT COUNT(*) FROM squadron_members")) body = ( f"**Total squadrons stored:** {row_total[0][0]:,}\n" f"**With points (rating > 0):** {row_pts[0][0]:,}\n" f"**Total members tracked:** {row_members[0][0]:,}" ) return "Squadrons DB Summary", body # ── SQ Battles DB ── if query_key == "sqb_total_games": async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: r_matches = list(await db.execute_fetchall("SELECT COUNT(*) FROM match_summary")) r_entries = list(await db.execute_fetchall("SELECT COUNT(*) FROM player_games_hist")) r_players = list(await db.execute_fetchall("SELECT COUNT(DISTINCT UID) FROM player_games_hist")) body = ( f"**Total matches:** {r_matches[0][0]:,}\n" f"**Total player-vehicle entries:** {r_entries[0][0]:,}\n" f"**Unique players:** {r_players[0][0]:,}" ) return "SQ Battles DB Summary", body if query_key.startswith("sqb_games_"): window_map = {"sqb_games_24h": (86400, "24 Hours"), "sqb_games_7d": (604800, "7 Days"), "sqb_games_30d": (2592000, "30 Days")} seconds, label = window_map[query_key] cutoff = now - seconds async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: r_matches = list(await db.execute_fetchall( "SELECT COUNT(*) FROM match_summary WHERE endtime_unix >= ?", (cutoff,) )) r_entries = list(await db.execute_fetchall( "SELECT COUNT(*) FROM player_games_hist WHERE endtime_unix >= ?", (cutoff,) )) r_players = list(await db.execute_fetchall( "SELECT COUNT(DISTINCT UID) FROM player_games_hist WHERE endtime_unix >= ?", (cutoff,) )) body = ( f"**Matches:** {r_matches[0][0]:,}\n" f"**Player-vehicle entries:** {r_entries[0][0]:,}\n" f"**Unique players:** {r_players[0][0]:,}" ) return f"SQ Battles — Last {label}", body if query_key == "sqb_active_players": async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: rows = list(await db.execute_fetchall( "SELECT nick, COUNT(DISTINCT session_id) as games " "FROM player_games_hist " "GROUP BY UID " "ORDER BY games DESC LIMIT 20" )) lines = [f"**{i}.** {esc(r[0])} — {r[1]:,} games" for i, r in enumerate(rows, 1)] return "Most Active Players (All Time)", "\n".join(lines) or "No data" if query_key == "sqb_top_maps": async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: rows = list(await db.execute_fetchall( "SELECT map_name, COUNT(*) as cnt " "FROM match_summary " "WHERE map_name IS NOT NULL AND map_name != '' " "GROUP BY map_name " "ORDER BY cnt DESC LIMIT 15" )) lines = [f"**{i}.** {esc(r[0])} — {r[1]:,} games" for i, r in enumerate(rows, 1)] return "Most Played Maps", "\n".join(lines) or "No data" if query_key == "sqb_receive_delay": async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: rows = list(await db.execute_fetchall( "SELECT session_id, endtime_unix, received_unix " "FROM match_summary " "WHERE received_unix IS NOT NULL AND endtime_unix IS NOT NULL " "ORDER BY endtime_unix DESC LIMIT 20" )) if not rows: return ( "Spectra Receive Delay (Last 20 Games)", "No data yet — `received_unix` is only populated for games processed after this build.", ) delays = [int(r[2]) - int(r[1]) for r in rows] lines = [] for r, d in zip(rows, delays): sid = str(r[0]) end_str = datetime.fromtimestamp(int(r[1]), tz=timezone.utc).strftime("%m-%d %H:%M:%S") mins, secs = divmod(max(d, 0), 60) lines.append(f"`{sid}` end {end_str}Z → +{mins}m{secs:02d}s") avg = sum(delays) / len(delays) a_min, a_sec = divmod(int(avg), 60) m_min, m_sec = divmod(min(delays), 60) x_min, x_sec = divmod(max(delays), 60) header = ( f"**Avg:** {a_min}m{a_sec:02d}s • " f"**Min:** {m_min}m{m_sec:02d}s • " f"**Max:** {x_min}m{x_sec:02d}s • " f"**N:** {len(rows)}\n\n" ) return "Spectra Receive Delay (Last 20 Games)", header + "\n".join(lines) # ── Command Usage (COMMAND_DATA.db) ── if query_key == "cmd_top20": async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db: rows = list(await db.execute_fetchall( "SELECT command_name, COUNT(*) as cnt " "FROM command_usage " "GROUP BY command_name " "ORDER BY cnt DESC LIMIT 20" )) lines = [f"**{i}.** /{r[0]} — {r[1]:,} uses" for i, r in enumerate(rows, 1)] return "Command Usage (All Time, Top 20)", "\n".join(lines) or "No data yet" if query_key == "cmd_last24h": cutoff = now - 86400 async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db: rows = list(await db.execute_fetchall( "SELECT command_name, COUNT(*) as cnt " "FROM command_usage " "WHERE timestamp >= ? " "GROUP BY command_name " "ORDER BY cnt DESC LIMIT 20", (cutoff,) )) r_total = list(await db.execute_fetchall( "SELECT COUNT(*) FROM command_usage WHERE timestamp >= ?", (cutoff,) )) total = r_total[0][0] if r_total else 0 lines = [f"**{i}.** /{r[0]} — {r[1]:,} uses" for i, r in enumerate(rows, 1)] header = f"**Total invocations (24h):** {total:,}\n\n" return "Command Usage (Last 24h)", header + ("\n".join(lines) or "No data yet") if query_key == "cmd_by_guild": async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db: rows = list(await db.execute_fetchall( "SELECT guild_id, COUNT(*) as cnt " "FROM command_usage " "WHERE guild_id IS NOT NULL " "GROUP BY guild_id " "ORDER BY cnt DESC LIMIT 15" )) lines = [] for i, r in enumerate(rows, 1): gid = r[0] guild = bot.get_guild(int(gid)) if gid else None name = esc(guild.name) if guild else f"ID: {gid}" lines.append(f"**{i}.** {name} — {r[1]:,} uses") return "Command Usage by Guild (Top 15)", "\n".join(lines) or "No data yet" return "Unknown Query", "Query not recognised." @is_blacklisted() @bot.tree.command(name="query", description="[DEV] Run predefined database queries") @app_commands.describe(query="Select a query to run") @app_commands.choices(query=QUERY_CHOICES) async def query_cmd(interaction: discord.Interaction, query: app_commands.Choice[str]): """Run a predefined read-only database query. Restricted to dev team and bot owner.""" await collect_command_stats(interaction) if not await is_dev_team(interaction): await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) return await interaction.response.defer(ephemeral=True) try: title, body = await _run_query(query.value) except Exception as e: logging.error(f"[/query] Error running query '{query.value}': {e}", exc_info=True) await interaction.followup.send(t("en", "dev.query_failed", error=e), ephemeral=True) return # Discord embed description limit is 4096 chars if len(body) > 4000: body = body[:3997] + "…" embed = discord.Embed(title=title, description=body, color=discord.Color.blurple()) embed.set_footer(text=f"{t('en', 'dev.query_prefix', name=query.name)} • {DEFAULT_FOOTER_CAT}") await interaction.followup.send(embed=embed, ephemeral=True) @query_cmd.error async def query_error(interaction, error): await permission_fail(interaction, error) if __name__ == "__main__": # Make sure required secrets are available for key in ['DISCORD_KEY', 'DEEPL_KEY']: if key not in os.environ: print(f"Warning: {key} environment variable is not set") if TOKEN != None: bot.run(TOKEN)