lets get this party starteddddd (#1287)
This commit is contained in:
+330
@@ -0,0 +1,330 @@
|
||||
"""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("tag_name") or 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="Team name, tag, 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("tag_name") or 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="Team name, tag, 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("tag_name") or 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
|
||||
)
|
||||
|
||||
# ── /log-player ────────────────────────────────────────────────────────
|
||||
@app_commands.command(name="log-player", description="Send a player's matches to this channel")
|
||||
@app_commands.describe(player="Player username")
|
||||
@app_commands.autocomplete(player=player_autocomplete)
|
||||
@app_commands.checks.has_permissions(manage_guild=True)
|
||||
@not_blacklisted()
|
||||
async def log_player(self, interaction: discord.Interaction, player: 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)
|
||||
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"]
|
||||
preferences.upsert_log_entry(interaction.guild_id, uid, "tss-player", nick, interaction.channel_id)
|
||||
await interaction.followup.send(
|
||||
f"✅ Logging **{nick}**'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_tag') 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 <player>` — link your Discord account to a WT player"
|
||||
),
|
||||
inline=False,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Autologging — Manage Server",
|
||||
value=(
|
||||
"`/set-team <team>` — set this server's team\n"
|
||||
"`/log-team <team>` — post a team's matches to this channel\n"
|
||||
"`/log-player <player>` — post a player'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))
|
||||
Reference in New Issue
Block a user