From c8b043a60568c1adea2802b3cb95b02891086eb1 Mon Sep 17 00:00:00 2001 From: Evanroby <107794516+Evanroby@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:45:46 +0200 Subject: [PATCH 1/2] [SlashHelpMenu] push the cog further --- slashhelpmenu/slashhelpmenu.py | 278 +++++++++++++++++++++------------ slashhelpmenu/view.py | 56 ++++--- 2 files changed, 215 insertions(+), 119 deletions(-) diff --git a/slashhelpmenu/slashhelpmenu.py b/slashhelpmenu/slashhelpmenu.py index 424c3995..d12e6655 100644 --- a/slashhelpmenu/slashhelpmenu.py +++ b/slashhelpmenu/slashhelpmenu.py @@ -22,7 +22,9 @@ SOFTWARE. """ +import asyncio import contextlib +import logging from collections import defaultdict import discord @@ -31,6 +33,16 @@ from .view import HelpView +log = logging.getLogger("red.maxcogs.slashhelpmenu") + +_TITLE = "Available Slash Commands" +_DESCRIPTION = ( + "These are all the slash commands available on this bot.\n" + "~~Strikethrough~~ indicates missing permissions.\n" + "Note: not all permission requirements can be detected." +) +_BASE_CHAR_COUNT = len(_TITLE) + len(_DESCRIPTION) + class SlashHelpMenu(commands.Cog): """ @@ -39,7 +51,7 @@ class SlashHelpMenu(commands.Cog): This is a slash command version of the default help command. It will not show context menu commands. """ - __version__ = "1.0.0" + __version__ = "1.1.0" __author__ = "MAX" __docs__ = "https://github.com/ltzmax/maxcogs/blob/master/docs/SlashHelpMenu.md" @@ -57,19 +69,89 @@ async def red_delete_data_for_user(self, *, requester: str, user_id: int) -> Non """No user data to delete.""" pass + def _new_embed(self, color: discord.Color) -> discord.Embed: + return discord.Embed( + title=_TITLE, + color=color, + description=_DESCRIPTION, + ) + + def _collect_commands( + self, + commands_list: list[app_commands.Command | app_commands.Group | app_commands.ContextMenu], + ) -> defaultdict[str, list[tuple[str, app_commands.Command | app_commands.Group]]]: + """ + Recursively walk the app command tree and bucket every non-context-menu + command under its cog name. + """ + cog_commands: defaultdict[ + str, list[tuple[str, app_commands.Command | app_commands.Group]] + ] = defaultdict(list) + + def process( + cmd: app_commands.Command | app_commands.Group | app_commands.ContextMenu, + group_prefix: str = "", + parent_cog_name: str | None = None, + ) -> None: + if cmd.name == "help" and not group_prefix: + return + if isinstance(cmd, app_commands.ContextMenu): + return + cog_name = parent_cog_name or "Uncategorized" + if hasattr(cmd, "binding") and cmd.binding is not None: + cog_name = getattr( + cmd.binding, + "qualified_name", + cmd.binding.__class__.__name__, + ) + elif hasattr(cmd, "cog") and cmd.cog is not None: + cog_name = getattr( + cmd.cog, + "qualified_name", + cmd.cog.__class__.__name__, + ) + elif hasattr(cmd, "module") and cmd.module: + cog_name = cmd.module.split(".")[-1].replace("cog", "").title() + + display_name = f"{group_prefix} {cmd.name}".strip() if group_prefix else cmd.name + + if isinstance(cmd, app_commands.Group): + if cmd.description and not cmd.commands: + cog_commands[cog_name].append((display_name, cmd)) + for subcmd in cmd.commands: + process(subcmd, group_prefix=display_name, parent_cog_name=cog_name) + else: + cog_commands[cog_name].append((display_name, cmd)) + + for cmd in commands_list: + process(cmd) + + return cog_commands + async def _can_use( self, interaction: discord.Interaction, - cmd: app_commands.Command | app_commands.Group, + cmd: app_commands.Command | app_commands.Group | app_commands.ContextMenu, ) -> bool: """ - Check if the user and the bot can use a command based on default_permissions. - - (In some cases this may not be 100% accurate, but it's the best we can do without actually trying to invoke the command.) + Check whether the invoking user can use a command, combining Red's permission + system with Discord's default_permissions. """ if interaction.guild is None or not isinstance(interaction.user, discord.Member): return True - default_perms = None + if await self.bot.is_owner(interaction.user): + return True + if not await self.bot.allowed_by_whitelist_blacklist(who=interaction.user): + return False + cog: commands.Cog | None = None + if hasattr(cmd, "binding") and cmd.binding is not None: + cog = cmd.binding + elif hasattr(cmd, "cog") and cmd.cog is not None: + cog = cmd.cog + + if cog is not None and await self.bot.cog_disabled_in_guild(cog, interaction.guild): + return False + default_perms: discord.Permissions | None = None current = cmd while current is not None: default_perms = getattr(current, "default_permissions", None) @@ -78,151 +160,145 @@ async def _can_use( current = getattr(current, "parent", None) if default_perms is None: return True - bot_me = interaction.guild.me - if not bot_me.guild_permissions.is_superset(default_perms): + if not interaction.guild.me.guild_permissions.is_superset(default_perms): return False - if interaction.user.guild_permissions.administrator or await self.bot.is_owner( - interaction.user - ): + if interaction.user.guild_permissions.administrator: return True return interaction.user.guild_permissions.is_superset(default_perms) + async def _build_mention( + self, + interaction: discord.Interaction, + display_name: str, + cmd: app_commands.Command | app_commands.Group, + ) -> str: + """ + Return a properly formatted mention string for a command. + + For subcommands (e.g. "tag list"), the parent command's ID is used so that + the mention renders as rather than falling back to + plain text. The result is wrapped in strikethrough if the user lacks + permission to run the command. + """ + top_level_name = display_name.split()[0] + mention = await self.bot.get_app_command_mention(top_level_name) + + if mention and " " in display_name: + cmd_id = mention.split(":")[-1].rstrip(">") + mention = f"" + elif not mention: + mention = f"`/{display_name}`" + + if not await self._can_use(interaction, cmd): + mention = f"~~{mention}~~" + + return mention + @commands.group() @commands.is_owner() - async def slashhelpset(self, ctx: commands.Context): + async def slashhelpset(self, ctx: commands.Context) -> None: """Settings for the slash help menu.""" + if ctx.invoked_subcommand is None: + eph = await self.config.eph() + embed = discord.Embed( + title="SlashHelpMenu Settings", + color=await ctx.embed_color(), + ) + embed.add_field(name="Ephemeral messages", value=str(eph), inline=False) + await ctx.send(embed=embed) @slashhelpset.command() - async def toggle(self, ctx: commands.Context, toggle: bool): + async def toggle(self, ctx: commands.Context, toggle: bool) -> None: """Whether the help menu should be sent as an ephemeral message.""" await self.config.eph.set(toggle) await ctx.send(f"Set ephemeral to {toggle}.") @app_commands.command(name="help", description="Shows all available slash commands.") - @app_commands.default_permissions(embed_links=True) - async def help_command(self, interaction: discord.Interaction): + async def help_command(self, interaction: discord.Interaction) -> None: + eph = await self.config.eph() + await interaction.response.defer(ephemeral=eph) + color = await self.bot.get_embed_color(interaction.channel) try: - commands_list = self.bot.tree.get_commands() - cog_commands = defaultdict(list) - - # This is a recursive function that processes all commands and groups - # and puts them into a dictionary with the cog name as the key. - def process_command(cmd, group_prefix="", parent_cog_name=None): - if cmd.name == "help" and not group_prefix: - return - - # Skip context menus for now. - # they're pretty limited so idk if its worth the effort to include them in the help menu. - # but in future i'll imporve this to include them in a separate section or something. - if isinstance(cmd, app_commands.ContextMenu): - return - - cog_name = parent_cog_name or "Uncategorized" - if hasattr(cmd, "binding") and cmd.binding: - cog_name = cmd.binding.__class__.__name__.replace("Cog", "").title() - elif hasattr(cmd, "cog") and cmd.cog: - cog_name = cmd.cog.__class__.__name__.replace("Cog", "").title() - elif hasattr(cmd, "module") and cmd.module: - cog_name = cmd.module.split(".")[-1].replace("cog", "").title() - display_name = f"{group_prefix} {cmd.name}".strip() if group_prefix else cmd.name - - if isinstance(cmd, app_commands.Group): - if hasattr(cmd, "description") and cmd.description and not cmd.commands: - cog_commands[cog_name].append((display_name, cmd)) - for subcmd in cmd.commands: - process_command( - subcmd, group_prefix=display_name, parent_cog_name=cog_name - ) - else: - cog_commands[cog_name].append((display_name, cmd)) - - for cmd in commands_list: - process_command(cmd) + cog_commands = self._collect_commands(self.bot.tree.get_commands()) if not cog_commands: - return await interaction.response.send_message( + await interaction.followup.send( "No slash commands available to you.", ephemeral=True ) + return - pages = [] - current_embed = discord.Embed( - title="Available Slash Commands", - color=discord.Color.blurple(), - description="These are all the slash commands available on this bot.\n~~Strikethrough~~ indicates missing permissions.\nNote: not all permission requirements can be detected.", - ) + pages: list[discord.Embed] = [] + current_embed = self._new_embed(color) current_field_count = 0 - current_char_count = 0 + current_char_count = _BASE_CHAR_COUNT for cog_name, cmds in sorted(cog_commands.items()): - cmds = sorted(cmds, key=lambda c: c[0]) - field_parts = [] - - mentions = [] - for display_name, cmd in cmds: - slash_mention = await self.bot.get_app_command_mention(display_name) - if slash_mention is None: - slash_mention = f"`/{display_name}`" - if not await self._can_use(interaction, cmd): - slash_mention = f"~~{slash_mention}~~" - mentions.append(slash_mention) - - current_part = " ".join(mentions) if mentions else "None" - if len(current_part) > 1024: - current_field_part = [] + sorted_cmds = sorted(cmds, key=lambda c: c[0]) + mentions: list[str] = list( + await asyncio.gather( + *[ + self._build_mention(interaction, display_name, cmd) + for display_name, cmd in sorted_cmds + ] + ) + ) + + joined = " ".join(mentions) + field_parts: list[str] = [] + + if len(joined) > 1024: + current_chunk: list[str] = [] for mention in mentions: - _line = " ".join(current_field_part + [mention]) - if len(_line) > 1024: - field_parts.append(" ".join(current_field_part)) - current_field_part = [mention] + candidate = " ".join(current_chunk + [mention]) + if len(candidate) > 1024: + field_parts.append(" ".join(current_chunk)) + current_chunk = [mention] else: - current_field_part.append(mention) - if current_field_part: - field_parts.append(" ".join(current_field_part)) + current_chunk.append(mention) + if current_chunk: + field_parts.append(" ".join(current_chunk)) else: - field_parts.append(current_part) + field_parts.append(joined) for i, part in enumerate(field_parts, 1): field_name = f"{cog_name} (Part {i})" if len(field_parts) > 1 else cog_name - field_char_count = len(part) - + field_char_count = len(part) + len(field_name) + 10 if ( current_field_count + 1 > 25 - or current_char_count + field_char_count + len(field_name) + 10 > 3000 + or current_char_count + field_char_count > 6000 ): pages.append(current_embed) - current_embed = discord.Embed( - title="Available Slash Commands", - color=discord.Color.blurple(), - description="These are all the slash commands available on this bot.\n~~Strikethrough~~ indicates missing permissions. Note: not all permission requirements can be detected.", - ) + current_embed = self._new_embed(color) current_field_count = 0 - current_char_count = 0 + current_char_count = _BASE_CHAR_COUNT current_embed.add_field(name=field_name, value=part, inline=False) current_field_count += 1 - current_char_count += field_char_count + len(field_name) + 10 + current_char_count += field_char_count if current_embed.fields: pages.append(current_embed) if not pages: - return await interaction.response.send_message( + await interaction.followup.send( "No slash commands available to you.", ephemeral=True ) + return total_pages = len(pages) for i, page in enumerate(pages, 1): page.set_footer(text=f"Page {i} of {total_pages}") - eph = await self.config.eph() - if len(pages) == 1: - return await interaction.response.send_message(embed=pages[0], ephemeral=eph) + if total_pages == 1: + await interaction.followup.send(embed=pages[0], ephemeral=eph) + return - view = HelpView(pages, interaction.user) - await interaction.response.send_message(embed=pages[0], view=view, ephemeral=eph) - view.message = await interaction.original_response() + view = HelpView(pages, interaction.user, interaction) + await interaction.followup.send(embed=pages[0], view=view, ephemeral=eph) except Exception: + log.exception("Unhandled error in /help command") with contextlib.suppress(discord.HTTPException): - await interaction.response.send_message( - "Something went wrong while generating the help menu.", ephemeral=True + await interaction.followup.send( + "Something went wrong while generating the help menu.", + ephemeral=True, ) diff --git a/slashhelpmenu/view.py b/slashhelpmenu/view.py index 9c047ad7..2135046b 100644 --- a/slashhelpmenu/view.py +++ b/slashhelpmenu/view.py @@ -26,13 +26,18 @@ class HelpView(discord.ui.View): - def __init__(self, pages: list, interaction_user: discord.User | discord.Member): + def __init__( + self, + pages: list[discord.Embed], + interaction_user: discord.User | discord.Member, + original_interaction: discord.Interaction, + ) -> None: super().__init__(timeout=180) self.pages = pages self.current_page = 0 self.interaction_user = interaction_user - self.message: discord.Message | None = None - self.update_buttons() + self.original_interaction = original_interaction + self._update_buttons() async def interaction_check(self, interaction: discord.Interaction) -> bool: if interaction.user != self.interaction_user: @@ -40,36 +45,51 @@ async def interaction_check(self, interaction: discord.Interaction) -> bool: return False return True - def update_buttons(self): + def _update_buttons(self) -> None: self.prev_button.disabled = self.current_page == 0 self.next_button.disabled = self.current_page == len(self.pages) - 1 @discord.ui.button(label="Previous", style=discord.ButtonStyle.blurple) - async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def prev_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: self.current_page -= 1 - self.update_buttons() + self._update_buttons() try: await interaction.response.edit_message(embed=self.pages[self.current_page], view=self) except (discord.NotFound, discord.InteractionResponded): pass @discord.ui.button(label="Next", style=discord.ButtonStyle.blurple) - async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def next_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: self.current_page += 1 - self.update_buttons() + self._update_buttons() try: await interaction.response.edit_message(embed=self.pages[self.current_page], view=self) except (discord.NotFound, discord.InteractionResponded): pass - async def on_timeout(self): + @discord.ui.button(label="Close", style=discord.ButtonStyle.red) + async def close_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: for item in self.children: - item.disabled = True - if self.message: - try: - await self.message.edit(view=self) - except discord.HTTPException: - pass - except RuntimeError: - # Bot shutting down — session already closed - pass + item.disabled = True # type: ignore[union-attr] + self.stop() + try: + await interaction.response.edit_message(view=self) + except (discord.NotFound, discord.InteractionResponded): + pass + + async def on_timeout(self) -> None: + for item in self.children: + item.disabled = True # type: ignore[union-attr] + try: + await self.original_interaction.edit_original_response(view=self) + except discord.HTTPException: + pass + except RuntimeError: + # Bot shutting down — session already closed + pass From 9ad0cd5abd0e9b41ae882f096e1881e3dee3a5af Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:50:07 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- README.md | 2 +- easterhunt/info.json | 6 +++--- heist/README.md | 2 +- nba/commands/nba_commands.py | 1 + nba/nba.py | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 95d8e90d..a2a9ae97 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ To install any cog you want: [p]cog install maxcogs ``` --------------------------------------------------------------- -## Cogs on this repo: +## Cogs on this repo: - Autopublisher - Automatically publish messages in news channels - Counting diff --git a/easterhunt/info.json b/easterhunt/info.json index 06039ef6..50546c24 100644 --- a/easterhunt/info.json +++ b/easterhunt/info.json @@ -9,9 +9,9 @@ "hidden": false, "min_bot_version": "3.5.21", "tags": [ - "easter", - "hunting", - "easter eggs", + "easter", + "hunting", + "easter eggs", "economy" ], "permissions": [], diff --git a/heist/README.md b/heist/README.md index 4f3c95bf..e62bc7c7 100644 --- a/heist/README.md +++ b/heist/README.md @@ -69,7 +69,7 @@ Check cooldowns for all heists.
- Usage: `[p]heist cooldowns` - Slash Usage: `/heist cooldowns` - Aliases: `cooldown` - + # [p]heistset Manage global heist settings.
- Usage: `[p]heistset` diff --git a/nba/commands/nba_commands.py b/nba/commands/nba_commands.py index aa9c8020..c2dfdc8c 100644 --- a/nba/commands/nba_commands.py +++ b/nba/commands/nba_commands.py @@ -453,6 +453,7 @@ async def playoffs(self, ctx: commands.Context): """ await ctx.typing() try: + def _fetch(): return playoffpicture.PlayoffPicture( timeout=30, headers=NBA_STATS_HEADERS diff --git a/nba/nba.py b/nba/nba.py index cb24cef7..ff56f491 100644 --- a/nba/nba.py +++ b/nba/nba.py @@ -56,6 +56,7 @@ # - Add a pre embed when game(s) are done playing for the day, # with a recap of the day's results (if we can get that data) and a lookahead at the next day's schedule. + class NBA(NBACommands, commands.Cog): """ NBA information cog.