feat(tally): fix live VC status updates and add permission pre-flight check

- Move tally hook from process_session (per-guild, gated by Logs subs)
  to process_ws_replays (once per game, all guilds) via on_game_finished
- Add set_voice_channel_status permission check at /tally-claim time so
  failures are immediate and visible rather than silent on every game
- Remove entitlement gate from tally_claim and tally_transfer
- Add VC tally permission section to /diagnose-perms when run in a VC
- Add 5 new locale keys to en.json for the permission messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
deploy
2026-06-20 08:02:53 +00:00
parent 661a71649a
commit 28a635438d
4 changed files with 39 additions and 14 deletions
+13 -8
View File
@@ -534,6 +534,19 @@ async def process_ws_replays(replays: list[dict]):
) )
local_data["scoreboard_context"] = scoreboard_context local_data["scoreboard_context"] = scoreboard_context
# Notify all active tallies immediately after the game is saved.
# Done here rather than inside process_session so it fires for all
# guilds regardless of their Logs channel subscriptions.
try:
await tally.on_game_finished(
local_data.get("teams", []),
local_data.get("winning_team_squadron") or None,
bool(local_data.get("draw", False)),
hex_id,
)
except Exception as e:
logging.error(f"[TALLY] on_game_finished failed for {hex_id}: {e}")
forwarded_replays.append(local_data) forwarded_replays.append(local_data)
validated_games.append({ validated_games.append({
"sessionIdHex": hex_id, "sessionIdHex": hex_id,
@@ -1132,14 +1145,6 @@ async def process_session(
else: else:
new_wl = get_standings(squadrons_clean) new_wl = get_standings(squadrons_clean)
# Update any live voice-channel tallies for this guild against this game.
try:
await tally.on_session_processed(
guild_id, teams, winner, is_draw, session_id
)
except Exception as e:
logging.error(f"[TALLY] hook failed for session {session_id}: {e}")
# Scoreboard Build # Scoreboard Build
lock = _scoreboard_locks.setdefault(session_id, asyncio.Lock()) lock = _scoreboard_locks.setdefault(session_id, asyncio.Lock())
async with lock: async with lock:
+13 -6
View File
@@ -1233,6 +1233,16 @@ async def _diagnose_perms_logic(interaction: discord.Interaction, target_channel
lines.append(t(lang, "diagnostics.premium_autolog_required")) lines.append(t(lang, "diagnostics.premium_autolog_required"))
lines.append("") lines.append("")
# ── 5. Voice channel tally permission ──
if isinstance(channel, discord.VoiceChannel):
vc_mention = f"<#{channel.id}>"
lines.append(t(lang, "commands.tally.vc_perm_header", vc=vc_mention))
if channel.permissions_for(bot_member).set_voice_channel_status:
lines.append(f"{t(lang, 'commands.tally.vc_perm_ok', vc=vc_mention)}")
else:
lines.append(f"{t(lang, 'commands.tally.no_vc_perm_diagnose', vc=vc_mention)}")
lines.append("")
# ── Send ── # ── Send ──
embed = discord.Embed( embed = discord.Embed(
title=t(lang, "diagnostics.title"), title=t(lang, "diagnostics.title"),
@@ -4267,13 +4277,13 @@ def _invoker_voice_channel(interaction: discord.Interaction):
@discord.app_commands.autocomplete(username=player_autocomplete, squadron=squadron_autocomplete) @discord.app_commands.autocomplete(username=player_autocomplete, squadron=squadron_autocomplete)
async def tally_claim(interaction: discord.Interaction, username: str = "", squadron: str = ""): async def tally_claim(interaction: discord.Interaction, username: str = "", squadron: 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"
if not interaction.guild_id or not await is_guild_entitled(interaction.guild_id):
await interaction.response.send_message(t(lang, "commands.tally.premium_required"), ephemeral=True)
return
vc = _invoker_voice_channel(interaction) vc = _invoker_voice_channel(interaction)
if vc is None: if vc is None:
await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True) await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True)
return return
if not vc.permissions_for(vc.guild.me).set_voice_channel_status:
await interaction.response.send_message(t(lang, "commands.tally.no_vc_perm"), ephemeral=True)
return
username = username.strip() username = username.strip()
squadron = squadron.strip() squadron = squadron.strip()
if bool(username) == bool(squadron): # neither or both if bool(username) == bool(squadron): # neither or both
@@ -4311,9 +4321,6 @@ async def tally_claim(interaction: discord.Interaction, username: str = "", squa
@discord.app_commands.autocomplete(username=player_autocomplete) @discord.app_commands.autocomplete(username=player_autocomplete)
async def tally_transfer(interaction: discord.Interaction, username: str): async def tally_transfer(interaction: discord.Interaction, username: 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"
if not interaction.guild_id or not await is_guild_entitled(interaction.guild_id):
await interaction.response.send_message(t(lang, "commands.tally.premium_required"), ephemeral=True)
return
vc = _invoker_voice_channel(interaction) vc = _invoker_voice_channel(interaction)
if vc is None: if vc is None:
await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True) await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True)
+5
View File
@@ -861,6 +861,11 @@
"status_line": "{base}: {verb} against {opponent}", "status_line": "{base}: {verb} against {opponent}",
"not_in_vc": "You must be connected to a voice channel to use this.", "not_in_vc": "You must be connected to a voice channel to use this.",
"premium_required": "This is a premium feature. Use /unlock to enable it for this server.", "premium_required": "This is a premium feature. Use /unlock to enable it for this server.",
"no_vc_perm": "The bot is missing the **Set Voice Channel Status** permission in this voice channel. Ask a server admin to grant it.",
"no_vc_perm_diagnose": "Missing **Set Voice Channel Status** — `/tally-claim` will fail in {vc}. Grant the bot this permission.",
"vc_perm_ok": "**Set Voice Channel Status** — `/tally-claim` can update {vc}.",
"vc_perm_header": "Voice Channel Tally ({vc})",
"vc_perm_not_in_vc": "Not in a voice channel — join one and re-run to check tally permissions.",
"need_one_input": "Provide exactly one of `username` or `squadron`.", "need_one_input": "Provide exactly one of `username` or `squadron`.",
"already_active": "A tally is already active in **{channel}** tracking **{target}**. Use /tally-transfer or /tally-clear first.", "already_active": "A tally is already active in **{channel}** tracking **{target}**. Use /tally-transfer or /tally-clear first.",
"claimed": "Now tracking **{target}** in **{channel}**. Status set to `0W-0L`.", "claimed": "Now tracking **{target}** in **{channel}**. Status set to `0W-0L`.",
+8
View File
@@ -266,6 +266,14 @@ async def on_session_processed(guild_id: int, teams: list[dict],
logging.error(f"[TALLY] on_session_processed error (guild={guild_id}): {e}") logging.error(f"[TALLY] on_session_processed error (guild={guild_id}): {e}")
async def on_game_finished(teams: list[dict], winner_short: Optional[str],
is_draw: bool, session_id: str) -> None:
"""Update all active tallies across every guild for one finished session."""
guild_ids = {gid for (gid, _) in _REGISTRY}
for guild_id in guild_ids:
await on_session_processed(guild_id, teams, winner_short, is_draw, session_id)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Idle sweep # Idle sweep
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------