9456 lines
395 KiB
Python
9456 lines
395 KiB
Python
"""
|
||
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 json
|
||
import logging
|
||
import math
|
||
import os
|
||
import re
|
||
import time as time_module
|
||
import traceback
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Awaitable, Callable, Dict, List, Optional, cast
|
||
|
||
# Third-Party Library Imports
|
||
import aiofiles
|
||
import aiosqlite
|
||
import deepl
|
||
import discord
|
||
import matplotlib
|
||
matplotlib.use('Agg') # Use non-interactive backend
|
||
import matplotlib.cm as cm # noqa: E402
|
||
import matplotlib.dates as mdates # noqa: E402
|
||
import matplotlib.pyplot as plt # noqa: E402
|
||
import numpy as np
|
||
from discord import Color, Embed, app_commands
|
||
from discord.ext import commands
|
||
from discord.ui import Select, View, button
|
||
from dotenv import load_dotenv
|
||
import matplotlib.patheffects as path_effects # noqa: E402
|
||
from matplotlib.collections import LineCollection
|
||
from matplotlib.colors import TwoSlopeNorm
|
||
|
||
# Local Module Imports (relative imports within BOT package)
|
||
from . import utils
|
||
from .analytics import (
|
||
get_map_stats, get_comp_analysis, get_player_consistency,
|
||
get_time_performance, get_matchup_history,
|
||
)
|
||
from .autologging import init_players_db, load_replay_data_from_disk, build_scoreboard_view
|
||
from data_parser import LangTableReader, count_unit_types, get_unit_type_abbrev, normalize_name
|
||
from .game_api import (
|
||
ClanInfoError,
|
||
obtain_clan_info_api,
|
||
obtain_clan_new_points,
|
||
obtain_clans_leaderboard,
|
||
)
|
||
from .health import init_health, get_health_snapshot
|
||
from .utils import t, guild_lang
|
||
from .lux_apis import fetch_replay_by_id
|
||
from .meta_manager import (
|
||
add_player_to_guild_meta,
|
||
bulk_add_squadron_players_to_guild_meta,
|
||
create_or_update_guild,
|
||
get_guild_meta_players,
|
||
get_guild_settings,
|
||
get_squadron_owner,
|
||
init_meta_db,
|
||
refresh_guild_player_vehicles,
|
||
remove_player_from_guild_meta,
|
||
search_guild_meta_by_vehicle,
|
||
transfer_squadron_to_guild,
|
||
update_guild_settings,
|
||
update_squadron_password,
|
||
validate_squadron_password,
|
||
)
|
||
from .scoreboard import create_scoreboard
|
||
from .stack_manager import register_commands as register_stack_commands
|
||
from .tasks import start_all_tasks
|
||
from . import utils as _utils
|
||
from .utils import (
|
||
STORAGE_DIR,
|
||
ICONS_DIR,
|
||
SQ_BATTLES_DB_PATH,
|
||
SQUADRONS_DB_PATH,
|
||
COMMAND_DATA_DB_PATH,
|
||
DISCORD_SKU_ID_STANDARD,
|
||
ENTITLEMENTS_DB_PATH,
|
||
TOKEN,
|
||
DEFAULT_FOOTER_CAT,
|
||
compress_json,
|
||
decompress_json,
|
||
init_game_cache,
|
||
init_vehicle_translation_cache,
|
||
invalidate_entitled_guilds_cache,
|
||
is_admin,
|
||
is_blacklisted,
|
||
gate_entitle,
|
||
is_guild_entitled,
|
||
get_guild_tier,
|
||
permission_fail,
|
||
refresh_entitled_guilds,
|
||
tier_cap,
|
||
tier_enforcement_active,
|
||
tier_allows_wildcard,
|
||
TIER_ORDER,
|
||
DISCORD_SKU_ID_PRO,
|
||
DISCORD_SKU_ID_MAX,
|
||
enabled_pref_keys_for,
|
||
enabled_non_wildcard_keys_for,
|
||
allowed_pref_keys_for,
|
||
is_notif_enabled,
|
||
WILDCARD_KEYS,
|
||
resolve_clan,
|
||
resolve_clan_id,
|
||
resolve_clans,
|
||
get_guild_squadron,
|
||
load_guild_preferences,
|
||
save_guild_preferences,
|
||
load_features,
|
||
save_features,
|
||
load_json,
|
||
write_json,
|
||
set_bot,
|
||
esc,
|
||
init_command_stats_db,
|
||
collect_command_stats,
|
||
command_locale,
|
||
is_dev_team,
|
||
LocaleJsonTranslator,
|
||
COMP_FREE_UNTIL_TS,
|
||
COMP_LIMIT_PER_TIMESLOT,
|
||
COMP_LIMIT_PER_USER_PER_TIMESLOT,
|
||
get_current_timeslot_start_ts,
|
||
get_comp_usage_in_timeslot,
|
||
get_comp_usage_in_timeslot_by_user,
|
||
get_entitled_guild_ids,
|
||
SQB_SLOTS_POSTED,
|
||
RECAP_LANGS,
|
||
RecapError,
|
||
get_player_recap,
|
||
get_seasons,
|
||
get_squadron_recap,
|
||
replay_session_dir,
|
||
)
|
||
from .wl import wl_bootstrap
|
||
|
||
# Load environment variables from .env file
|
||
load_dotenv()
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||
logging.getLogger("hpack").setLevel(logging.WARNING)
|
||
logging.getLogger("deepl").setLevel(logging.WARNING)
|
||
|
||
intents = discord.Intents.default()
|
||
intents.message_content = False
|
||
intents.reactions = True
|
||
intents.messages = True
|
||
|
||
class MyBot(commands.Bot):
|
||
"""Custom Discord bot subclass for SRE Bot.
|
||
|
||
Extends commands.Bot with a sync guard to prevent duplicate
|
||
slash-command tree syncs on reconnect.
|
||
"""
|
||
|
||
def __init__(self):
|
||
"""Initialize the bot with default prefix and configured intents."""
|
||
super().__init__(command_prefix='~', intents=intents)
|
||
self.synced = False
|
||
|
||
async def setup_hook(self):
|
||
"""Sync the slash-command tree with Discord on first startup."""
|
||
await self.tree.set_translator(LocaleJsonTranslator())
|
||
await self.tree.sync()
|
||
self.synced = True
|
||
|
||
bot = MyBot()
|
||
set_bot(bot) # Register bot globally for other modules
|
||
register_stack_commands(bot)
|
||
|
||
|
||
|
||
@bot.event
|
||
async def on_ready():
|
||
"""Handle bot startup: write guild report, refresh presence, init caches, and start tasks."""
|
||
GUILD_TOTAL = len(bot.guilds)
|
||
|
||
out_path = STORAGE_DIR / "GUILD_REPORT.txt"
|
||
with out_path.open("w", encoding="utf-8") as f:
|
||
f.write(f"We have logged in as {bot.user} in the following Guilds:\n\n")
|
||
for guild in bot.guilds:
|
||
created = guild.created_at.strftime("%Y-%m-%d")
|
||
f.write(
|
||
f" - {guild.name} (id: {guild.id}) | Members: {guild.member_count} | Created: {created}\n"
|
||
)
|
||
|
||
logging.info(f"Guild report written to {out_path.resolve()}")
|
||
logging.info(f' - Total Guilds: {GUILD_TOTAL}')
|
||
|
||
await _refresh_presence()
|
||
|
||
if not bot.synced:
|
||
await bot.tree.sync()
|
||
bot.synced = True
|
||
|
||
try:
|
||
await init_game_cache()
|
||
logging.info("Initialized game cache . . .")
|
||
except Exception as e:
|
||
logging.error(f"Error initializing game cache in startup: {e}")
|
||
|
||
try:
|
||
await init_vehicle_translation_cache()
|
||
except Exception as e:
|
||
logging.error(f"Error initializing vehicle translation cache in startup: {e}")
|
||
|
||
try:
|
||
await refresh_entitled_guilds(force=True)
|
||
logging.info("Initialized entitlements cache")
|
||
except Exception as e:
|
||
logging.error(f"Error initializing entitlements cache: {e}")
|
||
|
||
try:
|
||
await cast(Callable[[], Awaitable[None]], init_meta_db)()
|
||
logging.info("Initialized Meta.db . . .")
|
||
except Exception as e:
|
||
logging.error(f"Error initializing Meta.db in startup: {e}")
|
||
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn:
|
||
await init_players_db(conn)
|
||
logging.info("Initialized sq_battles.db . . .")
|
||
except Exception as e:
|
||
logging.error(f"Error initializing sq_battles.db in startup: {e}")
|
||
|
||
try:
|
||
await init_command_stats_db()
|
||
except Exception as e:
|
||
logging.error(f"Error initializing COMMAND_DATA.db in startup: {e}")
|
||
|
||
try:
|
||
# Initialize health monitoring
|
||
init_health(started_at=time_module.time(), guild_count=GUILD_TOTAL)
|
||
|
||
# Start all background tasks from tasks.py
|
||
await cast(Callable[[], Awaitable[None]], start_all_tasks)()
|
||
|
||
# Start the W/L queue consumer
|
||
wl_bootstrap()
|
||
|
||
logging.info("Engines 1-3 are a go . . .")
|
||
logging.info("We have liftoff ! ! !")
|
||
|
||
except Exception as e:
|
||
logging.error(f"Error starting tasks in startup: {e}")
|
||
|
||
|
||
@bot.event
|
||
async def on_guild_join(guild):
|
||
"""Handle joining a new guild: update presence and send setup hint.
|
||
|
||
Args:
|
||
guild: The Discord guild the bot just joined.
|
||
"""
|
||
GUILD_TOTAL = len(bot.guilds)
|
||
|
||
logging.info(f"Joined new guild: {guild.name} (id: {guild.id})")
|
||
logging.info(f"Updated total guilds: {GUILD_TOTAL}")
|
||
|
||
try:
|
||
await _refresh_presence()
|
||
except Exception as e:
|
||
logging.error(f"Error changing bot status after guild join: {e}")
|
||
|
||
# Send setup hint to system channel or first available text channel
|
||
try:
|
||
target = guild.system_channel
|
||
if not target:
|
||
for ch in guild.text_channels:
|
||
if ch.permissions_for(guild.me).send_messages:
|
||
target = ch
|
||
break
|
||
if target:
|
||
lang = 'en'
|
||
embed = Embed(
|
||
title=t(lang, "events.guild_join_title"),
|
||
description=t(lang, "events.guild_join_desc"),
|
||
color=Color.blue()
|
||
)
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
await target.send(embed=embed)
|
||
except Exception as e:
|
||
logging.error(f"Error sending setup hint to guild {guild.id}: {e}")
|
||
|
||
|
||
@bot.event
|
||
async def on_guild_remove(guild):
|
||
"""Handle being removed from a guild: update presence.
|
||
|
||
Args:
|
||
guild: The Discord guild the bot was removed from.
|
||
"""
|
||
GUILD_TOTAL = len(bot.guilds)
|
||
|
||
logging.info(f"Removed from guild: {guild.name} (id: {guild.id})")
|
||
logging.info(f"Updated total guilds: {GUILD_TOTAL}")
|
||
|
||
try:
|
||
await _refresh_presence()
|
||
except Exception as e:
|
||
logging.error(f"Error changing bot status after guild removal: {e}")
|
||
|
||
|
||
@bot.event
|
||
async def on_entitlement_create(entitlement):
|
||
"""Invalidate the entitlement cache when a Discord SKU subscription is created."""
|
||
invalidate_entitled_guilds_cache()
|
||
logging.info(f"[PREMIUM] Discord entitlement created for guild {entitlement.guild_id}, cache invalidated")
|
||
|
||
|
||
async def _refresh_presence():
|
||
"""Update the bot's Discord presence to show the current guild count."""
|
||
logging.info("Refreshing status . . .")
|
||
GUILD_TOTAL = len(bot.guilds)
|
||
|
||
await bot.change_presence(
|
||
activity=discord.Activity(
|
||
type=discord.ActivityType.playing,
|
||
name=f"Playing War Thunder in {GUILD_TOTAL} servers!"
|
||
)
|
||
)
|
||
|
||
async def squadron_autocomplete(
|
||
interaction: discord.Interaction,
|
||
current: str,
|
||
) -> List[discord.app_commands.Choice[str]]:
|
||
"""Autocomplete for squadron short names. Shared across all squadron-param commands."""
|
||
try:
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
|
||
await db.create_function("ulower", 1, str.lower)
|
||
if not current:
|
||
async with db.execute(
|
||
"""SELECT short_name, long_name FROM squadrons_data
|
||
WHERE short_name IS NOT NULL
|
||
ORDER BY position ASC NULLS LAST
|
||
LIMIT 25"""
|
||
) as cursor:
|
||
rows = await cursor.fetchall()
|
||
else:
|
||
search = f"%{current}%"
|
||
async with db.execute(
|
||
"""SELECT short_name, long_name FROM squadrons_data
|
||
WHERE short_name IS NOT NULL
|
||
AND (ulower(short_name) LIKE ulower(?)
|
||
OR ulower(long_name) LIKE ulower(?)
|
||
OR ulower(tag_name) LIKE ulower(?))
|
||
ORDER BY
|
||
CASE WHEN ulower(short_name) = ulower(?) THEN 0
|
||
WHEN ulower(short_name) LIKE ulower(?) THEN 1
|
||
ELSE 2
|
||
END,
|
||
position ASC NULLS LAST
|
||
LIMIT 25""",
|
||
(search, search, search, current, f"{current}%")
|
||
) as cursor:
|
||
rows = await cursor.fetchall()
|
||
|
||
return [
|
||
discord.app_commands.Choice(
|
||
name=row[0],
|
||
value=row[0]
|
||
)
|
||
for row in rows
|
||
]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
@is_blacklisted()
|
||
@bot.tree.command(name='comp', description=command_locale('Find the last known comps for a given team', "commands.comp.description"))
|
||
@app_commands.describe(squadron_short=command_locale('The shortname of the enemy team', "commands.comp.squadron_short"))
|
||
@discord.app_commands.autocomplete(squadron_short=squadron_autocomplete)
|
||
async def comp(interaction: discord.Interaction, squadron_short: str):
|
||
"""Show the last known team compositions for a squadron.
|
||
|
||
Loads comp data from the COMPS JSON directory, filters to comps seen in the
|
||
last hour, sorts by recency, and displays each comp with player-vehicle
|
||
lists and unit-type notation (e.g. 2F / 1T / 1AA).
|
||
|
||
Args:
|
||
interaction: The Discord interaction.
|
||
squadron_short: Short name of the enemy team.
|
||
"""
|
||
guild = interaction.guild
|
||
if guild is None:
|
||
return
|
||
|
||
lang = await guild_lang(guild.id)
|
||
|
||
await interaction.response.defer()
|
||
|
||
# ── Comp usage limit (after free period) ──────────────────────────
|
||
comp_footer = DEFAULT_FOOTER_CAT
|
||
now_ts_check = int(time_module.time())
|
||
if now_ts_check >= COMP_FREE_UNTIL_TS:
|
||
slot_start = get_current_timeslot_start_ts()
|
||
if slot_start is not None:
|
||
entitled = await is_guild_entitled(guild.id)
|
||
if not entitled:
|
||
used_server = await get_comp_usage_in_timeslot(guild.id, slot_start)
|
||
used_user = await get_comp_usage_in_timeslot_by_user(
|
||
interaction.user.id,
|
||
slot_start,
|
||
exclude_guild_ids=get_entitled_guild_ids(),
|
||
)
|
||
logging.info(
|
||
"[COMP-USAGE] guild=%s user=%s squadron=%s used_server=%s/%s used_user=%s/%s slot_start=%s entitled=%s",
|
||
guild.id,
|
||
interaction.user.id,
|
||
squadron_short,
|
||
used_server,
|
||
COMP_LIMIT_PER_TIMESLOT,
|
||
used_user,
|
||
COMP_LIMIT_PER_USER_PER_TIMESLOT,
|
||
slot_start,
|
||
entitled,
|
||
)
|
||
if used_user >= COMP_LIMIT_PER_USER_PER_TIMESLOT:
|
||
logging.warning(
|
||
"[COMP-LIMIT] guild=%s user=%s squadron=%s scope=user used_user=%s/%s slot_start=%s action=blocked",
|
||
guild.id,
|
||
interaction.user.id,
|
||
squadron_short,
|
||
used_user,
|
||
COMP_LIMIT_PER_USER_PER_TIMESLOT,
|
||
slot_start,
|
||
)
|
||
embed_limit = discord.Embed(
|
||
title=t(lang, "comp.limit_reached_title"),
|
||
description=t(
|
||
lang,
|
||
"comp.user_limit_reached_desc",
|
||
limit=COMP_LIMIT_PER_USER_PER_TIMESLOT,
|
||
),
|
||
color=discord.Color.red()
|
||
)
|
||
embed_limit.set_footer(
|
||
text=t(
|
||
lang,
|
||
"comp.user_remaining_footer",
|
||
remaining=0,
|
||
limit=COMP_LIMIT_PER_USER_PER_TIMESLOT,
|
||
)
|
||
)
|
||
return await interaction.followup.send(embed=embed_limit)
|
||
if used_server >= COMP_LIMIT_PER_TIMESLOT:
|
||
logging.warning(
|
||
"[COMP-LIMIT] guild=%s user=%s squadron=%s scope=server used_server=%s/%s slot_start=%s action=blocked",
|
||
guild.id,
|
||
interaction.user.id,
|
||
squadron_short,
|
||
used_server,
|
||
COMP_LIMIT_PER_TIMESLOT,
|
||
slot_start,
|
||
)
|
||
embed_limit = discord.Embed(
|
||
title=t(lang, "comp.limit_reached_title"),
|
||
description=t(lang, "comp.limit_reached_desc", limit=COMP_LIMIT_PER_TIMESLOT),
|
||
color=discord.Color.red()
|
||
)
|
||
embed_limit.set_footer(text=t(lang, "comp.remaining_footer", remaining=0, limit=COMP_LIMIT_PER_TIMESLOT))
|
||
return await interaction.followup.send(embed=embed_limit)
|
||
user_remaining = COMP_LIMIT_PER_USER_PER_TIMESLOT - used_user - 1
|
||
server_remaining = COMP_LIMIT_PER_TIMESLOT - used_server - 1
|
||
logging.info(
|
||
"[COMP-REMAINING] guild=%s user=%s squadron=%s user_remaining=%s/%s server_remaining=%s/%s slot_start=%s",
|
||
guild.id,
|
||
interaction.user.id,
|
||
squadron_short,
|
||
user_remaining,
|
||
COMP_LIMIT_PER_USER_PER_TIMESLOT,
|
||
server_remaining,
|
||
COMP_LIMIT_PER_TIMESLOT,
|
||
slot_start,
|
||
)
|
||
comp_footer = t(
|
||
lang,
|
||
"comp.remaining_footer_combined",
|
||
user_remaining=user_remaining,
|
||
user_limit=COMP_LIMIT_PER_USER_PER_TIMESLOT,
|
||
server_remaining=server_remaining,
|
||
server_limit=COMP_LIMIT_PER_TIMESLOT,
|
||
)
|
||
|
||
squadron_short = squadron_short.upper()
|
||
comp_dir = STORAGE_DIR / "COMPS"
|
||
squad_file = comp_dir / f"{squadron_short}.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
|
||
threshold_seconds = 3600 # 1 hour
|
||
now_ts = int(time_module.time())
|
||
|
||
embed = discord.Embed(
|
||
title=t(lang, "comp.title", squadron=squadron_short),
|
||
description=t(lang, "comp.desc", minutes=threshold_seconds // 60),
|
||
color=discord.Color.blurple()
|
||
)
|
||
embed.set_footer(text=comp_footer)
|
||
|
||
added_any = False
|
||
comp_index = 1 # <- start numbering here
|
||
|
||
# Sort by most recent (upd or reg)
|
||
for comp_key, comp in sorted(
|
||
comps_data.items(),
|
||
key=lambda kv: kv[1].get("upd") or kv[1].get("reg", 0),
|
||
reverse=True
|
||
):
|
||
logging.info(f"(COMP-CMD) Processing {comp_key} for {squadron_short}")
|
||
reg_ts = comp.get("reg", 0)
|
||
upd_ts = comp.get("upd", 0)
|
||
last_seen_ts = upd_ts or reg_ts
|
||
age = now_ts - last_seen_ts
|
||
|
||
# Skip if too old
|
||
if age > threshold_seconds:
|
||
continue
|
||
|
||
# Discord‐style relative timestamps
|
||
reg_str = f"<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
|
||
|
||
embed.add_field(
|
||
name=comp_title,
|
||
value=(
|
||
#f"SQ Number {squad_key}\n"
|
||
t(lang, "comp.last_seen_label", timestamp=last_str, warning=' ⚠️' if age > 1200 else '') + "\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, 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("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 + 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, 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, 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"
|
||
|
||
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"
|
||
else:
|
||
if type_normalized not in ("Logs", "Points"):
|
||
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, 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():
|
||
logs_chan = cfg.get("Logs", "")
|
||
if not logs_chan or "DISABLED" in str(logs_chan).upper():
|
||
continue
|
||
has_any_logs = True
|
||
chan_id_match = re.search(r"\b(\d{17,19})\b", str(logs_chan))
|
||
chan_id = int(chan_id_match.group(1)) if chan_id_match else None
|
||
|
||
# Check if channel is valid
|
||
if chan_id:
|
||
target_ch = bot.get_channel(chan_id)
|
||
if target_ch is None:
|
||
try:
|
||
target_ch = await bot.fetch_channel(chan_id)
|
||
except Exception:
|
||
target_ch = None
|
||
|
||
if target_ch and isinstance(target_ch, discord.abc.GuildChannel):
|
||
target_perms = target_ch.permissions_for(bot_member)
|
||
ch_ok = target_perms.send_messages and target_perms.attach_files
|
||
icon = "\u2705" if ch_ok else "\u274c"
|
||
status = "" if ch_ok else t(lang, "diagnostics.missing_send_attach")
|
||
else:
|
||
icon = "\u274c"
|
||
status = t(lang, "diagnostics.channel_not_found")
|
||
else:
|
||
icon = "\u274c"
|
||
status = t(lang, "diagnostics.invalid_channel_id")
|
||
|
||
is_selected_channel = chan_id == channel.id
|
||
here = t(lang, "diagnostics.selected_channel_tag") if is_selected_channel else ""
|
||
lines.append(f" {icon} `{key}` -> <#{chan_id}>{here}{status}")
|
||
|
||
if not has_any_logs:
|
||
lines.append(t(lang, "diagnostics.autolog_no_logs_channels"))
|
||
lines.append(t(lang, "diagnostics.autolog_enable_hint"))
|
||
|
||
lines.append("")
|
||
|
||
# ── 4. Premium / entitlement check ──
|
||
lines.append(t(lang, "diagnostics.premium_status_header"))
|
||
if await is_guild_entitled(guild.id):
|
||
lines.append(t(lang, "diagnostics.premium_active"))
|
||
else:
|
||
lines.append(t(lang, "diagnostics.premium_not_subscribed"))
|
||
lines.append(t(lang, "diagnostics.premium_autolog_required"))
|
||
lines.append("")
|
||
|
||
# ── Send ──
|
||
embed = discord.Embed(
|
||
title=t(lang, "diagnostics.title"),
|
||
description="\n".join(lines),
|
||
color=discord.Color.blue()
|
||
)
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||
|
||
|
||
def _extract_pref_channel_id(raw: Any) -> Optional[int]:
|
||
"""Extract a Discord channel ID from a stored preference value."""
|
||
if raw is None:
|
||
return None
|
||
match = re.search(r"\b(\d{17,19})\b", str(raw))
|
||
return int(match.group(1)) if match else None
|
||
|
||
|
||
def _format_pref_target_name(pref_key: str, settings: dict[str, Any]) -> str:
|
||
"""Prefer Short/Long squadron labels over raw preference keys."""
|
||
if pref_key.lower() in WILDCARD_KEYS or pref_key == "Global":
|
||
return pref_key
|
||
|
||
short_name = str(settings.get("Short") or "").strip()
|
||
long_name = str(settings.get("Long") or "").strip()
|
||
|
||
if short_name and long_name and short_name.lower() != long_name.lower():
|
||
return f"{short_name} - {long_name}"
|
||
if short_name:
|
||
return short_name
|
||
if long_name:
|
||
return long_name
|
||
return pref_key
|
||
|
||
|
||
def _configured_pref_targets(preferences: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""Return enabled channel targets from autolog preferences."""
|
||
targets: list[dict[str, Any]] = []
|
||
seen: set[tuple[str, str, int]] = set()
|
||
for squadron, settings in preferences.items():
|
||
if not isinstance(settings, dict):
|
||
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):
|
||
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)
|
||
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
await db.create_function("ulower", 1, str.lower)
|
||
async with db.execute(
|
||
"""
|
||
SELECT UID, MIN(nick) AS nick
|
||
FROM player_games_hist
|
||
WHERE ulower(nick) LIKE ulower(?)
|
||
GROUP BY UID
|
||
ORDER BY nick
|
||
LIMIT 25
|
||
""",
|
||
(f"%{player}%",),
|
||
) as cursor:
|
||
results = list(await cursor.fetchall())
|
||
except Exception as e:
|
||
error_str = str(e)[:1800]
|
||
return await interaction.followup.send(
|
||
t(lang, "common.database_error", error=error_str), ephemeral=True
|
||
)
|
||
|
||
if not results:
|
||
return await interaction.followup.send(
|
||
t(lang, "player.no_players_found", username=player), ephemeral=True
|
||
)
|
||
|
||
if len(results) > 1:
|
||
return await interaction.followup.send(
|
||
t(lang, "player.multiple_matches"),
|
||
view=CardPlayerSelectView(
|
||
results, interaction.user, season, theme_value, lang=lang
|
||
),
|
||
)
|
||
|
||
await _send_player_card(
|
||
interaction, int(results[0]["UID"]), results[0]["nick"],
|
||
season, theme_value, lang, followup=True,
|
||
)
|
||
|
||
|
||
@card.error
|
||
async def card_error(interaction, error):
|
||
await permission_fail(interaction, error)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# PLAYER STATS WITH VEHICLE BREAKDOWN
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
class PlayerSelectViewForStats(View):
|
||
"""View for selecting a player when multiple matches are found"""
|
||
def __init__(self, results, author: discord.abc.User, lang: str = "en"):
|
||
super().__init__(timeout=30)
|
||
self.author = author
|
||
self.results = results
|
||
self.lang = lang
|
||
|
||
options = [
|
||
discord.SelectOption(
|
||
label=row["nick"][:100],
|
||
description=f"UID: {row['UID']}"[:100],
|
||
value=str(row["UID"])[:100]
|
||
)
|
||
for row in results[:25] # Discord limit of 25 options
|
||
]
|
||
|
||
self.select = Select(
|
||
placeholder=t(lang, "player.select_player_placeholder"),
|
||
options=options,
|
||
min_values=1,
|
||
max_values=1
|
||
)
|
||
self.select.callback = self.select_callback
|
||
self.add_item(self.select)
|
||
|
||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||
return interaction.user.id == self.author.id
|
||
|
||
async def select_callback(self, interaction: discord.Interaction):
|
||
"""Fetch aggregated vehicle stats for the selected player and show VehicleStatsView."""
|
||
uid = self.select.values[0]
|
||
|
||
# Defer to show thinking state
|
||
await interaction.response.defer()
|
||
|
||
# Fetch vehicle stats for selected player
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
|
||
# Get player info
|
||
async with db.execute(
|
||
"SELECT nick, squadron_name FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1",
|
||
(uid,)
|
||
) as cursor:
|
||
player_row = await cursor.fetchone()
|
||
|
||
if not player_row:
|
||
await interaction.followup.send(t(self.lang, "player.no_stats_found", uid=uid), ephemeral=True)
|
||
return
|
||
|
||
# Get aggregated vehicle stats
|
||
async with db.execute(
|
||
"""
|
||
SELECT
|
||
vehicle_internal,
|
||
vehicle,
|
||
SUM(ground_kills) as total_ground_kills,
|
||
SUM(air_kills) as total_air_kills,
|
||
SUM(assists) as total_assists,
|
||
SUM(captures) as total_captures,
|
||
SUM(deaths) as total_deaths,
|
||
SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins,
|
||
SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses,
|
||
COUNT(*) as total_battles
|
||
FROM player_games_hist
|
||
WHERE UID = ?
|
||
GROUP BY vehicle_internal
|
||
ORDER BY total_battles DESC
|
||
""",
|
||
(uid,)
|
||
) as cursor:
|
||
vehicle_rows = await cursor.fetchall()
|
||
except Exception as e:
|
||
error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
|
||
await interaction.followup.send(t(self.lang, "common.database_error", error=error_str), ephemeral=True)
|
||
return
|
||
|
||
if not vehicle_rows:
|
||
await interaction.followup.send(t(self.lang, "player.no_vehicle_stats"), ephemeral=True)
|
||
return
|
||
|
||
# Convert rows to dicts with calculated win rate
|
||
vehicle_stats = []
|
||
for row in vehicle_rows:
|
||
wins = row['wins'] or 0
|
||
losses = row['losses'] or 0
|
||
total_battles = row['total_battles'] or 0
|
||
|
||
win_rate = '0.0'
|
||
if total_battles > 0 and wins >= 0:
|
||
win_rate = f"{(wins / total_battles * 100):.1f}"
|
||
|
||
vehicle_name = utils.apply_vehicle_name_filters(row['vehicle']) if row['vehicle'] else ""
|
||
|
||
vehicle_stats.append({
|
||
'vehicle_internal': row['vehicle_internal'],
|
||
'vehicle': vehicle_name or row['vehicle_internal'],
|
||
'total_ground_kills': row['total_ground_kills'] or 0,
|
||
'total_air_kills': row['total_air_kills'] or 0,
|
||
'total_assists': row['total_assists'] or 0,
|
||
'total_captures': row['total_captures'] or 0,
|
||
'total_deaths': row['total_deaths'] or 0,
|
||
'wins': wins,
|
||
'losses': losses,
|
||
'total_battles': total_battles,
|
||
'win_rate': win_rate
|
||
})
|
||
|
||
player_info = {
|
||
'nick': player_row['nick'],
|
||
'squadron': player_row['squadron_name'],
|
||
'uid': uid
|
||
}
|
||
|
||
# Update message with vehicle dropdown
|
||
await interaction.edit_original_response(
|
||
content=t(self.lang, "player.vehicles_found", count=len(vehicle_stats), nick=esc(player_info['nick'])),
|
||
view=VehicleStatsView(vehicle_stats, player_info, self.author, lang=self.lang)
|
||
)
|
||
|
||
|
||
class VehicleStatsView(View):
|
||
"""View for selecting a vehicle to see detailed stats with pagination"""
|
||
def __init__(self, vehicle_stats: list, player_info: dict, author: discord.abc.User, page: int = 0, lang: str = "en"):
|
||
super().__init__(timeout=60)
|
||
self.author = author
|
||
self.vehicle_stats = vehicle_stats
|
||
self.player_info = player_info
|
||
self.page = page
|
||
self.lang = lang
|
||
self.total_pages = (len(vehicle_stats) + 24) // 25 # Ceiling division
|
||
|
||
# Get current page of vehicles
|
||
start_idx = page * 25
|
||
end_idx = min(start_idx + 25, len(vehicle_stats))
|
||
current_vehicles = vehicle_stats[start_idx:end_idx]
|
||
|
||
# Create dropdown options from vehicle stats
|
||
options = []
|
||
for idx, vehicle in enumerate(current_vehicles):
|
||
actual_idx = start_idx + idx
|
||
|
||
# Calculate K/D ratio
|
||
total_kills = vehicle['total_ground_kills'] + vehicle['total_air_kills']
|
||
deaths = vehicle['total_deaths'] if vehicle['total_deaths'] > 0 else 1
|
||
kd = round(total_kills / deaths, 2)
|
||
|
||
# Create label with vehicle name and basic stats
|
||
label = vehicle['vehicle'][:100] if vehicle['vehicle'] else f"Vehicle {actual_idx+1}"
|
||
description = f"Battles: {vehicle['total_battles']} | K/D: {kd} | WR: {vehicle['win_rate']}%"
|
||
|
||
options.append(discord.SelectOption(
|
||
label=label[:100],
|
||
description=description[:100],
|
||
value=str(actual_idx) # Use actual index in full list
|
||
))
|
||
|
||
self.select = Select(
|
||
placeholder=t(lang, "player.vehicle_select_placeholder", page=page + 1, total=self.total_pages),
|
||
options=options,
|
||
min_values=1,
|
||
max_values=1
|
||
)
|
||
self.select.callback = self.select_callback
|
||
self.add_item(self.select)
|
||
|
||
# Add pagination buttons if needed
|
||
if self.total_pages > 1:
|
||
prev_button = discord.ui.Button(
|
||
label=t(lang, "buttons.prev_arrow"),
|
||
style=discord.ButtonStyle.secondary,
|
||
disabled=(page == 0)
|
||
)
|
||
next_button = discord.ui.Button(
|
||
label=t(lang, "buttons.next_arrow"),
|
||
style=discord.ButtonStyle.secondary,
|
||
disabled=(page >= self.total_pages - 1)
|
||
)
|
||
|
||
async def prev_callback(interaction: discord.Interaction):
|
||
await interaction.response.edit_message(
|
||
content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])),
|
||
view=VehicleStatsView(self.vehicle_stats, self.player_info, self.author, self.page - 1, lang=self.lang)
|
||
)
|
||
|
||
async def next_callback(interaction: discord.Interaction):
|
||
await interaction.response.edit_message(
|
||
content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])),
|
||
view=VehicleStatsView(self.vehicle_stats, self.player_info, self.author, self.page + 1, lang=self.lang)
|
||
)
|
||
|
||
prev_button.callback = prev_callback
|
||
next_button.callback = next_callback
|
||
|
||
self.add_item(prev_button)
|
||
self.add_item(next_button)
|
||
|
||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||
return interaction.user.id == self.author.id
|
||
|
||
async def select_callback(self, interaction: discord.Interaction):
|
||
"""Build a detailed stats embed for the selected vehicle and refresh the same message."""
|
||
# Get selected vehicle stats
|
||
vehicle_idx = int(self.select.values[0])
|
||
vehicle = self.vehicle_stats[vehicle_idx]
|
||
|
||
# Calculate additional stats
|
||
total_kills = vehicle['total_ground_kills'] + vehicle['total_air_kills']
|
||
deaths = vehicle['total_deaths'] if vehicle['total_deaths'] > 0 else 1
|
||
kd_ratio = round(total_kills / deaths, 2)
|
||
win_rate = vehicle['win_rate']
|
||
|
||
# Find vehicle icon
|
||
vehicle_internal = vehicle['vehicle_internal']
|
||
icon_filename = f"{vehicle_internal}.png"
|
||
icon_path = ICONS_DIR / "VEHICLES" / icon_filename
|
||
|
||
# Use not_found.png if vehicle icon doesn't exist
|
||
if not icon_path.exists():
|
||
icon_path = ICONS_DIR / "not_found.png"
|
||
icon_filename = "not_found.png"
|
||
|
||
# Create embed with vehicle stats
|
||
embed = discord.Embed(
|
||
title=f"{vehicle['vehicle']}",
|
||
description=t(self.lang, "player.stats_desc", nick=esc(self.player_info['nick']), squadron=self.player_info['squadron'], uid=self.player_info['uid']),
|
||
color=discord.Color.blue()
|
||
)
|
||
|
||
# Set vehicle icon as thumbnail
|
||
embed.set_thumbnail(url=f"attachment://{icon_filename}")
|
||
|
||
# Combat stats
|
||
gk = f"{vehicle['total_ground_kills']:,}"
|
||
ak = f"{vehicle['total_air_kills']:,}"
|
||
tk = f"{total_kills:,}"
|
||
ast = f"{vehicle['total_assists']:,}"
|
||
dth = f"{vehicle['total_deaths']:,}"
|
||
cap = f"{vehicle['total_captures']:,}"
|
||
embed.add_field(
|
||
name="\u200b",
|
||
value=(
|
||
f"{t(self.lang, 'player.combat_stats_header')}\n"
|
||
f"{t(self.lang, 'player.ground_kills_label', value=gk)}\n"
|
||
f"{t(self.lang, 'player.air_kills_label', value=ak)}\n"
|
||
f"{t(self.lang, 'player.total_kills_label', value=tk)}\n"
|
||
f"{t(self.lang, 'player.assists_label', value=ast)}\n"
|
||
f"{t(self.lang, 'player.deaths_label', value=dth)}\n"
|
||
f"{t(self.lang, 'player.kd_label', value=kd_ratio)}\n"
|
||
f"{t(self.lang, 'player.captures_label', value=cap)}\n"
|
||
),
|
||
inline=False
|
||
)
|
||
|
||
# Battle record
|
||
tb = f"{vehicle['total_battles']:,}"
|
||
wn = f"{vehicle['wins']:,}"
|
||
ls = f"{vehicle['losses']:,}"
|
||
embed.add_field(
|
||
name="\u200b",
|
||
value=(
|
||
f"{t(self.lang, 'player.battle_record_header')}\n"
|
||
f"{t(self.lang, 'player.total_battles_label', value=tb)}\n"
|
||
f"{t(self.lang, 'player.wins_label', value=wn)}\n"
|
||
f"{t(self.lang, 'player.losses_label', value=ls)}\n"
|
||
f"{t(self.lang, 'player.win_rate_label', value=win_rate)}"
|
||
),
|
||
inline=False
|
||
)
|
||
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
|
||
# Create file object for the icon
|
||
file = discord.File(fp=icon_path, filename=icon_filename)
|
||
|
||
await interaction.response.edit_message(
|
||
content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])),
|
||
embed=embed,
|
||
attachments=[file],
|
||
view=self,
|
||
)
|
||
|
||
|
||
@is_blacklisted()
|
||
@bot.tree.command(
|
||
name="player-stats",
|
||
description=command_locale("View detailed vehicle statistics for a player", "commands.player_stats.description")
|
||
)
|
||
@app_commands.describe(
|
||
username=command_locale("The WT username for stats request", "commands.player_stats.username"),
|
||
uid=command_locale("The WT UID for stats request", "commands.player_stats.uid")
|
||
)
|
||
@discord.app_commands.autocomplete(username=player_autocomplete)
|
||
async def player_stats(interaction: discord.Interaction, username: str = "", uid: str = ""):
|
||
"""View per-vehicle battle statistics for a player.
|
||
|
||
Resolves the player by UID or username search. If multiple username
|
||
matches are found, shows a PlayerSelectViewForStats dropdown. Otherwise
|
||
fetches aggregated vehicle stats and presents a VehicleStatsView with
|
||
paginated vehicle dropdown.
|
||
|
||
Args:
|
||
interaction: The Discord interaction.
|
||
username: War Thunder username to search for.
|
||
uid: War Thunder UID for direct lookup.
|
||
"""
|
||
await collect_command_stats(interaction)
|
||
lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
|
||
# Handle UID lookup
|
||
if uid:
|
||
await interaction.response.defer(thinking=True)
|
||
target_uid = uid
|
||
elif username:
|
||
await interaction.response.defer(thinking=True)
|
||
|
||
# Search for player by username
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
await db.create_function("ulower", 1, str.lower)
|
||
async with db.execute(
|
||
"""
|
||
SELECT UID, MIN(nick) AS nick
|
||
FROM player_games_hist
|
||
WHERE ulower(nick) LIKE ulower(?)
|
||
GROUP BY UID
|
||
ORDER BY nick
|
||
LIMIT 25
|
||
""",
|
||
(f"%{username}%",),
|
||
) as cursor:
|
||
results = 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 results:
|
||
await interaction.followup.send(
|
||
t(lang, "player.no_players_found", username=username),
|
||
ephemeral=True
|
||
)
|
||
return
|
||
elif len(results) > 1:
|
||
# Multiple matches found - show dropdown
|
||
await interaction.followup.send(
|
||
t(lang, "player.multiple_matches"),
|
||
view=PlayerSelectViewForStats(results, interaction.user, lang=lang)
|
||
)
|
||
return
|
||
|
||
target_uid = results[0]["UID"]
|
||
else:
|
||
await interaction.response.send_message(
|
||
t(lang, "player.must_provide_input"),
|
||
ephemeral=True
|
||
)
|
||
return
|
||
|
||
# Get vehicle stats for the 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",
|
||
(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
|
||
|
||
# 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
|
||
""",
|
||
(target_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(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
|
||
})
|
||
|
||
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)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# /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)
|
||
|
||
# Resolve player nick → most-recently-seen UID
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
await db.create_function("ulower", 1, str.lower)
|
||
async with db.execute(
|
||
"""
|
||
SELECT UID, nick, MAX(endtime_unix) AS last_seen
|
||
FROM player_games_hist
|
||
WHERE ulower(nick) = ulower(?)
|
||
GROUP BY UID
|
||
ORDER BY last_seen DESC
|
||
LIMIT 1
|
||
""",
|
||
(player,),
|
||
) as cursor:
|
||
uid_row = await cursor.fetchone()
|
||
except Exception as e:
|
||
return await interaction.followup.send(t(lang, "common.database_error", error=str(e)[:1800]), ephemeral=True)
|
||
|
||
if not uid_row:
|
||
return await interaction.followup.send(
|
||
embed=discord.Embed(
|
||
title=t(lang, "player.not_found_title"),
|
||
description=t(lang, "player.not_found_desc", player=esc(player)),
|
||
color=discord.Color.red(),
|
||
).set_footer(text=DEFAULT_FOOTER_CAT)
|
||
)
|
||
|
||
target_uid = uid_row["UID"]
|
||
player_nick = esc(uid_row["nick"])
|
||
|
||
# Query last 20 sessions
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
|
||
async with db.execute(
|
||
"SELECT squadron_tagged FROM player_games_hist WHERE UID = ? ORDER BY endtime_unix DESC LIMIT 1",
|
||
(target_uid,),
|
||
) as cursor:
|
||
sq_row = await cursor.fetchone()
|
||
sq_tag = sq_row["squadron_tagged"] if sq_row else ""
|
||
|
||
cutoff = int(time_module.time()) - 8 * 3600
|
||
async with db.execute(
|
||
"""
|
||
SELECT
|
||
session_id,
|
||
MAX(endtime_unix) AS endtime_unix,
|
||
MAX(victor_bool) AS victor_bool,
|
||
GROUP_CONCAT(vehicle_internal, '||') AS vehicles
|
||
FROM player_games_hist
|
||
WHERE UID = ? AND endtime_unix >= ?
|
||
GROUP BY session_id
|
||
ORDER BY MAX(endtime_unix) DESC
|
||
""",
|
||
(target_uid, cutoff),
|
||
) as cursor:
|
||
sessions = list(await cursor.fetchall())
|
||
except Exception as e:
|
||
return await interaction.followup.send(t(lang, "common.database_error", error=str(e)[:1800]), ephemeral=True)
|
||
|
||
if not sessions:
|
||
return await interaction.followup.send(
|
||
embed=discord.Embed(
|
||
title=t(lang, "player_games.no_recent_title"),
|
||
description=t(lang, "player_games.no_recent_desc", player=player_nick),
|
||
color=discord.Color.red(),
|
||
).set_footer(text=DEFAULT_FOOTER_CAT)
|
||
)
|
||
|
||
# Fetch match_summary for opponent, map, and full team JSON
|
||
session_ids = [s["session_id"] for s in sessions]
|
||
placeholders = ",".join("?" * len(session_ids))
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
async with db.execute(
|
||
f"SELECT session_id, map_name, winning_sq, losing_sq, winning_team_json, losing_team_json "
|
||
f"FROM match_summary WHERE session_id IN ({placeholders})",
|
||
session_ids,
|
||
) as cursor:
|
||
ms_rows = await cursor.fetchall()
|
||
except Exception:
|
||
ms_rows = []
|
||
ms_map = {r["session_id"]: r for r in ms_rows}
|
||
|
||
_type_order = {"F": 0, "B": 1, "H": 2, "L": 3, "T": 4, "AA": 5, "?": 6}
|
||
_comp_order = [("F",), ("B",), ("H",), ("L",), ("T",), ("AA",), ("?",)]
|
||
|
||
def _comp_notation(veh_internals: list[str]) -> str:
|
||
"""Build a compact composition notation string (e.g. '2F / 1T / 1AA') from vehicle internals.
|
||
|
||
Args:
|
||
veh_internals: List of internal vehicle name strings.
|
||
|
||
Returns:
|
||
Formatted composition string, or '—' if empty.
|
||
"""
|
||
nd = count_unit_types(veh_internals)
|
||
parts = [f"{nd[code]}{code}" for (code,) in _comp_order if nd.get(code, 0) > 0]
|
||
return " / ".join(parts) or "—"
|
||
|
||
def _team_block(players: list[dict]) -> str:
|
||
"""Format a team's player list into a fixed-width code block sorted by vehicle type.
|
||
|
||
Args:
|
||
players: List of player dicts with 'nick', 'vehicle', and 'vehicle_new' keys.
|
||
|
||
Returns:
|
||
Discord code block string with aligned nick | vehicle rows.
|
||
"""
|
||
sorted_players = sorted(
|
||
players,
|
||
key=lambda p: _type_order.get(get_unit_type_abbrev(p.get("vehicle")), 6)
|
||
)
|
||
max_nick = max((len(p.get("nick", "?")) for p in sorted_players), default=1)
|
||
lines = [
|
||
f"{p.get('nick', '?'):<{max_nick}} | {p.get('vehicle_new') or normalize_name(p.get('vehicle') or '') or '?'}"
|
||
for p in sorted_players
|
||
]
|
||
return "```\n" + "\n".join(lines) + "\n```"
|
||
|
||
# Tally wins/losses and build a list of game groups
|
||
# Each game group is 1–2 (field_name, field_value) tuples
|
||
wins = 0
|
||
losses = 0
|
||
comp_counter: dict[str, int] = {}
|
||
game_groups: list[list[tuple[str, str]]] = []
|
||
|
||
for s in sessions:
|
||
is_win = (s["victor_bool"] or "").upper() == "WIN"
|
||
wins += is_win
|
||
losses += not is_win
|
||
|
||
ms = ms_map.get(s["session_id"])
|
||
if ms:
|
||
opponent = esc((ms["losing_sq"] if is_win else ms["winning_sq"]) or "?")
|
||
raw_map = re.sub(r'\[.*?\]', '', ms["map_name"] or "").strip()
|
||
map_name = esc(raw_map or "Unknown")
|
||
my_json = ms["winning_team_json"] if is_win else ms["losing_team_json"]
|
||
opp_json = ms["losing_team_json"] if is_win else ms["winning_team_json"]
|
||
try:
|
||
my_players = (decompress_json(my_json) if my_json else {}).get("players", [])
|
||
except (json.JSONDecodeError, TypeError):
|
||
my_players = []
|
||
try:
|
||
opp_players = (decompress_json(opp_json) if opp_json else {}).get("players", [])
|
||
except (json.JSONDecodeError, TypeError):
|
||
opp_players = []
|
||
else:
|
||
opponent = "?"
|
||
map_name = "Unknown"
|
||
my_players = []
|
||
opp_players = []
|
||
|
||
my_veh = [p.get("vehicle") for p in my_players if p.get("vehicle")] or \
|
||
[v for v in (s["vehicles"] or "").split("||") if v]
|
||
my_comp = _comp_notation(my_veh)
|
||
comp_counter[my_comp] = comp_counter.get(my_comp, 0) + 1
|
||
|
||
ts = f"<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)
|
||
async with aiofiles.open(replay_dir / "replay_data.json", "w", encoding="utf-8") as f:
|
||
await f.write(json.dumps(replay_data, indent=4, ensure_ascii=False))
|
||
|
||
# 3. Translate vehicles
|
||
translate = LangTableReader("English")
|
||
for team in replay_data.get("teams", []):
|
||
for player in team.get("players", []):
|
||
vehicle = player.get("vehicle")
|
||
if vehicle:
|
||
translated = translate.get_translate(vehicle)
|
||
player["vehicle_new"] = translated if translated else vehicle
|
||
else:
|
||
player["vehicle"] = "DISCONNECTED"
|
||
player["vehicle_new"] = "DISCONNECTED"
|
||
|
||
# 4. Resolve clan long names
|
||
squads = [t.get("squadron") for t in replay_data.get("teams", []) if t.get("squadron")]
|
||
squads_tagged = [t.get("squadron_tagged") for t in replay_data.get("teams", []) if t.get("squadron_tagged")]
|
||
resolved = await resolve_clans(shorts=squads, tags=squads_tagged)
|
||
for team, clan_info in zip(replay_data.get("teams", []), resolved):
|
||
if team and clan_info.get("long_name"):
|
||
team["squadron_long"] = clan_info["long_name"]
|
||
|
||
# 5. Build scoreboard
|
||
winner = replay_data.get("winning_team_squadron", "")
|
||
is_draw = replay_data.get("draw", False)
|
||
teams = replay_data.get("teams", [])
|
||
timestamp = replay_data.get("timestamp", 0)
|
||
map_name = replay_data.get("map", "")
|
||
|
||
match_details: dict = {"utc_timestamp": str(timestamp), "session_id": session_id}
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as _conn:
|
||
async with _conn.execute(
|
||
"SELECT received_unix FROM match_summary WHERE session_id = ?",
|
||
(session_id,),
|
||
) as _cur:
|
||
_row = await _cur.fetchone()
|
||
if _row and _row[0] is not None:
|
||
match_details["received_unix"] = int(_row[0])
|
||
except Exception:
|
||
pass
|
||
|
||
output_dir = replay_session_dir(session_id)
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
output_path = output_dir / "game_result-not_involved-English.png"
|
||
|
||
if not output_path.exists():
|
||
await create_scoreboard(
|
||
match_details,
|
||
winner,
|
||
teams[0] if len(teams) > 0 else {},
|
||
teams[1] if len(teams) > 1 else {},
|
||
map_name,
|
||
str(output_path),
|
||
bar_color="not_involved",
|
||
is_draw=is_draw,
|
||
)
|
||
|
||
if not output_path.exists():
|
||
return await interaction.followup.send(
|
||
embed=discord.Embed(
|
||
title=t(lang, "match.scoreboard_error_title"),
|
||
description=t(lang, "match.scoreboard_error_desc"),
|
||
color=discord.Color.red(),
|
||
).set_footer(text=DEFAULT_FOOTER_CAT),
|
||
ephemeral=True,
|
||
)
|
||
|
||
# 6. Send with buttons
|
||
view = build_scoreboard_view(interaction.guild_id or 0, session_id, lang=lang)
|
||
with open(output_path, "rb") as f:
|
||
await interaction.followup.send(
|
||
file=discord.File(f, filename="game_result.png"),
|
||
view=view,
|
||
)
|
||
|
||
|
||
@is_blacklisted()
|
||
@bot.tree.command(
|
||
name="view-match",
|
||
description=command_locale("View a match scoreboard by ID or player", "commands.view_match.description")
|
||
)
|
||
@app_commands.describe(
|
||
match_id=command_locale("The session hex ID of the match to view", "commands.view_match.match_id"),
|
||
player_name=command_locale("A player's username to browse recent matches", "commands.view_match.player_name"),
|
||
)
|
||
@discord.app_commands.autocomplete(player_name=player_autocomplete)
|
||
async def view_match(
|
||
interaction: discord.Interaction,
|
||
match_id: Optional[str] = None,
|
||
player_name: Optional[str] = None,
|
||
):
|
||
"""View a match scoreboard by direct session ID or by browsing a player's recent games.
|
||
|
||
If match_id is provided, defers and renders the scoreboard directly via
|
||
_send_view_match_scoreboard. If player_name is provided, resolves the player's
|
||
UID, fetches their last 100 sessions with opponent/map info, and presents a
|
||
ViewMatchSelectView dropdown for the user to pick a match.
|
||
"""
|
||
await collect_command_stats(interaction)
|
||
lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
|
||
if not match_id and not player_name:
|
||
return await interaction.response.send_message(
|
||
embed=discord.Embed(
|
||
title=t(lang, "match.missing_input_title"),
|
||
description=t(lang, "match.missing_input_desc"),
|
||
color=discord.Color.red(),
|
||
).set_footer(text=DEFAULT_FOOTER_CAT),
|
||
ephemeral=True,
|
||
)
|
||
|
||
# Direct ID lookup
|
||
if match_id:
|
||
await interaction.response.defer(thinking=True)
|
||
# Normalise: strip leading "0" prefix if provided, strip whitespace
|
||
match_id = match_id.strip().lstrip("0") or match_id.strip()
|
||
await _send_view_match_scoreboard(interaction, match_id, lang=lang)
|
||
return
|
||
|
||
# Player lookup → show dropdown of last 100 games
|
||
await interaction.response.defer(thinking=True, ephemeral=True)
|
||
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
await db.create_function("ulower", 1, str.lower)
|
||
|
||
# Resolve nick → UID
|
||
async with db.execute(
|
||
"""
|
||
SELECT UID, nick, MAX(endtime_unix) AS last_seen
|
||
FROM player_games_hist
|
||
WHERE ulower(nick) = ulower(?)
|
||
GROUP BY UID
|
||
ORDER BY last_seen DESC
|
||
LIMIT 1
|
||
""",
|
||
(player_name,),
|
||
) as cursor:
|
||
uid_row = await cursor.fetchone()
|
||
|
||
if not uid_row:
|
||
return await interaction.followup.send(
|
||
embed=discord.Embed(
|
||
title=t(lang, "player.not_found_title"),
|
||
description=t(lang, "player.not_found_desc", player=esc(player_name or '')),
|
||
color=discord.Color.red(),
|
||
).set_footer(text=DEFAULT_FOOTER_CAT),
|
||
ephemeral=True,
|
||
)
|
||
|
||
target_uid = uid_row["UID"]
|
||
player_nick = uid_row["nick"]
|
||
|
||
# Fetch last 100 sessions with opponent info
|
||
async with db.execute(
|
||
"""
|
||
SELECT
|
||
p.session_id,
|
||
MAX(p.endtime_unix) AS endtime_unix,
|
||
MAX(p.victor_bool) AS victor_bool,
|
||
ms.map_name,
|
||
CASE
|
||
WHEN UPPER(MAX(p.victor_bool)) = 'WIN' THEN ms.losing_sq
|
||
ELSE ms.winning_sq
|
||
END AS opponent
|
||
FROM player_games_hist p
|
||
LEFT JOIN match_summary ms ON ms.session_id = p.session_id
|
||
WHERE p.UID = ?
|
||
GROUP BY p.session_id
|
||
ORDER BY MAX(p.endtime_unix) DESC
|
||
LIMIT 100
|
||
""",
|
||
(target_uid,),
|
||
) as cursor:
|
||
sessions = [dict(row) for row in await cursor.fetchall()]
|
||
|
||
except Exception as e:
|
||
return await interaction.followup.send(
|
||
t(lang, "common.database_error", error=str(e)[:1800]),
|
||
ephemeral=True,
|
||
)
|
||
|
||
if not sessions:
|
||
return await interaction.followup.send(
|
||
embed=discord.Embed(
|
||
title=t(lang, "match.no_games_title"),
|
||
description=t(lang, "match.no_games_desc", player=esc(player_nick)),
|
||
color=discord.Color.red(),
|
||
).set_footer(text=DEFAULT_FOOTER_CAT),
|
||
ephemeral=True,
|
||
)
|
||
|
||
view = ViewMatchSelectView(sessions, interaction.user, lang=lang)
|
||
view.message = await interaction.followup.send(
|
||
embed=discord.Embed(
|
||
title=t(lang, "match.recent_matches_title", player=esc(player_nick)),
|
||
description=t(lang, "match.recent_matches_desc", count=len(sessions)),
|
||
color=discord.Color.blurple(),
|
||
).set_footer(text=DEFAULT_FOOTER_CAT),
|
||
view=view,
|
||
ephemeral=True,
|
||
wait=True,
|
||
)
|
||
|
||
|
||
@view_match.error
|
||
async def view_match_perm_error(interaction, error):
|
||
await permission_fail(interaction, error)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# PLAYER COMPARISON
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
_COMPARE_STATS_ORDER = [
|
||
("total_battles", "compare.battles_label"),
|
||
("wins", "compare.wins_label"),
|
||
("losses", "compare.losses_label"),
|
||
("win_rate", "compare.win_rate_label"),
|
||
("ground_kills", "compare.ground_kills_label"),
|
||
("air_kills", "compare.air_kills_label"),
|
||
("total_kills", "compare.total_kills_label"),
|
||
("assists", "compare.assists_label"),
|
||
("deaths", "compare.deaths_label"),
|
||
("kd", "compare.kd_label"),
|
||
("captures", "compare.captures_label"),
|
||
]
|
||
|
||
# Stats where lower is better
|
||
_LOWER_IS_BETTER = {"deaths", "losses"}
|
||
|
||
|
||
async def _resolve_player_uids_batch(db, usernames: List[str], lang: str = "en"):
|
||
"""Resolve multiple usernames in one pass. Returns (uid_list, error_msg) or (None, error_msg)."""
|
||
uids: List[str] = []
|
||
for name in usernames:
|
||
async with db.execute(
|
||
"""
|
||
SELECT UID, nick FROM (
|
||
SELECT UID, nick, ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn
|
||
FROM player_games_hist
|
||
WHERE ulower(nick) LIKE ulower(?)
|
||
) WHERE rn = 1
|
||
ORDER BY nick LIMIT 25
|
||
""",
|
||
(f"%{name}%",),
|
||
) as cursor:
|
||
results = list(await cursor.fetchall())
|
||
if not results:
|
||
return None, t(lang, "compare.no_players_found", name=name)
|
||
if len(results) > 1:
|
||
matches = ", ".join(esc(r["nick"]) for r in results[:10])
|
||
return None, t(lang, "compare.multiple_matches", name=name, matches=matches)
|
||
uids.append(results[0]["UID"])
|
||
return uids, None
|
||
|
||
|
||
async def _fetch_players_aggregate_batch(db, uids: List[str]) -> List[Optional[dict]]:
|
||
"""Fetch aggregate stats for multiple UIDs in two batched queries."""
|
||
placeholders = ",".join("?" for _ in uids)
|
||
|
||
# Batch 1: latest nick + squadron for each UID
|
||
info_map: dict = {}
|
||
async with db.execute(
|
||
f"""
|
||
SELECT UID, nick, squadron_name FROM (
|
||
SELECT UID, nick, squadron_name,
|
||
ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn
|
||
FROM player_games_hist
|
||
WHERE UID IN ({placeholders})
|
||
) WHERE rn = 1
|
||
""",
|
||
uids,
|
||
) as cursor:
|
||
async for row in cursor:
|
||
info_map[row["UID"]] = row
|
||
|
||
# Batch 2: aggregate stats for all UIDs at once
|
||
stats_map: dict = {}
|
||
async with db.execute(
|
||
f"""
|
||
SELECT
|
||
UID,
|
||
SUM(ground_kills) as ground_kills,
|
||
SUM(air_kills) as air_kills,
|
||
SUM(assists) as assists,
|
||
SUM(captures) as captures,
|
||
SUM(deaths) as deaths,
|
||
SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins,
|
||
SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses,
|
||
COUNT(*) as total_battles
|
||
FROM player_games_hist
|
||
WHERE UID IN ({placeholders})
|
||
GROUP BY UID
|
||
""",
|
||
uids,
|
||
) as cursor:
|
||
async for row in cursor:
|
||
stats_map[row["UID"]] = row
|
||
|
||
# Assemble results in UID order
|
||
results: List[Optional[dict]] = []
|
||
for uid in uids:
|
||
info = info_map.get(uid)
|
||
row = stats_map.get(uid)
|
||
if not info or not row or not row["total_battles"]:
|
||
results.append(None)
|
||
continue
|
||
|
||
total_kills = (row["ground_kills"] or 0) + (row["air_kills"] or 0)
|
||
deaths = row["deaths"] or 0
|
||
total_battles = row["total_battles"] or 0
|
||
wins = row["wins"] or 0
|
||
losses = row["losses"] or 0
|
||
|
||
results.append({
|
||
"nick": info["nick"],
|
||
"squadron": info["squadron_name"] or "None",
|
||
"uid": uid,
|
||
"ground_kills": row["ground_kills"] or 0,
|
||
"air_kills": row["air_kills"] or 0,
|
||
"total_kills": total_kills,
|
||
"assists": row["assists"] or 0,
|
||
"captures": row["captures"] or 0,
|
||
"deaths": deaths,
|
||
"kd": round(total_kills / deaths, 2) if deaths > 0 else float(total_kills),
|
||
"wins": wins,
|
||
"losses": losses,
|
||
"total_battles": total_battles,
|
||
"win_rate": f"{(wins / total_battles * 100):.1f}" if total_battles > 0 else "0.0",
|
||
})
|
||
return results
|
||
|
||
|
||
def _build_compare_embed(players: List[dict], lang: str = "en") -> Embed:
|
||
"""Build a side-by-side comparison embed for multiple players."""
|
||
|
||
# Pre-compute best value per stat
|
||
best: dict = {}
|
||
all_tied: dict = {}
|
||
for key, _ in _COMPARE_STATS_ORDER:
|
||
nums = [float(p[key]) for p in players]
|
||
if key in _LOWER_IS_BETTER:
|
||
best[key] = min(nums)
|
||
else:
|
||
best[key] = max(nums)
|
||
all_tied[key] = len(set(nums)) == 1
|
||
|
||
# Count how many stats each player "wins" to find the overall best
|
||
win_counts = []
|
||
for p in players:
|
||
count = 0
|
||
for key, _ in _COMPARE_STATS_ORDER:
|
||
if not all_tied[key] and float(p[key]) == best[key]:
|
||
count += 1
|
||
win_counts.append(count)
|
||
max_wins = max(win_counts)
|
||
|
||
title = " vs ".join(esc(p["nick"]) for p in players)
|
||
embed = Embed(title=title, color=Color.blue())
|
||
|
||
for i, p in enumerate(players):
|
||
star = " \u2b50" if win_counts[i] == max_wins and max_wins > 0 else ""
|
||
lines = []
|
||
for key, label_key in _COMPARE_STATS_ORDER:
|
||
val = p[key]
|
||
formatted = f"{val:,}" if isinstance(val, int) else str(val)
|
||
suffix = "%" if key == "win_rate" else ""
|
||
is_best = not all_tied[key] and float(p[key]) == best[key]
|
||
label = t(lang, label_key)
|
||
line = f"{label}: {formatted}{suffix}"
|
||
if is_best:
|
||
line = f"**{line}**"
|
||
lines.append(line)
|
||
embed.add_field(
|
||
name=f"{esc(p['nick'])} [{esc(p['squadron'])}]{star}",
|
||
value="\n".join(lines),
|
||
inline=True,
|
||
)
|
||
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
return embed
|
||
|
||
|
||
async def _generate_compare_graph(players: List[dict], lang: str = "en") -> tuple[Optional[Path], List[str]]:
|
||
"""Generate a points-over-time graph for the given players over the last 90 days.
|
||
|
||
Reads per-player points from the squadrons_points table (same source as /sq-stats).
|
||
Returns (path, missing_nicks) where missing_nicks lists players with no graph data.
|
||
"""
|
||
cutoff = int(time_module.time()) - (90 * 86400)
|
||
uid_set = {p["uid"] for p in players}
|
||
uid_to_nick = {p["uid"]: p["nick"] for p in players}
|
||
|
||
# player_points: {uid: [(unix_time, points), ...]}
|
||
player_points: dict = {p["uid"]: [] for p in players}
|
||
|
||
# --- Phase 1: fast indexed lookup via known squadron names ---
|
||
squadrons_needed: set[str] = set()
|
||
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
|
||
# Current membership: squadron_members → squadrons_data
|
||
uid_ph = ",".join("?" for _ in uid_set)
|
||
async with db.execute(
|
||
f"""
|
||
SELECT DISTINCT sd.long_name
|
||
FROM squadron_members sm
|
||
JOIN squadrons_data sd ON sm.clan_id = sd.clan_id
|
||
WHERE sm.uid IN ({uid_ph})
|
||
""",
|
||
list(uid_set),
|
||
) as cursor:
|
||
async for row in cursor:
|
||
squadrons_needed.add(row[0])
|
||
|
||
# Also include the squadron from battle history (already in player dict)
|
||
for p in players:
|
||
if p["squadron"] and p["squadron"] != "None":
|
||
squadrons_needed.add(p["squadron"])
|
||
|
||
# Fast per-squadron queries (uses idx_squadrons_points_longname_time)
|
||
for sq_name in squadrons_needed:
|
||
async with db.execute(
|
||
"""
|
||
SELECT unix_time, clan_pts
|
||
FROM squadrons_points
|
||
WHERE long_name = ? AND unix_time > ?
|
||
ORDER BY unix_time ASC
|
||
""",
|
||
(sq_name, cutoff),
|
||
) as cursor:
|
||
async for row in cursor:
|
||
try:
|
||
members_dict, _ = decompress_json(row[1])
|
||
for uid_str, pdata in members_dict.items():
|
||
if uid_str in uid_set:
|
||
player_points[uid_str].append(
|
||
(row[0], pdata["points"])
|
||
)
|
||
except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError):
|
||
continue
|
||
|
||
# --- Phase 2: lookup historical squadrons from battle records ---
|
||
missing_uids = {p["uid"] for p in players if not player_points[p["uid"]]}
|
||
if missing_uids:
|
||
# Find squadrons these players played for via sq_battles.db
|
||
extra_squads: set[str] = set()
|
||
uid_ph2 = ",".join("?" for _ in missing_uids)
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as sqdb:
|
||
async with sqdb.execute(
|
||
f"SELECT DISTINCT squadron_name FROM player_games_hist WHERE UID IN ({uid_ph2})",
|
||
list(missing_uids),
|
||
) as cursor:
|
||
async for row in cursor:
|
||
if row[0] and row[0] not in squadrons_needed:
|
||
extra_squads.add(row[0])
|
||
|
||
# Query those squadrons using the same indexed path as Phase 1
|
||
for sq_name in extra_squads:
|
||
async with db.execute(
|
||
"""
|
||
SELECT unix_time, clan_pts
|
||
FROM squadrons_points
|
||
WHERE long_name = ? AND unix_time > ?
|
||
ORDER BY unix_time ASC
|
||
""",
|
||
(sq_name, cutoff),
|
||
) as cursor:
|
||
async for row in cursor:
|
||
try:
|
||
members_dict, _ = decompress_json(row[1])
|
||
for uid_str, pdata in members_dict.items():
|
||
if uid_str in missing_uids:
|
||
player_points[uid_str].append(
|
||
(row[0], pdata["points"])
|
||
)
|
||
except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError):
|
||
continue
|
||
|
||
# Sort each player's points by timestamp (data may come from multiple squadrons)
|
||
for uid in player_points:
|
||
player_points[uid].sort(key=lambda x: x[0])
|
||
|
||
# Identify players with no data
|
||
missing_nicks = [uid_to_nick[p["uid"]] for p in players if not player_points[p["uid"]]]
|
||
|
||
# Check if we got any data at all
|
||
if not any(pts for pts in player_points.values()):
|
||
return None, missing_nicks
|
||
|
||
# Plot
|
||
fig, ax = plt.subplots(figsize=(12, 6), facecolor=SQ_STATS_GRAPH_COLORS['bg'])
|
||
ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg'])
|
||
|
||
colors = ['#12ed2f', '#ed4012', '#127eed', '#edd012', '#ed12c7', '#12edd0', '#ed8a12']
|
||
for idx, p in enumerate(players):
|
||
pts = player_points[p["uid"]]
|
||
if pts:
|
||
times = [mdates.date2num(datetime.fromtimestamp(t)) for t, _ in pts]
|
||
values = [v for _, v in pts]
|
||
color = colors[idx % len(colors)]
|
||
ax.plot(times, values, linewidth=2, color=color, label=uid_to_nick[p["uid"]])
|
||
|
||
ax.set_xlabel('Date', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
|
||
ax.set_ylabel('Points', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text'])
|
||
ax.set_title(t(lang, "compare.graph_title"), fontsize=14, fontweight='bold',
|
||
color=SQ_STATS_GRAPH_COLORS['text'])
|
||
ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid'])
|
||
ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text'])
|
||
for spine in ax.spines.values():
|
||
spine.set_color(SQ_STATS_GRAPH_COLORS['text'])
|
||
ax.legend(facecolor=SQ_STATS_GRAPH_COLORS['plot_bg'],
|
||
edgecolor=SQ_STATS_GRAPH_COLORS['grid'],
|
||
labelcolor=SQ_STATS_GRAPH_COLORS['text'])
|
||
ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
|
||
fig.autofmt_xdate()
|
||
plt.tight_layout()
|
||
|
||
temp_path = Path(f"/tmp/compare_{'_'.join(p['uid'] for p in players[:3])}_{int(time_module.time())}.png")
|
||
plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg'])
|
||
plt.close(fig)
|
||
return temp_path, missing_nicks
|
||
|
||
|
||
class CompareGraphView(View):
|
||
"""View with a graph button and per-player website link buttons."""
|
||
|
||
def __init__(self, players: List[dict], lang: str = "en"):
|
||
super().__init__(timeout=120)
|
||
self.players = players
|
||
self.lang = lang
|
||
for i, p in enumerate(players):
|
||
self.add_item(discord.ui.Button(
|
||
label=p["nick"][:80],
|
||
style=discord.ButtonStyle.link,
|
||
url=f"https://sre.pawjob.us/players/{p['uid']}",
|
||
row=0 if i < 4 else 1,
|
||
))
|
||
self.show_graph.label = t(lang, "buttons.show_graph")
|
||
|
||
@discord.ui.button(label="Show Graph", style=discord.ButtonStyle.blurple, row=2)
|
||
async def show_graph(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||
"""Fetch 90-day points graph for compared players and attach it to the message."""
|
||
await interaction.response.defer()
|
||
temp_path, missing_nicks = await _generate_compare_graph(self.players, lang=self.lang)
|
||
if not temp_path:
|
||
await interaction.followup.send(t(self.lang, "compare.no_graph_data"), ephemeral=True)
|
||
return
|
||
button.disabled = True
|
||
await interaction.edit_original_response(view=self)
|
||
if missing_nicks:
|
||
names = ", ".join(f"**{n}**" for n in missing_nicks)
|
||
await interaction.followup.send(
|
||
content=t(self.lang, "compare.no_squadron_points_data", names=names),
|
||
file=discord.File(temp_path),
|
||
)
|
||
else:
|
||
await interaction.followup.send(file=discord.File(temp_path))
|
||
try:
|
||
temp_path.unlink()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@is_blacklisted()
|
||
@bot.tree.command(name="compare", description=command_locale("Compare aggregate SQB stats between players", "commands.compare.description"))
|
||
@app_commands.describe(
|
||
player1=command_locale("First player username", "commands.compare.player1"),
|
||
player2=command_locale("Second player username", "commands.compare.player2"),
|
||
player3=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
|
||
player4=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
|
||
player5=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
|
||
player6=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
|
||
player7=command_locale("Additional player username (optional)", "commands.compare.player_optional"),
|
||
)
|
||
@discord.app_commands.autocomplete(
|
||
player1=player_autocomplete, player2=player_autocomplete,
|
||
player3=player_autocomplete, player4=player_autocomplete,
|
||
player5=player_autocomplete, player6=player_autocomplete,
|
||
player7=player_autocomplete,
|
||
)
|
||
async def compare(
|
||
interaction: discord.Interaction,
|
||
player1: str, player2: str,
|
||
player3: str = "", player4: str = "",
|
||
player5: str = "", player6: str = "",
|
||
player7: str = "",
|
||
):
|
||
"""Compare aggregate squadron battle stats between 2-7 players.
|
||
|
||
Resolves each username to a UID, fetches aggregated battle stats (kills, deaths,
|
||
win rate, etc.) from sq_battles.db, builds a side-by-side comparison embed, and
|
||
attaches a CompareGraphView with a graph button and per-player website links.
|
||
"""
|
||
await collect_command_stats(interaction)
|
||
lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
|
||
await interaction.response.defer(thinking=True)
|
||
|
||
usernames = [p for p in [player1, player2, player3, player4, player5, player6, player7] if p]
|
||
|
||
try:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
await db.create_function("ulower", 1, str.lower)
|
||
db.row_factory = aiosqlite.Row
|
||
|
||
uids, err = await _resolve_player_uids_batch(db, usernames, lang=lang)
|
||
if err or uids is None:
|
||
await interaction.followup.send(err or t(lang, "compare.could_not_resolve"), ephemeral=True)
|
||
return
|
||
|
||
player_stats = await _fetch_players_aggregate_batch(db, uids)
|
||
|
||
except Exception as e:
|
||
error_str = str(e)[:1800]
|
||
await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True)
|
||
return
|
||
|
||
# Check for any failed lookups
|
||
players = []
|
||
for i, p in enumerate(player_stats):
|
||
if not p:
|
||
await interaction.followup.send(
|
||
t(lang, "compare.could_not_fetch", name=usernames[i]), ephemeral=True)
|
||
return
|
||
players.append(p)
|
||
|
||
view = CompareGraphView(players, lang=lang)
|
||
await interaction.followup.send(embed=_build_compare_embed(players, lang=lang), view=view)
|
||
|
||
|
||
@compare.error
|
||
async def compare_perm_error(interaction, error):
|
||
await permission_fail(interaction, error)
|
||
|
||
|
||
@is_blacklisted()
|
||
@bot.tree.command(name="leaderboard", description=command_locale("Get the SRE Bot global leaderboard", "commands.leaderboard.description"))
|
||
async def leaderboard(interaction: discord.Interaction):
|
||
"""Send the SRE Bot global leaderboard URL."""
|
||
await collect_command_stats(interaction)
|
||
await interaction.response.send_message(
|
||
"https://sre.pawjob.us/leaderboard/players"
|
||
)
|
||
|
||
@leaderboard.error
|
||
async def leaderboard_perm_error(interaction, error):
|
||
await permission_fail(interaction, error)
|
||
|
||
|
||
|
||
@is_blacklisted()
|
||
@is_admin()
|
||
@bot.tree.command(name='set-squadron', description=command_locale('Set the squadron tag for this server', "commands.set_squadron.description"))
|
||
@app_commands.describe(abbreviated_name=command_locale('The short name of the squadron to set', "commands.set_squadron.abbreviated_name"))
|
||
@discord.app_commands.autocomplete(abbreviated_name=squadron_autocomplete)
|
||
async def set_squadron(interaction: discord.Interaction, abbreviated_name: str):
|
||
"""Set or swap the squadron tag for this server.
|
||
|
||
Resolves the abbreviated squadron name via resolve_clan, then either sets it
|
||
directly in SQUADRONS.json or shows a ConfirmSwapView if a different squadron
|
||
is already configured for this guild.
|
||
"""
|
||
await collect_command_stats(interaction)
|
||
await interaction.response.defer(ephemeral=True)
|
||
lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
|
||
|
||
squadrons_path = STORAGE_DIR / "SQUADRONS.json"
|
||
squadrons = await load_json(squadrons_path, {})
|
||
|
||
guild_id = str(interaction.guild_id)
|
||
|
||
# Clean input
|
||
new_short = re.sub(r'\W+', '', abbreviated_name)
|
||
|
||
# Resolve using short name only
|
||
try:
|
||
clan = await resolve_clan(short=new_short)
|
||
except Exception as e:
|
||
logging.error(f"Error resolving squadron {new_short}: {e}")
|
||
clan = None
|
||
|
||
if not clan or clan["long_name"] == "<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 target‐codes → (native name, English name)
|
||
LANGUAGE_OPTIONS = {
|
||
"RU": ("Русский", "Russian"),
|
||
#"EN-US": ("English (US)", "English (US)"),
|
||
"EN-GB": ("English", "English"),
|
||
"UK": ("Українська", "Ukrainian"),
|
||
"ES": ("Español", "Spanish"),
|
||
"FR": ("Français", "French"),
|
||
"DE": ("Deutsch", "German"),
|
||
"ZH-HANS": ("简体中文", "Chinese"),
|
||
"JA": ("日本語", "Japanese"),
|
||
"KO": ("한국어", "Korean"),
|
||
"IT": ("Italiano", "Italian"),
|
||
"PT-PT": ("Português", "Portuguese"),
|
||
#"PT-BR": ("Português (Brasil)","Portuguese (Brazil)"),
|
||
"PL": ("Polski", "Polish"),
|
||
"LT": ("Lietuvių", "Lithuanian"),
|
||
"LV": ("Latviešu", "Latvian"),
|
||
"ET": ("Eesti", "Estonian"),
|
||
#"DA": ("Dansk", "Danish"),
|
||
"FI": ("Suomi", "Finnish"),
|
||
#"ID": ("Bahasa Indonesia", "Indonesian"),
|
||
#"NB": ("Norsk bokmål", "Norwegian"),
|
||
"NL": ("Nederlands", "Dutch"),
|
||
"SV": ("Svenska", "Swedish"),
|
||
"CS": ("Čeština", "Czech"),
|
||
#"SK": ("Slovenčina", "Slovak"),
|
||
#"SL": ("Slovenščina", "Slovenian"),
|
||
"RO": ("Română", "Romanian"),
|
||
"BG": ("Български", "Bulgarian"),
|
||
"EL": ("Ελληνικά", "Greek"),
|
||
"HU": ("Magyar", "Hungarian"),
|
||
"AR": ("العربية", "Arabic"),
|
||
"TR": ("Türkçe", "Turkish"),
|
||
}
|
||
|
||
DEEPL_API_KEY = os.environ.get("DEEPL_KEY")
|
||
translator = deepl.Translator(DEEPL_API_KEY) if DEEPL_API_KEY else None
|
||
|
||
def perform_translation(text: str, target_language: str) -> str:
|
||
"""Translate text to the target language using the DeepL API.
|
||
|
||
Args:
|
||
text: The text to translate.
|
||
target_language: DeepL target language code (e.g. "DE", "FR").
|
||
|
||
Returns:
|
||
Translated text, or an error message if translation fails.
|
||
"""
|
||
if not translator:
|
||
return "Translation unavailable (DeepL not configured)"
|
||
try:
|
||
result = translator.translate_text(text, target_lang=target_language.upper())
|
||
return result.text # type: ignore
|
||
except Exception as e:
|
||
logging.error(f"Translation failed: {e}")
|
||
return "Translation failed"
|
||
|
||
# ── UI Components ─────────────────────────────────────────────────────────────
|
||
|
||
class LanguageSelect(discord.ui.Select):
|
||
"""Dropdown for selecting a target language to translate a message via DeepL."""
|
||
|
||
def __init__(self, original_message: discord.Message, lang: str = "en"):
|
||
options = [
|
||
discord.SelectOption(
|
||
label=f"{native} ({english})",
|
||
value=code
|
||
)
|
||
for code, (native, english) in LANGUAGE_OPTIONS.items()
|
||
]
|
||
super().__init__(
|
||
placeholder=t(lang, "language.translate_placeholder"),
|
||
min_values=1, max_values=1,
|
||
options=options
|
||
)
|
||
self._msg = original_message
|
||
|
||
async def callback(self, interaction: discord.Interaction):
|
||
target = self.values[0]
|
||
text = self._msg.content
|
||
translated = await asyncio.to_thread(perform_translation, text, target)
|
||
await interaction.response.send_message(
|
||
f"**{self._msg.author.display_name} → {LANGUAGE_OPTIONS[target][1]}:**\n{translated}",
|
||
ephemeral=True
|
||
)
|
||
self.view.stop() # type: ignore
|
||
|
||
|
||
class LanguageView(discord.ui.View):
|
||
"""View wrapper containing the LanguageSelect dropdown for the Translate Message context menu."""
|
||
|
||
def __init__(self, message: discord.Message, lang: str = "en"):
|
||
super().__init__(timeout=1200)
|
||
self.add_item(LanguageSelect(message, lang=lang))
|
||
|
||
|
||
# ── Replace your TranslatorCog/slash-command with this context menu ─────────
|
||
|
||
@bot.tree.context_menu(name=command_locale("Translate Message", "commands.translate_message.name"))
|
||
async def translate_message(
|
||
interaction: discord.Interaction,
|
||
message: discord.Message
|
||
):
|
||
"""Right-click any message → Apps → Translate Message."""
|
||
lang = await guild_lang(interaction.guild.id) if interaction.guild else "en"
|
||
view = LanguageView(message, lang=lang)
|
||
await interaction.response.send_message(
|
||
t(lang, "language.translate_prompt"),
|
||
view=view,
|
||
ephemeral=True
|
||
)
|
||
|
||
|
||
@is_blacklisted()
|
||
@gate_entitle("standard")
|
||
@bot.tree.command(name="sq-track", description=command_locale("Track a squadron and compare stats against the last check", "commands.sq_track.description"))
|
||
@app_commands.describe(
|
||
squadron_short_name=command_locale("Short name of the squadron to track", "commands.sq_track.squadron_short_name")
|
||
)
|
||
@discord.app_commands.autocomplete(squadron_short_name=squadron_autocomplete)
|
||
async def track_squadron(
|
||
interaction: discord.Interaction,
|
||
squadron_short_name: str
|
||
):
|
||
"""Track a squadron's stats and show deltas since the last check.
|
||
|
||
Fetches current squadron stats (rating, kills, wins, K/D, etc.), loads
|
||
the previous snapshot from disk, computes and displays diffs for each stat,
|
||
then saves the current snapshot for the next comparison.
|
||
"""
|
||
await collect_command_stats(interaction)
|
||
await interaction.response.defer()
|
||
logging.info("Running /sq-track")
|
||
lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
|
||
|
||
clan = await resolve_clan(short=squadron_short_name.lower())
|
||
if not clan or clan["long_name"] == "<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 + 86399
|
||
is_current = start_ts <= now <= end_ts + 86399
|
||
|
||
if is_past:
|
||
lines.append(f"> ~~**{br_str} ({date_str})**~~")
|
||
elif is_current:
|
||
lines.append("")
|
||
lines.append(f"> > **{br_str} ({date_str})** <")
|
||
lines.append("")
|
||
else:
|
||
lines.append(f"> **{br_str} ({date_str})**")
|
||
|
||
description = "\n".join(lines) + f"\n\n{_schedule_footer(lang)}"
|
||
|
||
embed = discord.Embed(
|
||
title=t(lang, "misc.schedule_title"),
|
||
description=description,
|
||
color=discord.Color.gold()
|
||
)
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
|
||
await interaction.followup.send(embed=embed)
|
||
|
||
|
||
@schedule_cmd.error
|
||
async def schedule_error(interaction, error):
|
||
await permission_fail(interaction, error)
|
||
|
||
|
||
# ── /news config ─────────────────────────────────────────────────────────────
|
||
_NEWS_JSON = Path(__file__).parent / "NEWS.json"
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@is_blacklisted()
|
||
@bot.tree.command(name="news", description=command_locale("View the latest SRE Bot news and announcements", "commands.news.description"))
|
||
async def news_cmd(interaction: discord.Interaction):
|
||
"""Load NEWS.json and display up to 10 news entries as embeds."""
|
||
await collect_command_stats(interaction)
|
||
await interaction.response.defer()
|
||
lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
|
||
|
||
if not _NEWS_JSON.exists():
|
||
await interaction.followup.send(
|
||
embed=discord.Embed(
|
||
title=t(lang, "misc.news_no_news_title"),
|
||
description=t(lang, "misc.news_no_news_desc"),
|
||
color=discord.Color.blurple()
|
||
),
|
||
ephemeral=True
|
||
)
|
||
return
|
||
|
||
all_entries = await load_json(_NEWS_JSON, [])
|
||
|
||
# Filter out expired entries
|
||
now_ts = int(time_module.time())
|
||
entries = [e for e in all_entries if now_ts < e.get("expires", float("inf"))]
|
||
|
||
if not entries:
|
||
await interaction.followup.send(
|
||
embed=discord.Embed(
|
||
title=t(lang, "misc.news_no_news_title"),
|
||
description=t(lang, "misc.news_no_news_desc"),
|
||
color=discord.Color.blurple()
|
||
),
|
||
ephemeral=True
|
||
)
|
||
return
|
||
|
||
embeds = []
|
||
for i, item in enumerate(entries[:10]): # Discord max 10 embeds per message
|
||
embed = discord.Embed(
|
||
title=item.get("title", "Announcement"),
|
||
description=item.get("body", ""),
|
||
color=discord.Color.blurple()
|
||
)
|
||
if i == len(entries) - 1 or i == 9:
|
||
embed.set_footer(text=t(lang, "misc.news_footer"))
|
||
embeds.append(embed)
|
||
|
||
await interaction.followup.send(embeds=embeds)
|
||
|
||
|
||
@news_cmd.error
|
||
async def news_error(interaction, error):
|
||
await permission_fail(interaction, error)
|
||
|
||
|
||
|
||
@is_blacklisted()
|
||
@bot.tree.command(name="help", description=command_locale("View the guide, ToS, and support links", "commands.help.description"))
|
||
async def help(interaction: discord.Interaction):
|
||
"""Display the full 29-command guide with documentation and support links."""
|
||
await collect_command_stats(interaction)
|
||
support_server = "https://discord.gg/BCvkK8JhPe"
|
||
documentation_link = "https://sre.pawjob.us/docs"
|
||
tos_link = "https://sre.pawjob.us/terms"
|
||
lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
|
||
|
||
command_rows = [
|
||
("/setup", "commands.setup.description"),
|
||
("/quick-log", "commands.quick_log.description"),
|
||
("/comp", "commands.comp.description"),
|
||
("/player-stats", "commands.player_stats.description"),
|
||
("/view-player-games", "commands.view_player_games.description"),
|
||
("/view-match", "commands.view_match.description"),
|
||
("/compare", "commands.compare.description"),
|
||
("/leaderboard", "commands.leaderboard.description"),
|
||
("/top", "commands.top.description"),
|
||
("/set-squadron", "commands.set_squadron.description"),
|
||
("/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"),
|
||
("/loss-calculator", "commands.loss_calculator.description"),
|
||
("/autolog-management", "commands.autolog_management.description"),
|
||
("/diagnose-perms", "commands.diagnose_perms.description"),
|
||
("/language", "commands.language.description"),
|
||
("/credits", "commands.credits.description"),
|
||
("/meta-management", "commands.meta_management.description"),
|
||
("/meta", "commands.meta.description"),
|
||
("/recent", "commands.recent.description"),
|
||
("/vs", "commands.vs.description"),
|
||
("/schedule", "commands.schedule.description"),
|
||
("/website", "commands.website.description"),
|
||
("/unlock", "commands.unlock.description"),
|
||
("Translate Message", "commands.translate_message.name"),
|
||
("/stack-create", "commands.stack_create.description"),
|
||
("/stack-manage", "commands.stack_manage.description"),
|
||
("/news", "commands.news.description"),
|
||
("/donate", "commands.donate.description"),
|
||
("/analytics", "commands.analytics.description"),
|
||
("/sq-card", "commands.sq_card.description"),
|
||
("/card", "commands.card.description"),
|
||
("/bot-status", "commands.bot_status.description"),
|
||
]
|
||
guide_lines = [t(lang, "misc.help_commands_header")]
|
||
guide_lines.extend(
|
||
f"{idx}. **{name}** - {t(lang, key)}"
|
||
for idx, (name, key) in enumerate(command_rows, start=1)
|
||
)
|
||
guide_lines.append("")
|
||
guide_lines.append(t(lang, "misc.help_links", docs=documentation_link, support=support_server))
|
||
guide_lines.append(t(lang, "misc.help_terms", terms=tos_link))
|
||
guide_text = "\n".join(guide_lines)
|
||
|
||
embed = discord.Embed(title=t(lang, "misc.help_title"), description=guide_text, color=discord.Color.blue())
|
||
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:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db:
|
||
rows = list(await db.execute_fetchall(
|
||
"SELECT 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 30"
|
||
))
|
||
if rows:
|
||
delays = [max(int(r[1]) - int(r[0]), 0) for r in rows]
|
||
avg_delay = int(sum(delays) / len(delays))
|
||
sample_size = len(delays)
|
||
last_received_ts = max(int(r[1]) for r in rows)
|
||
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:
|
||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db:
|
||
ttl_rows = list(await db.execute_fetchall(
|
||
"SELECT 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 30"
|
||
))
|
||
if ttl_rows:
|
||
delays = [max(int(r[1]) - int(r[0]), 0) for r in ttl_rows]
|
||
avg = sum(delays) / len(delays)
|
||
a_min, a_sec = divmod(int(avg), 60)
|
||
mn_min, mn_sec = divmod(min(delays), 60)
|
||
mx_min, mx_sec = divmod(max(delays), 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:** {len(delays)}"
|
||
)
|
||
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)
|