"""TSSBOT slash commands. TSSBOT's first slash commands, mirroring SREBOT's resolve + autocomplete UX: * ``/set-team`` — set this server's team (writes TEAMS.json) * ``/log-team`` — subscribe a team's matches to this channel (autolog) * ``/log-player`` — subscribe a player's matches to this channel (autolog) * ``/set-player`` — link your Discord account to a WT player (shared PLAYERS.json) * ``/player-stats`` — TSS career stats for a player (defaults to your linked account) Config commands (set/log) require Manage Server. All commands honor the shared discord-user blacklist. """ from __future__ import annotations import json import logging import time from pathlib import Path from typing import List import discord from discord import app_commands from discord.ext import commands from . import preferences, storage from shared_store import check_user_blacklist, get_linked_uid, save_player_link log = logging.getLogger("tssbot.commands") FOOTER = "ᓚᘏᗢ" _NEWS_JSON = Path(__file__).resolve().parent / "NEWS.json" # --------------------------------------------------------------------------- # Checks # --------------------------------------------------------------------------- def not_blacklisted(): """App-command check rejecting blacklisted Discord users (shared BLACKLIST.json).""" async def predicate(interaction: discord.Interaction) -> bool: blocked, reason = check_user_blacklist(interaction.user.id) if blocked: raise app_commands.CheckFailure(reason or "You are blacklisted from using this bot.") return True return app_commands.check(predicate) # --------------------------------------------------------------------------- # Autocomplete # --------------------------------------------------------------------------- async def team_autocomplete(interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: try: rows = await storage.search_teams(current, 25) except Exception: return [] choices = [] for r in rows: label = (r.get("long_name") or str(r["team_id"]))[:100] choices.append(app_commands.Choice(name=label, value=str(r["team_id"]))) return choices async def player_autocomplete(interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]: try: rows = await storage.search_players(current, 25) except Exception: return [] return [app_commands.Choice(name=r["nick"][:100], value=r["nick"][:100]) for r in rows] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _too_many_msg(candidates: List[dict]) -> str: preview = ", ".join(f"`{c['nick']}`" for c in candidates[:10]) return ( "Multiple players match — pick a more specific name (use the autocomplete):\n" f"{preview}" ) # --------------------------------------------------------------------------- # Cog # --------------------------------------------------------------------------- class TssCommands(commands.Cog): """Container for TSSBOT's application commands.""" def __init__(self, bot: commands.Bot): self.bot = bot async def cog_app_command_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError): if isinstance(error, app_commands.CheckFailure): msg = str(error) or "You can't use this command." if interaction.response.is_done(): await interaction.followup.send(msg, ephemeral=True) else: await interaction.response.send_message(msg, ephemeral=True) return log.error("app command error: %s", error) try: if interaction.response.is_done(): await interaction.followup.send(f"Something went wrong: {error}", ephemeral=True) else: await interaction.response.send_message(f"Something went wrong: {error}", ephemeral=True) except discord.HTTPException: pass # ── /set-team ────────────────────────────────────────────────────────── @app_commands.command(name="set-team", description="Set this server's team") @app_commands.describe(team="TSS team name or ID") @app_commands.autocomplete(team=team_autocomplete) @app_commands.checks.has_permissions(manage_guild=True) @not_blacklisted() async def set_team(self, interaction: discord.Interaction, team: str): await interaction.response.defer(ephemeral=True) if interaction.guild_id is None: return await interaction.followup.send("This command must be used in a server.", ephemeral=True) resolved = await storage.resolve_team(team) if not resolved: return await interaction.followup.send(f"Could not find a team matching **{team}**.", ephemeral=True) name = resolved.get("long_name") or str(resolved["team_id"]) preferences.set_guild_team(interaction.guild_id, int(resolved["team_id"]), name) await interaction.followup.send( f"✅ This server's team is now **{name}** (id `{resolved['team_id']}`).", ephemeral=True ) # ── /log-team ────────────────────────────────────────────────────────── @app_commands.command(name="log-team", description="Send a team's matches to this channel") @app_commands.describe(team="TSS team name or ID") @app_commands.autocomplete(team=team_autocomplete) @app_commands.checks.has_permissions(manage_guild=True) @not_blacklisted() async def log_team(self, interaction: discord.Interaction, team: str): await interaction.response.defer(ephemeral=True) if interaction.guild_id is None or interaction.channel_id is None: return await interaction.followup.send("This command must be used in a server channel.", ephemeral=True) resolved = await storage.resolve_team(team) if not resolved: return await interaction.followup.send(f"Could not find a team matching **{team}**.", ephemeral=True) name = resolved.get("long_name") or str(resolved["team_id"]) preferences.upsert_log_entry( interaction.guild_id, int(resolved["team_id"]), "tss-team", name, interaction.channel_id ) await interaction.followup.send( f"✅ Logging **{name}**'s matches to <#{interaction.channel_id}>.", ephemeral=True ) # ── /set-player ──────────────────────────────────────────────────────── @app_commands.command(name="set-player", description="Link your Discord account to a War Thunder player") @app_commands.describe(player="Player username") @app_commands.autocomplete(player=player_autocomplete) @not_blacklisted() async def set_player(self, interaction: discord.Interaction, player: str): await interaction.response.defer(ephemeral=True) candidates = await storage.resolve_players(player) if not candidates: return await interaction.followup.send(f"No players found matching **{player}**.", ephemeral=True) if len(candidates) > 1: return await interaction.followup.send(_too_many_msg(candidates), ephemeral=True) uid, nick = str(candidates[0]["uid"]), candidates[0]["nick"] save_player_link(interaction.user.id, uid) await interaction.followup.send( f"✅ Linked your Discord account to **{nick}** (UID `{uid}`).\n" "`/player-stats` with no argument will now default to this account.", ephemeral=True, ) # ── /player-stats ────────────────────────────────────────────────────── @app_commands.command(name="player-stats", description="View TSS career stats for a player") @app_commands.describe(player="Player username (defaults to your linked account)") @app_commands.autocomplete(player=player_autocomplete) @not_blacklisted() async def player_stats(self, interaction: discord.Interaction, player: str = ""): await interaction.response.defer() if player: candidates = await storage.resolve_players(player) if not candidates: return await interaction.followup.send(f"No players found matching **{player}**.", ephemeral=True) if len(candidates) > 1: return await interaction.followup.send(_too_many_msg(candidates), ephemeral=True) uid = str(candidates[0]["uid"]) else: uid = get_linked_uid(interaction.user.id) or "" if not uid: return await interaction.followup.send( "Provide a player, or link your account first with `/set-player`.", ephemeral=True ) career = await storage.player_career(uid) if not career: return await interaction.followup.send("No TSS games on record for that player.", ephemeral=True) teams = await storage.player_teams(uid) battles = career.get("battles") or 0 wins = career.get("wins") or 0 losses = career.get("losses") or 0 win_rate = f"{(wins / battles * 100):.1f}%" if battles else "0.0%" deaths = career.get("deaths") or 0 kills = (career.get("ground_kills") or 0) + (career.get("air_kills") or 0) kd = f"{(kills / deaths):.2f}" if deaths else f"{kills}" embed = discord.Embed( title=f"{career['nick']} — TSS career", color=discord.Color.orange(), ) embed.add_field(name="Battles", value=str(battles), inline=True) embed.add_field(name="Win rate", value=f"{win_rate} ({wins}W / {losses}L)", inline=True) embed.add_field(name="K/D", value=f"{kd} ({kills} kills / {deaths} deaths)", inline=True) embed.add_field( name="Ground / Air kills", value=f"{career.get('ground_kills') or 0} / {career.get('air_kills') or 0}", inline=True, ) embed.add_field(name="Assists", value=str(career.get("assists") or 0), inline=True) embed.add_field(name="Captures", value=str(career.get("captures") or 0), inline=True) if teams: top = teams[:5] embed.add_field( name="Teams seen with", value="\n".join( f"`{(tm.get('team_name') or '?')}` — {tm.get('games') or 0} games" for tm in top ), inline=False, ) embed.set_footer(text=FOOTER) await interaction.followup.send(embed=embed) # ── /news ────────────────────────────────────────────────────────────── @app_commands.command(name="news", description="View the latest TSSBOT news and announcements") @not_blacklisted() async def news(self, interaction: discord.Interaction): await interaction.response.defer() all_entries = [] if _NEWS_JSON.exists(): try: with open(_NEWS_JSON, "r", encoding="utf-8") as f: all_entries = json.load(f) except (json.JSONDecodeError, OSError): all_entries = [] now_ts = int(time.time()) entries = [ e for e in all_entries if isinstance(e, dict) and now_ts < e.get("expires", float("inf")) ] if not entries: await interaction.followup.send( embed=discord.Embed( title="No news", description="There are no announcements right now.", color=discord.Color.orange(), ), ephemeral=True, ) return embeds = [ discord.Embed( title=item.get("title", "Announcement"), description=item.get("body", ""), color=discord.Color.orange(), ) for item in entries[:10] ] embeds[-1].set_footer(text=FOOTER) await interaction.followup.send(embeds=embeds) # ── /help ────────────────────────────────────────────────────────────── @app_commands.command(name="help", description="List TSSBOT commands and what they do") @not_blacklisted() async def help_cmd(self, interaction: discord.Interaction): embed = discord.Embed( title="TSSBOT commands", description="Live TSS team & player stats, plus match autologging.", color=discord.Color.orange(), ) embed.add_field( name="Stats", value=( "`/player-stats [player]` — TSS career stats for a player " "(defaults to your linked account)\n" "`/set-player ` — link your Discord account to a WT player" ), inline=False, ) embed.add_field( name="Autologging — Manage Server", value=( "`/set-team ` — set this server's team\n" "`/log-team ` — post a team's matches to this channel" ), inline=False, ) embed.add_field( name="Info", value="`/news` — latest announcements\n`/help` — this message", inline=False, ) embed.set_footer(text=FOOTER) await interaction.response.send_message(embed=embed) async def setup_commands(bot: commands.Bot) -> None: """Register the TSS command cog on the bot.""" await bot.add_cog(TssCommands(bot))