lets get this party starteddddd (#1287)

This commit is contained in:
NotSoToothless
2026-05-30 08:45:32 -07:00
committed by GitHub
parent 8396f48f90
commit 54c06bd275
4 changed files with 243 additions and 70 deletions
+4 -3
View File
@@ -28,6 +28,7 @@ import discord
# Local Module Imports # Local Module Imports
from . import utils from . import utils
from data_parser import LangTableReader from data_parser import LangTableReader
from shared_store import blacklisted_squadrons
from .game_api import get_point_diff from .game_api import get_point_diff
from .render_replay import load_gob_file, render_gob from .render_replay import load_gob_file, render_gob
from .health import record_game_processed, record_ws_message from .health import record_game_processed, record_ws_message
@@ -42,7 +43,6 @@ from .utils import (
SQ_BATTLES_DB_PATH, SQ_BATTLES_DB_PATH,
SQUADRONS_DB_PATH, SQUADRONS_DB_PATH,
BLACKLISTED_SERVER_IDS, BLACKLISTED_SERVER_IDS,
BLACKLISTED_SQUADRONS,
DEFAULT_FOOTER_CAT, DEFAULT_FOOTER_CAT,
compress_json, compress_json,
decompress_json, decompress_json,
@@ -50,6 +50,7 @@ from .utils import (
norm, norm,
resolve_clans, resolve_clans,
resolve_pref_key, resolve_pref_key,
is_foreign_pref_entry,
load_features, load_features,
remove_guild_pref_notification, remove_guild_pref_notification,
PREMIUM_ACTIVATION_TS, PREMIUM_ACTIVATION_TS,
@@ -653,7 +654,7 @@ async def build_hex_plus_guild(
# under all of its known names so it can be found regardless of which # under all of its known names so it can be found regardless of which
# form ends up in the replay JSON. # form ends up in the replay JSON.
for key, cfg in prefs.items(): for key, cfg in prefs.items():
if not isinstance(cfg, dict): if not isinstance(cfg, dict) or is_foreign_pref_entry(cfg):
continue continue
chan = cfg.get("Logs") chan = cfg.get("Logs")
if not chan or "DISABLED" in str(chan).upper(): if not chan or "DISABLED" in str(chan).upper():
@@ -692,7 +693,7 @@ async def build_hex_plus_guild(
# PHASE 2: Match games against pre-built lookup tables # PHASE 2: Match games against pre-built lookup tables
blacklisted_squad_norms = {norm(bl) for bl in BLACKLISTED_SQUADRONS} blacklisted_squad_norms = {norm(bl) for bl in blacklisted_squadrons()}
for g in games: for g in games:
sid = g.get("sessionIdHex", "") sid = g.get("sessionIdHex", "")
+174 -5
View File
@@ -48,6 +48,7 @@ from .analytics import (
) )
from .autologging import init_players_db, load_replay_data_from_disk, build_scoreboard_view 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 data_parser import LangTableReader, count_unit_types, get_unit_type_abbrev, normalize_name
from shared_store import get_linked_uid, save_player_link
from .game_api import ( from .game_api import (
ClanInfoError, ClanInfoError,
obtain_clan_info_api, obtain_clan_info_api,
@@ -1140,6 +1141,8 @@ async def _diagnose_perms_logic(interaction: discord.Interaction, target_channel
else: else:
has_any_logs = False has_any_logs = False
for key, cfg in prefs.items(): for key, cfg in prefs.items():
if utils.is_foreign_pref_entry(cfg):
continue
logs_chan = cfg.get("Logs", "") logs_chan = cfg.get("Logs", "")
if not logs_chan or "DISABLED" in str(logs_chan).upper(): if not logs_chan or "DISABLED" in str(logs_chan).upper():
continue continue
@@ -1227,7 +1230,7 @@ def _configured_pref_targets(preferences: dict[str, Any]) -> list[dict[str, Any]
targets: list[dict[str, Any]] = [] targets: list[dict[str, Any]] = []
seen: set[tuple[str, str, int]] = set() seen: set[tuple[str, str, int]] = set()
for squadron, settings in preferences.items(): for squadron, settings in preferences.items():
if not isinstance(settings, dict): if not isinstance(settings, dict) or utils.is_foreign_pref_entry(settings):
continue continue
pref_key = str(squadron) pref_key = str(squadron)
display_name = _format_pref_target_name(pref_key, settings) display_name = _format_pref_target_name(pref_key, settings)
@@ -1288,7 +1291,7 @@ def _management_pref_rows(
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for squadron, settings in preferences.items(): for squadron, settings in preferences.items():
if not isinstance(settings, dict): if not isinstance(settings, dict) or utils.is_foreign_pref_entry(settings):
continue continue
for storage_type in _notif_types_for_management(notif_type): for storage_type in _notif_types_for_management(notif_type):
if storage_type in settings: if storage_type in settings:
@@ -3477,7 +3480,7 @@ class CardPlayerSelectView(View):
async def card( async def card(
interaction: discord.Interaction, interaction: discord.Interaction,
season: str, season: str,
player: str, player: str = "",
theme: app_commands.Choice[str] | None = None, theme: app_commands.Choice[str] | None = None,
): ):
"""Generate and send a season recap card PNG for a player. """Generate and send a season recap card PNG for a player.
@@ -3505,6 +3508,18 @@ async def card(
embed.set_footer(text=DEFAULT_FOOTER_CAT) embed.set_footer(text=DEFAULT_FOOTER_CAT)
return await interaction.followup.send(embed=embed, ephemeral=True) return await interaction.followup.send(embed=embed, ephemeral=True)
# No player given — fall back to the caller's linked account, if any.
if not player:
linked_uid = get_linked_uid(interaction.user.id)
if not linked_uid:
return await interaction.followup.send(
t(lang, "player.must_provide_or_link"), ephemeral=True
)
nick = await _latest_nick_for_uid(linked_uid)
return await _send_player_card(
interaction, int(linked_uid), nick, season, theme_value, lang, followup=True,
)
try: try:
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
@@ -3960,11 +3975,17 @@ async def player_stats(interaction: discord.Interaction, username: str = "", uid
await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True) await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True)
return return
else: else:
# No explicit input — fall back to the caller's linked account, if any.
linked_uid = get_linked_uid(interaction.user.id)
if not linked_uid:
await interaction.response.send_message( await interaction.response.send_message(
t(lang, "player.must_provide_input"), t(lang, "player.must_provide_or_link"),
ephemeral=True ephemeral=True
) )
return return
await interaction.response.defer(thinking=True)
uid = linked_uid
target_uid = linked_uid
# UID path: fetch vehicle stats (username path already has them from above). # UID path: fetch vehicle stats (username path already has them from above).
if uid: if uid:
@@ -4039,6 +4060,144 @@ async def player_stats_perm_error(interaction, error):
await permission_fail(interaction, error) await permission_fail(interaction, error)
async def _resolve_player_for_link(username: str) -> list:
"""Return [{UID, nick}] rows matching a username (exact, then substring)."""
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT UID, MIN(nick) AS nick FROM player_games_hist "
"WHERE nick = ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25",
(username,),
) as cursor:
results = list(await cursor.fetchall())
if not results:
async with db.execute(
"SELECT UID, MIN(nick) AS nick FROM player_games_hist "
"WHERE nick LIKE ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25",
(f"%{username}%",),
) as cursor:
results = list(await cursor.fetchall())
return results
async def _latest_nick_for_uid(uid: str) -> str:
"""Best-effort latest nick for a UID; falls back to the UID string."""
try:
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT nick FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1",
(uid,),
) as cursor:
row = await cursor.fetchone()
if row:
return row["nick"]
except Exception:
pass
return uid
class SetPlayerSelectView(View):
"""Select which matched player to link to the invoking Discord user."""
def __init__(self, results, author: discord.abc.User, lang: str = "en"):
super().__init__(timeout=30)
self.author = author
self.lang = lang
options = [
discord.SelectOption(
label=row["nick"][:100],
description=f"UID: {row['UID']}"[:100],
value=str(row["UID"])[:100],
)
for row in results[:25]
]
self.select = Select(
placeholder=t(lang, "player.select_player_placeholder"),
options=options,
min_values=1,
max_values=1,
)
self.select.callback = self.select_callback
self.add_item(self.select)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
return interaction.user.id == self.author.id
async def select_callback(self, interaction: discord.Interaction):
uid = self.select.values[0]
nick = await _latest_nick_for_uid(uid)
save_player_link(interaction.user.id, uid)
await interaction.response.edit_message(
content=t(self.lang, "player.link_success", nick=esc(nick), uid=uid),
view=None,
)
@is_blacklisted()
@bot.tree.command(
name="set-player",
description=command_locale("Link your Discord account to a War Thunder player", "commands.set_player.description"),
)
@app_commands.describe(
username=command_locale("The WT username to link", "commands.set_player.username"),
uid=command_locale("The WT UID to link", "commands.set_player.uid"),
)
@discord.app_commands.autocomplete(username=player_autocomplete)
async def set_player(interaction: discord.Interaction, username: str = "", uid: str = ""):
"""Link the invoking Discord user to a WT account in the shared PLAYERS.json.
Once linked, commands like /player-stats default to this account when run
without an explicit username/uid.
"""
await collect_command_stats(interaction)
lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
if uid:
await interaction.response.defer(thinking=True, ephemeral=True)
nick = await _latest_nick_for_uid(uid)
save_player_link(interaction.user.id, uid)
await interaction.followup.send(
t(lang, "player.link_success", nick=esc(nick), uid=uid), ephemeral=True
)
return
if username:
await interaction.response.defer(thinking=True, ephemeral=True)
try:
results = await _resolve_player_for_link(username)
except Exception as e:
error_str = str(e)[:1800]
await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True)
return
if not results:
await interaction.followup.send(
t(lang, "player.no_players_found", username=username), ephemeral=True
)
return
if len(results) > 1:
await interaction.followup.send(
t(lang, "player.link_select"),
view=SetPlayerSelectView(results, interaction.user, lang=lang),
ephemeral=True,
)
return
target_uid = str(results[0]["UID"])
save_player_link(interaction.user.id, target_uid)
await interaction.followup.send(
t(lang, "player.link_success", nick=esc(results[0]["nick"]), uid=target_uid),
ephemeral=True,
)
return
await interaction.response.send_message(t(lang, "player.must_provide_input"), ephemeral=True)
@set_player.error
async def set_player_perm_error(interaction, error):
await permission_fail(interaction, error)
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# /view-player-games — Last 20 games for a player # /view-player-games — Last 20 games for a player
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
@@ -4112,7 +4271,7 @@ class FindPlayerView(discord.ui.View):
) )
@app_commands.describe(player=command_locale("The player's username", "commands.common.player_username")) @app_commands.describe(player=command_locale("The player's username", "commands.common.player_username"))
@discord.app_commands.autocomplete(player=player_autocomplete) @discord.app_commands.autocomplete(player=player_autocomplete)
async def view_player_games(interaction: discord.Interaction, player: str): async def view_player_games(interaction: discord.Interaction, player: str = ""):
"""Display a player's recent squadron battle sessions with win/loss, comps, and opponents. """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 Resolves the player nickname to a UID, queries sessions from the last 8 hours
@@ -4127,6 +4286,16 @@ async def view_player_games(interaction: discord.Interaction, player: str):
lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en'
await interaction.response.defer(ephemeral=False) await interaction.response.defer(ephemeral=False)
if not player:
# Fall back to the caller's linked account, if any.
linked_uid = get_linked_uid(interaction.user.id)
if not linked_uid:
return await interaction.followup.send(
t(lang, "player.must_provide_or_link"), ephemeral=True
)
target_uid = linked_uid
player_nick = esc(await _latest_nick_for_uid(linked_uid))
else:
# Resolve player nick → most-recently-seen UID # Resolve player nick → most-recently-seen UID
try: try:
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
+9 -1
View File
@@ -244,7 +244,10 @@
"not_found_desc": "No game history found for `{player}`.", "not_found_desc": "No game history found for `{player}`.",
"no_players_found": "No players found matching **{username}**\nTry using `/website` to search on the website.", "no_players_found": "No players found matching **{username}**\nTry using `/website` to search on the website.",
"multiple_matches": "Multiple matches found, choose the correct one below:", "multiple_matches": "Multiple matches found, choose the correct one below:",
"must_provide_input": "You must provide at least a UID or username." "must_provide_input": "You must provide at least a UID or username.",
"must_provide_or_link": "You must provide a UID or username, or link your account with `/set-player` first.",
"link_select": "Multiple players match — select which account to link to your Discord:",
"link_success": "✅ Linked your Discord account to **{nick}** (UID `{uid}`).\nCommands like `/player-stats` will now default to this account."
}, },
"player_games": { "player_games": {
"no_recent_title": "No Recent Games", "no_recent_title": "No Recent Games",
@@ -759,6 +762,11 @@
"description": "Set the squadron tag for this server", "description": "Set the squadron tag for this server",
"abbreviated_name": "The short name of the squadron to set" "abbreviated_name": "The short name of the squadron to set"
}, },
"set_player": {
"description": "Link your Discord account to a War Thunder player",
"username": "The WT username to link",
"uid": "The WT UID to link"
},
"setup": { "setup": {
"description": "Set up the bot for this server" "description": "Set up the bot for this server"
}, },
+22 -27
View File
@@ -34,6 +34,7 @@ from wcwidth import wcswidth
# BOT/__init__.py has already put BOTS/SHARED on sys.path; re-export it # BOT/__init__.py has already put BOTS/SHARED on sys.path; re-export it
# under a public name so peer modules can use it for asset paths. # under a public name so peer modules can use it for asset paths.
from . import SHARED_DIR # noqa: F401 — re-exported for siblings from . import SHARED_DIR # noqa: F401 — re-exported for siblings
from shared_store import check_user_blacklist
from data_parser import ( from data_parser import (
LangTableReader, LangTableReader,
UnitTags, UnitTags,
@@ -129,23 +130,9 @@ def decompress_json(data):
BLACKLISTED_SERVER_IDS = [ BLACKLISTED_SERVER_IDS = [
] ]
BLACKLISTED_SQUADRONS = [ # Blacklisted users and squadrons now live in the shared, version-controlled
"FIR3", # BOTS/SHARED/BLACKLIST.json (read via shared_store) so both bots share one
"F4WRD", # source of truth. Use check_user_blacklist() / shared_store.blacklisted_squadrons().
]
BLACKLISTED_USER_IDS = [
635917136619110411, #wolfhunter4374
498231384238850048, #liquidtaeja
1166571862789193769, #astro
872791644947234847, #nova
591290628722393090, #rayan
1129994258267512842, #squawk
1417443909918916670, #maverickfighter9
1034483237197713539, #HK416A6
(601497771266277387, "Z supporter + racism"), #lowe/koniglion
815535178122002463, #markboss
]
# ── Premium / Entitlements ──────────────────────────────────────────────────── # ── Premium / Entitlements ────────────────────────────────────────────────────
PREMIUM_ACTIVATION_TS: int = 1775459700 # Unix timestamp when premium gating activates PREMIUM_ACTIVATION_TS: int = 1775459700 # Unix timestamp when premium gating activates
@@ -540,8 +527,9 @@ async def is_dev_team(interaction: discord.Interaction) -> bool:
def is_blacklisted(): def is_blacklisted():
"""Return an app-command check that rejects blacklisted users or guilds. """Return an app-command check that rejects blacklisted users or guilds.
Entries in BLACKLISTED_USER_IDS may be plain ints or Blacklisted users come from the shared BLACKLIST.json (see
``(user_id, reason)`` tuples. ``shared_store.check_user_blacklist``); entries there may be a plain id or
``[id, reason]``.
Raises: Raises:
BlacklistCheckFailure: If the guild or user is blacklisted, BlacklistCheckFailure: If the guild or user is blacklisted,
@@ -552,13 +540,8 @@ def is_blacklisted():
if guild is not None and guild.id in BLACKLISTED_SERVER_IDS: if guild is not None and guild.id in BLACKLISTED_SERVER_IDS:
raise BlacklistCheckFailure(t("en", "common.access_denied_desc")) raise BlacklistCheckFailure(t("en", "common.access_denied_desc"))
uid = interaction.user.id blocked, reason = check_user_blacklist(interaction.user.id)
for entry in BLACKLISTED_USER_IDS: if blocked:
if isinstance(entry, tuple):
blocked_id, reason = entry
else:
blocked_id, reason = entry, None
if uid == blocked_id:
raise BlacklistCheckFailure(reason) raise BlacklistCheckFailure(reason)
return True return True
return app_commands.check(predicate) return app_commands.check(predicate)
@@ -778,9 +761,21 @@ def is_notif_enabled(entry: Any, notif_type: str) -> bool:
return bool(re.search(r"\d{17,19}", raw)) return bool(re.search(r"\d{17,19}", raw))
def is_foreign_pref_entry(entry: Any) -> bool:
"""True if a preferences entry belongs to another bot (TSSBOT) and SRE should skip it.
Both bots share ``STORAGE/PREFERENCES/<guild>-preferences.json``. TSSBOT entries
carry a ``Type`` of ``tss-team``/``tss-player``; SRE entries have no such Type.
"""
return isinstance(entry, dict) and str(entry.get("Type", "")).lower().startswith("tss")
def enabled_pref_keys_for(prefs: Dict[str, Any], notif_type: str) -> List[str]: def enabled_pref_keys_for(prefs: Dict[str, Any], notif_type: str) -> List[str]:
"""Squadron keys (in JSON insertion order) whose entry has this notif enabled.""" """Squadron keys (in JSON insertion order) whose entry has this notif enabled."""
return [k for k, v in prefs.items() if is_notif_enabled(v, notif_type)] return [
k for k, v in prefs.items()
if not is_foreign_pref_entry(v) and is_notif_enabled(v, notif_type)
]
def allowed_pref_keys_for(prefs: Dict[str, Any], tier: Optional[str], notif_type: str) -> set[str]: def allowed_pref_keys_for(prefs: Dict[str, Any], tier: Optional[str], notif_type: str) -> set[str]: