0.4.1.0.a8

- Converted all commands to slash-only commands
This commit is contained in:
Franz Rolfsvaag 2025-08-16 03:12:33 +02:00
parent 0349c36880
commit 77f92abe19
8 changed files with 114 additions and 127 deletions

2
bot.py
View File

@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
# Version consists of:
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.4.1.0.a7"
VERSION = "0.4.1.0.a8"
# ---------- Env loading ----------

View File

@ -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))

View File

@ -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))

View File

@ -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()

View File

@ -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))

View File

@ -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()

View File

@ -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)")

View File

@ -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))