Files
deploy c0214eaaae fix(recap): speed up /card lookup, fix stale completed-season cache, add Place Finished
- /card player lookup was a leading-wildcard substring match with a Python
  ulower() UDF over 6.16M player_games_hist rows — a full scan measured at
  27s live before the ~2s render. Add a prefix fast-path
  (nick LIKE 'name%' COLLATE NOCASE) that uses the existing NOCASE index
  (~1ms), falling back to the ulower substring scan only when the prefix
  finds nothing. Same fix applied to _resolve_player_uids_batch (compare/stats).
  Lookup: 27,205ms -> 1ms.

- Completed-season recap cache served ANY cached PNG forever, including files
  rendered mid-season (frozen at whatever point they were last viewed). Only
  serve from cache when the file's mtime is after season end; otherwise
  re-render. Fixed in both BOT/utils.py and web/server.js (shared cache).

- Replace squadron card "Rating change" with "Place finished" (#rank / total),
  derived by ranking clans by final total_score among those active in-season.

- Add (CARD)/(RECAP) timing logs for prod observability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:20:26 +00:00

9913 lines
414 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, tasks
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
from . import tally
# 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.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
"""Fallback slash-command error handler for commands without local handlers."""
await permission_fail(interaction, error)
@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 / "SREBOT_GUILDS.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)()
try:
tally.load_from_disk()
if not tally_sweep_loop.is_running():
tally_sweep_loop.start()
logging.info("Initialized tally tracking")
except Exception as e:
logging.error(f"Error initializing tally tracking: {e}")
# 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}")
@tasks.loop(minutes=2)
async def tally_sweep_loop():
"""Periodically wipe idle voice-channel tallies."""
try:
await tally.sweep_idle()
except Exception as e:
logging.error(f"[TALLY] sweep loop error: {e}")
@tally_sweep_loop.before_loop
async def _before_tally_sweep():
await bot.wait_until_ready()
@bot.event
async def on_voice_state_update(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
"""Wipe a VC's tally the moment it empties of human members."""
channel = before.channel
if channel is None or (after.channel is not None and after.channel.id == channel.id):
return
guild_id = channel.guild.id
if tally.get(guild_id, channel.id) is None:
return
if all(m.bot for m in channel.members):
tally.wipe(guild_id, channel.id)
await tally.clear_status(channel.id)
@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 SQB 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
# Discordstyle relative timestamps
reg_str = f"<t:{reg_ts}:R>"
last_str = f"<t:{last_seen_ts}:R>"
# 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") == "<unresolved>":
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"(<t:{start_ts}:D> → <t:{end_ts}:D>) 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") == "<unresolved>":
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"(<t:{start_ts}:D> → <t:{end_ts}:D>)\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("")
# ── 5. Voice channel tally permission ──
if isinstance(channel, discord.VoiceChannel):
vc_mention = f"<#{channel.id}>"
lines.append(t(lang, "commands.tally.vc_perm_header", vc=vc_mention))
if channel.permissions_for(bot_member).set_voice_channel_status:
lines.append(f" ✅ {t(lang, 'commands.tally.vc_perm_ok', vc=vc_mention)}")
else:
lines.append(f" ❌ {t(lang, 'commands.tally.no_vc_perm_diagnose', vc=vc_mention)}")
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:
_t_lookup = time_module.monotonic()
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
db.row_factory = aiosqlite.Row
async def _lookup(where: str, pattern: str) -> list:
async with db.execute(
f"""
SELECT UID, MIN(nick) AS nick
FROM player_games_hist
WHERE {where}
GROUP BY UID
ORDER BY nick
LIMIT 25
""",
(pattern,),
) as cursor:
return list(await cursor.fetchall())
# Fast path: a prefix match uses the `nick COLLATE NOCASE` index
# (~ms), covering the common case of typing the start of a name.
# This avoids a full scan of the multi-million-row games table.
results = await _lookup("nick LIKE ? COLLATE NOCASE", f"{player}%")
lookup_path = "prefix"
# Fallback: only if the prefix found nothing do we pay for a full
# substring scan. ulower() gives full Unicode case-folding (e.g.
# Cyrillic) that the ASCII-only NOCASE collation can't.
if not results:
await db.create_function("ulower", 1, str.lower)
results = await _lookup("ulower(nick) LIKE ulower(?)", f"%{player}%")
lookup_path = "substring"
logging.info(
"(CARD) nick lookup query=%r path=%s matches=%d ms=%d",
player, lookup_path, len(results), (time_module.monotonic() - _t_lookup) * 1000,
)
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)
# ═══════════════════════════════════════════════════════════════════════════
# /tally-claim · /tally-transfer · /tally-clear — live VC scoreline tracking
# ═══════════════════════════════════════════════════════════════════════════
def _invoker_voice_channel(interaction: discord.Interaction):
"""Return the voice channel the invoker is connected to, or None."""
member = interaction.user
if isinstance(member, discord.Member) and member.voice and member.voice.channel:
return member.voice.channel
return None
@bot.tree.command(
name="tally-claim",
description=command_locale(
"Track a live SQB scoreline on your current voice channel",
"commands.tally.description_claim",
),
)
@app_commands.describe(
username=command_locale("Username", "commands.tally.username"),
squadron=command_locale("Squadron Name (like DSPL)", "commands.tally.squadron"),
)
@discord.app_commands.autocomplete(username=player_autocomplete, squadron=squadron_autocomplete)
async def tally_claim(interaction: discord.Interaction, username: str = "", squadron: str = ""):
lang = await guild_lang(interaction.guild.id) if interaction.guild else "en"
vc = _invoker_voice_channel(interaction)
if vc is None:
await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True)
return
if not vc.permissions_for(vc.guild.me).set_voice_channel_status:
await interaction.response.send_message(t(lang, "commands.tally.no_vc_perm"), ephemeral=True)
return
username = username.strip()
squadron = squadron.strip()
if bool(username) == bool(squadron): # neither or both
await interaction.response.send_message(t(lang, "commands.tally.need_one_input"), ephemeral=True)
return
existing = tally.get(interaction.guild_id, vc.id)
if existing is not None:
await interaction.response.send_message(
t(lang, "commands.tally.already_active", channel=vc.name, target=existing.display_target),
ephemeral=True,
)
return
if username:
mode, target, display = "player", username, username
else:
mode, target, display = "squadron", squadron, squadron
tly = tally.claim(interaction.guild_id, vc.id, mode, target, display, interaction.user.id)
await tally.push_status(tly)
await interaction.response.send_message(
t(lang, "commands.tally.claimed", target=display, channel=vc.name), ephemeral=True
)
@bot.tree.command(
name="tally-transfer",
description=command_locale(
"Transfer the active voice-channel tally to a different player",
"commands.tally.description_transfer",
),
)
@app_commands.describe(username=command_locale("Username", "commands.tally.username"))
@discord.app_commands.autocomplete(username=player_autocomplete)
async def tally_transfer(interaction: discord.Interaction, username: str):
lang = await guild_lang(interaction.guild.id) if interaction.guild else "en"
vc = _invoker_voice_channel(interaction)
if vc is None:
await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True)
return
if tally.get(interaction.guild_id, vc.id) is None:
await interaction.response.send_message(
t(lang, "commands.tally.no_active", channel=vc.name), ephemeral=True
)
return
username = username.strip()
tly = tally.transfer(interaction.guild_id, vc.id, username, username)
if tly is None:
await interaction.response.send_message(
t(lang, "commands.tally.no_active", channel=vc.name), ephemeral=True
)
return
await tally.push_status(tly)
await interaction.response.send_message(
t(lang, "commands.tally.transferred", channel=vc.name, target=username,
base=f"{tly.wins}W-{tly.losses}L"),
ephemeral=True,
)
@bot.tree.command(
name="tally-clear",
description=command_locale(
"Clear the active tally on your current voice channel",
"commands.tally.description_wipe",
),
)
async def tally_clear(interaction: discord.Interaction):
lang = await guild_lang(interaction.guild.id) if interaction.guild else "en"
vc = _invoker_voice_channel(interaction)
if vc is None:
await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True)
return
if not interaction.guild_id or not tally.wipe(interaction.guild_id, vc.id):
await interaction.response.send_message(
t(lang, "commands.tally.no_active", channel=vc.name), ephemeral=True
)
return
await tally.clear_status(vc.id)
await interaction.response.send_message(
t(lang, "commands.tally.wiped", channel=vc.name), ephemeral=True
)
# ═══════════════════════════════════════════════════════════════════════════
# /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 12 (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"<t:{s['endtime_unix']}:R>" 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"<t:{g['endtime_unix']}:R>" 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] = []
async def _resolve_one(where: str, pattern: str) -> list:
async with db.execute(
f"""
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 {where}
) WHERE rn = 1
ORDER BY nick LIMIT 25
""",
(pattern,),
) as cursor:
return list(await cursor.fetchall())
for name in usernames:
# Fast path: prefix match uses the nick COLLATE NOCASE index (~ms).
# Only fall back to a full substring scan (ulower for Unicode
# case-folding) when the prefix finds nothing.
results = await _resolve_one("nick LIKE ? COLLATE NOCASE", f"{name}%")
if not results:
await db.create_function("ulower", 1, str.lower)
results = await _resolve_one("ulower(nick) LIKE ulower(?)", f"%{name}%")
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"] == "<unresolved>":
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"] == "<unresolved>":
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": "<English>",
"Français": "<French>",
"Italiano": "<Italian>",
"Deutsch": "<German>",
"Español": "<Spanish>",
"Русский": "<Russian>",
"Polski": "<Polish>",
"Čeština": "<Czech>",
"简体中文": "<Chinese>",
"Português": "<Portuguese>",
"Українська": "<Ukrainian>",
}
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 targetcodes → (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"] == "<unresolved>":
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 <t:UNIX:t> 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"<t:{ts}:t> {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"<t:{endtime_unix}:R>" 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"<t:{endtime_unix}:R>" 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}: **<t:{start_ts}:t> - <t:{end_ts}:t>**")
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"<t:{start_ts}:d> — <t:{end_ts}:d>"
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 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_tally", [
("/tally-claim", "commands.tally.description_claim"),
("/tally-transfer", "commands.tally.description_transfer"),
("/tally-clear", "commands.tally.description_wipe"),
]),
("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"<t:{last_received_ts}:R> (<t:{last_received_ts}:T>)"
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"<t:{int(last_run)}:R>" 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"<t:{int(last_msg)}:R>" 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"<t:{int(ent.starts_at.timestamp())}:d>" if ent.starts_at else "—"
ends = f"<t:{int(ent.ends_at.timestamp())}:d>" 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"<t:{row[3]}:d>" 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"<t:{row[1]}:d>" if row[1] else "—"
created = f"<t:{row[2]}:d>" 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)