From 77f92abe19cdd70eaa67cd60f92ec537df07c27c Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sat, 16 Aug 2025 03:12:33 +0200 Subject: [PATCH] 0.4.1.0.a8 - Converted all commands to slash-only commands --- bot.py | 2 +- modules/auto_vc/auto_vc.py | 55 +++++++++++++++----------- modules/pirate_cards/pirate_cards.py | 41 ++++++++++--------- modules/pirate_report/pirate_report.py | 46 ++++++++++++--------- modules/pirates_list/pirates_list.py | 23 ++++++----- modules/reaction_role/reaction_role.py | 17 ++++---- modules/usage/usage_stats.py | 35 ++-------------- modules/user_cards/user_cards.py | 22 +++++------ 8 files changed, 114 insertions(+), 127 deletions(-) diff --git a/bot.py b/bot.py index efb516a..7252d1e 100644 --- a/bot.py +++ b/bot.py @@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice # Version consists of: # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update) -VERSION = "0.4.1.0.a7" +VERSION = "0.4.1.0.a8" # ---------- Env loading ---------- diff --git a/modules/auto_vc/auto_vc.py b/modules/auto_vc/auto_vc.py index d1f7797..a0094b5 100644 --- a/modules/auto_vc/auto_vc.py +++ b/modules/auto_vc/auto_vc.py @@ -3,8 +3,9 @@ import asyncio import time import discord from discord.ext import commands +from discord import app_commands from modules.common.settings import cfg # ENV-first config helper -from mod_perms import require_mod_ctx # <- use project mod perms +from mod_perms import require_mod_ctx, require_mod_interaction # <- use project mod perms def now() -> float: return time.time() @@ -225,10 +226,12 @@ class AutoVCCog(commands.Cog): # ------------- admin commands ------------- - @commands.hybrid_command(name="avc_status", description="Show Auto-VC status for this guild") - @commands.guild_only() - async def avc_status(self, ctx: commands.Context): - g = ctx.guild + @app_commands.command(name="avc_status", description="Show Auto-VC status for this guild") + async def avc_status(self, interaction: discord.Interaction): + if not interaction.guild: + return await interaction.response.send_message("Use this in a server.", ephemeral=True) + + g = interaction.guild recs = sorted(self._vc_records(g.id), key=lambda r: r.get('created_ts', 0)) lines = [ f"Trigger: <#{self.trigger_id or 0}> | Category: <#{self.category_id or 0}> | Prefix: `{self.prefix}` | Delay: {self.delay}s" @@ -244,29 +247,35 @@ class AutoVCCog(commands.Cog): t = self.empty_since.get(rec['channel_id']) tail = f" | idle {int(now()-t)}s" if t and (not ch or (ch and not ch.members)) else "" lines.append(f"- #{idx}: {name} — {state}{tail}") + msg = "Auto-VC status:\n" + "\n".join(lines) if lines else "No Auto-VC rooms tracked." - await ctx.reply(msg) + await interaction.response.send_message(msg) - @commands.hybrid_command(name="avc_cleanup_now", description="[MOD] Run an immediate cleanup pass (delete idle rooms & renumber)") - @commands.guild_only() - async def avc_cleanup_now(self, ctx: commands.Context): - if not await require_mod_ctx(ctx, "This command is restricted to moderators."): + @app_commands.command(name="avc_cleanup_now", description="[MOD] Run an immediate cleanup pass (delete idle rooms & renumber)") + async def avc_cleanup_now(self, interaction: discord.Interaction): + if not interaction.guild: + return await interaction.response.send_message("Use this in a server.", ephemeral=True) + if not await require_mod_interaction(interaction, "This command is restricted to moderators."): return - # serialize vs sweeper and other admin ops - async with self._ops_lock: - await self._cleanup_pass(ctx.guild) - await self._log(ctx.guild, f"🧹 Cleanup pass invoked by {ctx.author.mention}") - await ctx.reply("Cleanup pass complete.", ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None) - @commands.hybrid_command(name="avc_renumber", description="[MOD] Force a renumber of tracked rooms") - @commands.guild_only() - async def avc_renumber(self, ctx: commands.Context): - if not await require_mod_ctx(ctx, "This command is restricted to moderators."): - return + await interaction.response.defer(ephemeral=True) async with self._ops_lock: - await self._renumber(ctx.guild) - await self._log(ctx.guild, f"🔢 Renumber invoked by {ctx.author.mention}") - await ctx.reply("Renumbered.", ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None) + await self._cleanup_pass(interaction.guild) + await self._log(interaction.guild, f"🧹 Cleanup pass invoked by {interaction.user.mention}") + await interaction.followup.send("Cleanup pass complete.", ephemeral=True) + + @app_commands.command(name="avc_renumber", description="[MOD] Force a renumber of tracked rooms") + async def avc_renumber(self, interaction: discord.Interaction): + if not interaction.guild: + return await interaction.response.send_message("Use this in a server.", ephemeral=True) + if not await require_mod_interaction(interaction, "This command is restricted to moderators."): + return + + await interaction.response.defer(ephemeral=True) + async with self._ops_lock: + await self._renumber(interaction.guild) + await self._log(interaction.guild, f"🔢 Renumber invoked by {interaction.user.mention}") + await interaction.followup.send("Renumbered.", ephemeral=True) async def setup(bot): await bot.add_cog(AutoVCCog(bot)) diff --git a/modules/pirate_cards/pirate_cards.py b/modules/pirate_cards/pirate_cards.py index 9bbd883..7c033ce 100644 --- a/modules/pirate_cards/pirate_cards.py +++ b/modules/pirate_cards/pirate_cards.py @@ -2,8 +2,9 @@ import asyncio import discord from discord.ext import commands +from discord import app_commands from datetime import datetime -from mod_perms import require_mod_ctx +from mod_perms import require_mod_ctx, require_mod_interaction from modules.common.settings import cfg # ENV-first config helper @@ -196,30 +197,28 @@ class PirateCardsCog(commands.Cog): await self.refresh_card_for_account(guild, new_account) # -------- command (mod-gated via require_mod_ctx) -------- - @commands.hybrid_command(name="pirate_cards_rebuild", description="[MOD] Rebuild pirate cards for all known pirates") - async def pirate_cards_rebuild(self, ctx: commands.Context): - if not await require_mod_ctx(ctx, "This command is restricted to moderators."): + @app_commands.command(name="pirate_cards_rebuild", description="[MOD] Rebuild pirate cards for all known pirates") + async def pirate_cards_rebuild(self, interaction: discord.Interaction): + if not interaction.guild: + return await interaction.response.send_message("Use this in a server.", ephemeral=True) + if not await require_mod_interaction(interaction, "This command is restricted to moderators."): return - if not ctx.guild: - return await ctx.reply("Use this in a server.", ephemeral=True) - async with self._lock_for(ctx.guild.id): - guild = ctx.guild - ch = guild.get_channel(self.pirates_channel_id) - if not ch: - return await ctx.reply("Configured pirates_list_channel_id not found.", ephemeral=True) + await interaction.response.defer(ephemeral=True) + guild = interaction.guild + ch = guild.get_channel(self.pirates_channel_id) + if not ch: + return await interaction.followup.send("Configured pirates_list_channel_id not found.", ephemeral=True) - count = 0 - for p in self.bot.data_manager.get('pirates'): - try: - await self.refresh_card_for_account(guild, p.get('account_name', '')) - count += 1 - except Exception: - continue - - is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None - await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash) + count = 0 + for p in self.bot.data_manager.get('pirates'): + try: + await self.refresh_card_for_account(guild, p.get('account_name', '')) + count += 1 + except Exception: + continue + await interaction.followup.send(f"Rebuilt/updated {count} pirate cards.", ephemeral=True) async def setup(bot): await bot.add_cog(PirateCardsCog(bot)) diff --git a/modules/pirate_report/pirate_report.py b/modules/pirate_report/pirate_report.py index 5d00a8c..c078f34 100644 --- a/modules/pirate_report/pirate_report.py +++ b/modules/pirate_report/pirate_report.py @@ -518,18 +518,23 @@ class PirateReportCog(commands.Cog): return (matches[0], None) # Remove pirate (mod-only) - @commands.hybrid_command(name='remove_pirate', description='[MOD] Remove an approved pirate entry') - async def remove_pirate(self, ctx, account_name: str): - if not await require_mod_ctx(ctx, "This command is restricted to moderators."): + @app_commands.command(name="remove_pirate", description="[MOD] Remove an approved pirate entry") + @app_commands.describe(account_name="Account name to remove") + async def remove_pirate(self, interaction: discord.Interaction, account_name: str): + if not interaction.guild: + return await interaction.response.send_message("Use this in a server.", ephemeral=True) + if not await require_mod_interaction(interaction, "This command is restricted to moderators."): return + acct_lower = account_name.strip().lower() dm = self.bot.data_manager if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')): - return await ctx.reply("Pirate not found.") + return await interaction.response.send_message("Pirate not found.", ephemeral=True) + dm.remove('pirates', lambda p: p['account_name'].lower() == acct_lower) - await self._modlog(ctx.guild, f"🗑️ Removed pirate {account_name} by {ctx.author.mention}") - await self._refresh_pirates_list(ctx.guild) - await ctx.reply("Removed.", ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None) + await self._modlog(interaction.guild, f"🗑️ Removed pirate {account_name} by {interaction.user.mention}") + await self._refresh_pirates_list(interaction.guild) + await interaction.response.send_message("Removed.", ephemeral=True) # Modal launchers @app_commands.command(name="report", description="Submit a pirate report for moderator review (opens a form)") @@ -551,10 +556,15 @@ class PirateReportCog(commands.Cog): await interaction.response.send_modal(EncounterModal(self)) # ---- Migration: convert encounter identifiers to accounts (mod-only) ---- - @commands.hybrid_command(name='encounters_migrate_ids', description='[MOD] Migrate encounter identifiers to account names') - async def encounters_migrate_ids(self, ctx: commands.Context): - if not await require_mod_ctx(ctx, "This command is restricted to moderators."): + @app_commands.command(name="encounters_migrate_ids", description="[MOD] Migrate encounter identifiers to account names") + async def encounters_migrate_ids(self, interaction: discord.Interaction): + if not interaction.guild: + return await interaction.response.send_message("Use this in a server.", ephemeral=True) + if not await require_mod_interaction(interaction, "This command is restricted to moderators."): return + + await interaction.response.defer(ephemeral=True) + dm = self.bot.data_manager pirates = dm.get('pirates') by_char = {} @@ -562,10 +572,7 @@ class PirateReportCog(commands.Cog): by_char.setdefault(p['character_name'].lower(), []).append(p) by_acct = {p['account_name'].lower(): p for p in pirates} - changed = 0 - ambiguous = 0 - missing = 0 - already = 0 + changed = ambiguous = missing = already = 0 for e in dm.get('encounters'): ident = e.get('identifier', '') @@ -583,26 +590,27 @@ class PirateReportCog(commands.Cog): ambiguous += 1 continue acct = matches[0]['account_name'] - # update this one entry atomically + def pred(x, ts=e['timestamp'], rid=e['reporter_id'], ident_old=ident): return x.get('timestamp') == ts and x.get('reporter_id') == rid and x.get('identifier') == ident_old def upd(x, acct_new=acct): x['identifier'] = acct_new return x + ok = dm.update('encounters', pred, upd) if ok: changed += 1 - await ctx.reply( - f"Migration complete.\n" + await interaction.followup.send( + "Migration complete.\n" f"- Updated to accounts: **{changed}**\n" f"- Already accounts: **{already}**\n" f"- Ambiguous character names (skipped): **{ambiguous}**\n" f"- Not found in pirates list (skipped): **{missing}**", - ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None + ephemeral=True ) - await self._refresh_pirates_list(ctx.guild) + await self._refresh_pirates_list(interaction.guild) # Moderator reaction handling (atomic claim) @commands.Cog.listener() diff --git a/modules/pirates_list/pirates_list.py b/modules/pirates_list/pirates_list.py index ac849a6..011e8d9 100644 --- a/modules/pirates_list/pirates_list.py +++ b/modules/pirates_list/pirates_list.py @@ -2,7 +2,8 @@ import asyncio import discord from discord.ext import commands -from mod_perms import require_mod_ctx +from discord import app_commands +from mod_perms import require_mod_ctx, require_mod_interaction from modules.common.settings import cfg as _cfg @@ -211,19 +212,17 @@ class PiratesListCog(commands.Cog): dm.remove("pirates_list_posts", lambda r, mid=m.id: r.get("message_id") == mid) # Manual refresh command (hybrid: works as /pirates_list_refresh and !pirates_list_refresh) - @commands.hybrid_command(name="pirates_list_refresh", description="[MOD] Rebuild the compact pirates list") - @commands.cooldown(1, 10, commands.BucketType.guild) # tiny anti-spam - async def pirates_list_refresh(self, ctx: commands.Context): - if not await require_mod_ctx(ctx, "This command is restricted to moderators."): + @app_commands.command(name="pirates_list_refresh", description="[MOD] Rebuild the compact pirates list") + @app_commands.checks.cooldown(1, 10) # guild-scope anti-spam analogue + async def pirates_list_refresh(self, interaction: discord.Interaction): + if not interaction.guild: + return await interaction.response.send_message("Use this in a server.", ephemeral=True) + if not await require_mod_interaction(interaction, "This command is restricted to moderators."): return - if not ctx.guild: - return await ctx.reply("Use this in a server.", ephemeral=True) - - await self.refresh_list(ctx.guild) - - is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None - await ctx.reply("Pirates list refreshed.", ephemeral=is_slash) + await interaction.response.defer(ephemeral=True) + await self.refresh_list(interaction.guild) + await interaction.followup.send("Pirates list refreshed.", ephemeral=True) async def setup(bot: commands.Bot): await bot.add_cog(PiratesListCog(bot)) diff --git a/modules/reaction_role/reaction_role.py b/modules/reaction_role/reaction_role.py index 84b80fd..269a7ef 100644 --- a/modules/reaction_role/reaction_role.py +++ b/modules/reaction_role/reaction_role.py @@ -1,6 +1,7 @@ # modules/reaction_role/reaction_role.py import discord from discord.ext import commands +from discord import app_commands from modules.common.emoji_accept import is_accept from modules.common.settings import cfg # ENV-first helper @@ -94,22 +95,22 @@ class ReactionRoleCog(commands.Cog): return False # ---- commands (hybrid = prefix + slash) ---- - @commands.hybrid_command(name='nick_same', description='Claim that your global display name matches your in-game name (triggers mod review)') - async def nick_same(self, ctx: commands.Context): - member = ctx.author if isinstance(ctx.author, discord.Member) else None - if not member or not ctx.guild: - return await ctx.reply("Use this in a server.", ephemeral=True) + @app_commands.command(name="nick_same", description="Claim that your global display name matches your in-game name (triggers mod review)") + async def nick_same(self, interaction: discord.Interaction): + if not interaction.guild or not isinstance(interaction.user, discord.Member): + return await interaction.response.send_message("Use this in a server.", ephemeral=True) + + member: discord.Member = interaction.user - # Atomic path handled inside NickNudge nn = self.bot.get_cog('NickNudgeCog') if nn and hasattr(nn, 'ensure_pending_and_maybe_open'): try: - await nn.ensure_pending_and_maybe_open(ctx.guild, member, source="nick_same") + await nn.ensure_pending_and_maybe_open(interaction.guild, member, source="nick_same") except Exception: pass await self.maybe_apply_full_access(member) - await ctx.reply("Thanks — your nickname claim was sent for moderator review.", ephemeral=True) + await interaction.response.send_message("Thanks — your nickname claim was sent for moderator review.", ephemeral=True) # ---- listeners ---- @commands.Cog.listener() diff --git a/modules/usage/usage_stats.py b/modules/usage/usage_stats.py index b29ef0f..29fc547 100644 --- a/modules/usage/usage_stats.py +++ b/modules/usage/usage_stats.py @@ -6,22 +6,15 @@ import discord COUNTER_KEY_PREFIX = "cmd::" def _key_from_app(cmd: discord.app_commands.Command) -> str: - name = getattr(cmd, "qualified_name", None) or getattr(cmd, "name", "unknown") - return f"{COUNTER_KEY_PREFIX}{name}" - -def _key_from_ctx(ctx: commands.Context) -> str: - c = getattr(ctx, "command", None) - name = getattr(c, "qualified_name", None) or getattr(c, "name", "unknown") - return f"{COUNTER_KEY_PREFIX}{name}" + return f"{COUNTER_KEY_PREFIX}{getattr(cmd, 'qualified_name', None) or getattr(cmd, 'name', 'unknown')}" class UsageStatsCog(commands.Cog): - """Count command runs once: app for slash, prefix for non-interaction Context.""" + """Slash-only metrics; count once per successful app command completion.""" def __init__(self, bot: commands.Bot): self.bot = bot - print("[usage] UsageStatsCog init") + print("[usage] UsageStatsCog init (slash-only)") - # -------- slash / app-commands -------- @commands.Cog.listener() async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command): dm = getattr(self.bot, "data_manager", None) @@ -34,30 +27,10 @@ class UsageStatsCog(commands.Cog): except Exception as e: print("[usage] app !! incr failed:", repr(e)) - # -------- prefix (and hybrid-as-prefix) -------- - @commands.Cog.listener() - async def on_command_completion(self, ctx: commands.Context): - # If this Context came from a slash interaction (hybrid invoked via slash), - # DO NOT count here—the app listener already did. - if getattr(ctx, "interaction", None): - print("[usage] px ~~ skip: ctx.interaction is set (slash path already counted)") - return - - dm = getattr(self.bot, "data_manager", None) - if not dm: - return - try: - key = _key_from_ctx(ctx) - newv = dm.incr_counter(key, 1) - print(f"[usage] px ++ {key} -> {newv}") - except Exception as e: - print("[usage] px !! incr failed:", repr(e)) - async def setup(bot: commands.Bot): - # Avoid double registration if extensions are discovered/reloaded twice if getattr(bot, "_usage_stats_loaded", False): print("[usage] UsageStatsCog already loaded; skipping duplicate add") return await bot.add_cog(UsageStatsCog(bot)) bot._usage_stats_loaded = True - print("[usage] UsageStatsCog loaded") + print("[usage] UsageStatsCog loaded (slash-only)") diff --git a/modules/user_cards/user_cards.py b/modules/user_cards/user_cards.py index 8c64e5a..461ae4a 100644 --- a/modules/user_cards/user_cards.py +++ b/modules/user_cards/user_cards.py @@ -4,6 +4,7 @@ import time from typing import Optional, Set, Tuple import discord from discord.ext import commands +from discord import app_commands from modules.common.emoji_accept import is_accept from modules.common.settings import cfg # ENV-first helper @@ -494,20 +495,18 @@ class UserCardsCog(commands.Cog): await asyncio.sleep(12 * 60 * 60) # twice a day # ---------- mod command: rescan + live reconcile ---------- + @app_commands.command(name="usercards_rescan", description="[MOD] Re-check all users and refresh cards") + @app_commands.default_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) + async def usercards_rescan(self, interaction: discord.Interaction): + if not interaction.guild: + return await interaction.response.send_message("Use this in a server.", ephemeral=True) - @commands.hybrid_command( - name="usercards_rescan", - description="[MOD] Re-check all users and refresh cards" - ) - @commands.has_permissions(manage_guild=True) - async def usercards_rescan(self, ctx: commands.Context): - g = ctx.guild - if not g: - return await ctx.reply("Use this in a server.", ephemeral=True) + await interaction.response.defer(ephemeral=True) + g = interaction.guild rchg, echg, nadd, nrem = await self._reconcile_agreements(g) - # Rebuild cards updated = 0 for m in g.members: if not m.bot: @@ -517,13 +516,12 @@ class UserCardsCog(commands.Cog): except Exception: pass - await ctx.reply( + await interaction.followup.send( f"Reconciled from messages. Changes — Rules: **{rchg}**, RoE: **{echg}**, " f"Nickname (added): **{nadd}**, Nickname (removed): **{nrem}**. " f"Refreshed cards for **{updated}** members.", ephemeral=True ) - async def setup(bot): await bot.add_cog(UserCardsCog(bot))