From d39ab056768a76182934f1f9c330632832c95724 Mon Sep 17 00:00:00 2001 From: everestmcarthur Date: Thu, 7 May 2026 23:28:40 +0000 Subject: [PATCH] v1.1.0: embed help by default, CV2 when toggled on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major rewrite of help rendering: - Embed mode (default): Rich embeds with interactive View buttons (⏮◀️▶️⏭) + category select dropdown + close button - CV2 mode (when `cv2 toggle` is on): Components V2 containers with the same navigation - Shared data-gathering layer: both modes use the same logic to gather commands, filter permissions, handle categories - All Red help features: command filtering, permission checks, aliases, subcommands, cooldowns, format_help_for_context - Text fallback for DMs --- newhelpmenu/formatter.py | 1149 +++++++++++++++++++++--------------- newhelpmenu/newhelpmenu.py | 193 +++--- 2 files changed, 796 insertions(+), 546 deletions(-) diff --git a/newhelpmenu/formatter.py b/newhelpmenu/formatter.py index f40a3c1..b80318a 100644 --- a/newhelpmenu/formatter.py +++ b/newhelpmenu/formatter.py @@ -1,412 +1,58 @@ """ -Components V2 Help Formatter for Red-DiscordBot. +Help Formatter for NewHelpMenu — Red-DiscordBot. -Replaces the default embed-based help with a fully Components V2 -layout featuring containers, sections, buttons, and select menus. +Two rendering modes: + 1. Embed mode (default) — rich embeds with a discord.ui.View for buttons/selects + 2. CV2 mode (when toggled) — Components V2 LayoutView with containers + +Both share the same data-gathering logic, only rendering differs. """ from __future__ import annotations import asyncio +import logging from collections import defaultdict from typing import Any, Dict, List, Optional, Sequence, Tuple, Union import discord from discord import ui +from discord.ext import commands as dpy_commands from redbot.core import commands from redbot.core.commands import Command, Group from redbot.core.utils.chat_formatting import humanize_list, pagify +log = logging.getLogger("red.evecogs.newhelpmenu.formatter") -# ──────────────────────────── constants ──────────────────────────── - -MAX_FIELDS_PER_PAGE = 12 -MAX_COMPONENTS = 38 # Leave room for nav action row + safety margin +MAX_FIELDS_PER_PAGE = 8 +EMBED_CHAR_LIMIT = 5800 # Leave margin under 6000 -# ──────────────────────────── layout builders ──────────────────────────── +# ══════════════════════════════════════════════════════════════════ +# DATA GATHERING (shared by both embed and CV2 modes) +# ══════════════════════════════════════════════════════════════════ -def _make_header_container( - title: str, - description: str = "", - *, - accent_color: int = 0x5865F2, - thumbnail_url: Optional[str] = None, - bot_name: str = "Bot", -) -> ui.Container: - """Build the top header container for a help page.""" - container = ui.Container(accent_colour=discord.Colour(accent_color)) - - header_md = f"## {title}" - if description: - header_md += f"\n{description}" - - if thumbnail_url: - section = ui.Section(accessory=ui.Thumbnail(thumbnail_url)) - section.add_item(ui.TextDisplay(header_md)) - container.add_item(section) - else: - container.add_item(ui.TextDisplay(header_md)) - - return container - - -def _make_category_container( - category_name: str, - commands_list: List[Tuple[str, str]], - *, - accent_color: int = 0x5865F2, - emoji: str = "📂", -) -> ui.Container: - """Build a container for a category with its command listing.""" - container = ui.Container(accent_colour=discord.Colour(accent_color)) - container.add_item( - ui.TextDisplay(f"### {emoji} {category_name}") - ) - container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) - - lines: List[str] = [] - for cmd_name, cmd_short in commands_list: - short = cmd_short[:80] if cmd_short else "No description" - lines.append(f"`{cmd_name}` — {short}") - - text = "\n".join(lines) - for chunk in pagify(text, delims=["\n"], page_length=3900): - container.add_item(ui.TextDisplay(chunk)) - - return container - - -def _make_command_container( - cmd: commands.Command, - prefix: str, - *, - accent_color: int = 0x5865F2, -) -> ui.Container: - """Build a detailed container for a single command.""" - container = ui.Container(accent_colour=discord.Colour(accent_color)) - - if isinstance(cmd, commands.Group): - container.add_item(ui.TextDisplay(f"## 📁 {prefix}{cmd.qualified_name}")) - else: - container.add_item(ui.TextDisplay(f"## 🔹 {prefix}{cmd.qualified_name}")) - - container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) - - # Usage - sig = cmd.signature or "" - usage_line = f"**Usage:** `{prefix}{cmd.qualified_name} {sig}`".strip() - container.add_item(ui.TextDisplay(usage_line)) - - # Description - help_text = cmd.help or cmd.brief or cmd.short_doc or "No description provided." - container.add_item(ui.TextDisplay(help_text[:3900])) - - # Aliases - if cmd.aliases: - aliases = ", ".join(f"`{a}`" for a in cmd.aliases) - container.add_item(ui.TextDisplay(f"**Aliases:** {aliases}")) - - # Cooldown - if cmd.cooldown: - cd = cmd.cooldown - container.add_item( - ui.TextDisplay( - f"**Cooldown:** {cd.rate} uses per {cd.per:.0f}s ({cd.type.name})" - ) - ) - - # Subcommands for groups - if isinstance(cmd, commands.Group) and cmd.commands: - container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) - container.add_item(ui.TextDisplay("### Subcommands")) - sub_lines = [] - for sub in sorted(cmd.commands, key=lambda c: c.name): - short = sub.short_doc or "No description" - sub_lines.append(f"`{prefix}{sub.qualified_name}` — {short[:60]}") - sub_text = "\n".join(sub_lines) - for chunk in pagify(sub_text, delims=["\n"], page_length=3900): - container.add_item(ui.TextDisplay(chunk)) - - return container - - -# ──────────────────────────── paginator view ──────────────────────────── - - -class HelpPaginatorView(ui.LayoutView): - """A LayoutView that supports paginated help with button navigation.""" - - def __init__( - self, - pages: List[List[Any]], - *, - author_id: int, - timeout: float = 180.0, - category_options: Optional[List[discord.SelectOption]] = None, - ): - super().__init__(timeout=timeout) - self.pages = pages - self.current_page = 0 - self.author_id = author_id - self.category_options = category_options - self.message: Optional[discord.Message] = None - self._build_page() - - def _build_page(self): - """Populate the view with the current page's components.""" - self.clear_items() - - for component in self.pages[self.current_page]: - self.add_item(component) - - if len(self.pages) > 1: - self.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) - - nav_row = ui.ActionRow() - - nav_row.add_item(ui.Button( - emoji="⏮️", - style=discord.ButtonStyle.secondary, - custom_id="help_first", - disabled=self.current_page == 0, - )) - nav_row.add_item(ui.Button( - emoji="◀️", - style=discord.ButtonStyle.primary, - custom_id="help_prev", - disabled=self.current_page == 0, - )) - nav_row.add_item(ui.Button( - label=f"{self.current_page + 1}/{len(self.pages)}", - style=discord.ButtonStyle.secondary, - custom_id="help_page_indicator", - disabled=True, - )) - nav_row.add_item(ui.Button( - emoji="▶️", - style=discord.ButtonStyle.primary, - custom_id="help_next", - disabled=self.current_page >= len(self.pages) - 1, - )) - nav_row.add_item(ui.Button( - emoji="⏭️", - style=discord.ButtonStyle.secondary, - custom_id="help_last", - disabled=self.current_page >= len(self.pages) - 1, - )) - self.add_item(nav_row) - - close_row = ui.ActionRow() - close_row.add_item(ui.Button( - label="Close", - emoji="🗑️", - style=discord.ButtonStyle.danger, - custom_id="help_close", - )) - - if self.category_options and len(self.category_options) > 1: - close_row.add_item(ui.Select( - placeholder="Jump to category...", - options=self.category_options[:25], - custom_id="help_category_select", - )) - self.add_item(close_row) - - async def interaction_check(self, interaction: discord.Interaction) -> bool: - if interaction.user.id != self.author_id: - await interaction.response.send_message( - "This help menu isn't yours!", ephemeral=True - ) - return False - return True - - async def on_timeout(self): - if self.message: - try: - self.clear_items() - for component in self.pages[self.current_page]: - self.add_item(component) - self.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) - self.add_item(ui.TextDisplay("-# Help menu timed out. Run the help command again.")) - await self.message.edit(view=self) - except (discord.NotFound, discord.HTTPException): - pass - - async def _handle_nav(self, interaction: discord.Interaction, page: int): - self.current_page = max(0, min(page, len(self.pages) - 1)) - self._build_page() - await interaction.response.edit_message(view=self) - - async def handle_interaction(self, interaction: discord.Interaction): - """Route interactions to the correct handler.""" - custom_id = interaction.data.get("custom_id", "") if interaction.data else "" - - if custom_id == "help_first": - await self._handle_nav(interaction, 0) - elif custom_id == "help_prev": - await self._handle_nav(interaction, self.current_page - 1) - elif custom_id == "help_next": - await self._handle_nav(interaction, self.current_page + 1) - elif custom_id == "help_last": - await self._handle_nav(interaction, len(self.pages) - 1) - elif custom_id == "help_close": - self.stop() - if self.message: - try: - await self.message.delete() - except (discord.NotFound, discord.HTTPException): - pass - elif custom_id == "help_category_select": - values = interaction.data.get("values", []) if interaction.data else [] - if values: - try: - page_idx = int(values[0]) - await self._handle_nav(interaction, page_idx) - except (ValueError, IndexError): - pass - - -# ──────────────────────────── menu paginator ──────────────────────────── - - -class CV2MenuPaginator(ui.LayoutView): - """General-purpose Components V2 paginator to replace Red's menu system.""" - - def __init__( - self, - pages: List[Any], - *, - author_id: int, - timeout: float = 120.0, - accent_color: int = 0x5865F2, - ): - super().__init__(timeout=timeout) - self.raw_pages = pages - self.current_page = 0 - self.author_id = author_id - self.accent_color = accent_color - self.message: Optional[discord.Message] = None - self._build_page() - - def _convert_page(self, page: Any) -> List[Any]: - """Convert a raw page into components.""" - from .converter import embed_to_container - - if isinstance(page, discord.Embed): - return [embed_to_container(page, accent_color=self.accent_color)] - elif isinstance(page, str): - container = ui.Container(accent_colour=discord.Colour(self.accent_color)) - for chunk in pagify(page, page_length=3900): - container.add_item(ui.TextDisplay(chunk)) - return [container] - elif isinstance(page, (ui.Container, ui.TextDisplay)): - return [page] - elif isinstance(page, list): - return page - else: - return [ui.TextDisplay(str(page)[:3900])] - - def _build_page(self): - self.clear_items() - - components = self._convert_page(self.raw_pages[self.current_page]) - for comp in components: - self.add_item(comp) - - if len(self.raw_pages) > 1: - self.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) - nav_row = ui.ActionRow() - - nav_row.add_item(ui.Button( - emoji="◀️", - style=discord.ButtonStyle.primary, - custom_id="cv2menu_prev", - disabled=self.current_page == 0, - )) - nav_row.add_item(ui.Button( - label=f"{self.current_page + 1}/{len(self.raw_pages)}", - style=discord.ButtonStyle.secondary, - custom_id="cv2menu_indicator", - disabled=True, - )) - nav_row.add_item(ui.Button( - emoji="▶️", - style=discord.ButtonStyle.primary, - custom_id="cv2menu_next", - disabled=self.current_page >= len(self.raw_pages) - 1, - )) - nav_row.add_item(ui.Button( - label="Close", - emoji="🗑️", - style=discord.ButtonStyle.danger, - custom_id="cv2menu_close", - )) - self.add_item(nav_row) - - async def interaction_check(self, interaction: discord.Interaction) -> bool: - if interaction.user.id != self.author_id: - await interaction.response.send_message( - "This menu isn't yours!", ephemeral=True - ) - return False - return True - - async def on_timeout(self): - if self.message: - try: - self.clear_items() - components = self._convert_page(self.raw_pages[self.current_page]) - for comp in components: - self.add_item(comp) - self.add_item(ui.TextDisplay("-# Menu timed out.")) - await self.message.edit(view=self) - except (discord.NotFound, discord.HTTPException): - pass - - async def handle_interaction(self, interaction: discord.Interaction): - custom_id = interaction.data.get("custom_id", "") if interaction.data else "" - if custom_id == "cv2menu_prev": - self.current_page = max(0, self.current_page - 1) - self._build_page() - await interaction.response.edit_message(view=self) - elif custom_id == "cv2menu_next": - self.current_page = min(len(self.raw_pages) - 1, self.current_page + 1) - self._build_page() - await interaction.response.edit_message(view=self) - elif custom_id == "cv2menu_close": - self.stop() - if self.message: - try: - await self.message.delete() - except (discord.NotFound, discord.HTTPException): - pass - - -# ──────────────────────────── help builder ──────────────────────────── - - -async def build_bot_help_pages( +async def gather_bot_help_data( ctx: commands.Context, - bot: commands.Bot, + bot, *, categories: Dict[str, List[str]], category_emojis: Dict[str, str], - accent_color: int, show_hidden: bool = False, - blacklisted_cogs: List[str] = None, - blacklisted_commands: List[str] = None, -) -> Tuple[List[List[Any]], List[discord.SelectOption]]: - """Build paginated help pages for the full bot. + blacklisted_cogs: Optional[List[str]] = None, + blacklisted_commands: Optional[List[str]] = None, +) -> Tuple[List[Tuple[str, str, List[Tuple[str, str]]]], int]: + """Gather help data for all bot commands. - Returns (pages, category_select_options). + Returns: + (category_list, total_commands) + category_list: [(category_name, emoji, [(cmd_name, short_doc), ...]), ...] """ blacklisted_cogs = blacklisted_cogs or [] blacklisted_commands = blacklisted_commands or [] - prefix = ctx.clean_prefix - # Gather all visible cogs and their commands + # Gather all visible commands grouped by cog cog_commands: Dict[str, List[commands.Command]] = defaultdict(list) for cmd in sorted(bot.commands, key=lambda c: c.qualified_name): @@ -425,7 +71,7 @@ async def build_bot_help_pages( continue cog_commands[cog_name].append(cmd) - # Build category mappings + # Map cogs → categories cog_to_category: Dict[str, str] = {} for cat_name, cog_list in categories.items(): for cog_name in cog_list: @@ -433,101 +79,43 @@ async def build_bot_help_pages( # Group by category category_data: Dict[str, List[Tuple[str, str]]] = defaultdict(list) + total = 0 for cog_name, cmds in cog_commands.items(): cat = cog_to_category.get(cog_name, cog_name) for cmd in cmds: - short = cmd.short_doc or cmd.brief or "No description" + short = cmd.format_shortdoc_for_context(ctx) if hasattr(cmd, 'format_shortdoc_for_context') else (cmd.short_doc or "No description") category_data[cat].append((f"{prefix}{cmd.qualified_name}", short)) + total += 1 sorted_cats = sorted(category_data.keys()) - - # ── Build pages ── - pages: List[List[Any]] = [] - select_options: List[discord.SelectOption] = [] - - # Page 0: Overview - bot_user = bot.user - bot_name = bot_user.display_name if bot_user else "Bot" - bot_avatar = bot_user.display_avatar.url if bot_user else None - - overview_components: List[Any] = [] - - header = _make_header_container( - f"{bot_name} — Help", - f"Use `{prefix}help ` for details on a command.\nUse `{prefix}help ` for a category overview.\n\n**{len(sorted_cats)} categories · {sum(len(v) for v in category_data.values())} commands**", - accent_color=accent_color, - thumbnail_url=bot_avatar, - bot_name=bot_name, - ) - overview_components.append(header) - - summary_lines: List[str] = [] - for i, cat in enumerate(sorted_cats): - emoji = category_emojis.get(cat, "📂") - cmd_count = len(category_data[cat]) - summary_lines.append(f"{emoji} **{cat}** — {cmd_count} command{'s' if cmd_count != 1 else ''}") - select_options.append( - discord.SelectOption( - label=cat[:100], - value=str(i + 1), - emoji=emoji if len(emoji) <= 2 else None, - description=f"{cmd_count} commands", - ) - ) - - summary_container = ui.Container(accent_colour=discord.Colour(accent_color)) - summary_container.add_item(ui.TextDisplay("### 📚 Categories")) - summary_container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) - summary_text = "\n".join(summary_lines) - for chunk in pagify(summary_text, delims=["\n"], page_length=3900): - summary_container.add_item(ui.TextDisplay(chunk)) - overview_components.append(summary_container) - - pages.append(overview_components) - - # Category pages + result = [] for cat in sorted_cats: - cmds = category_data[cat] emoji = category_emojis.get(cat, "📂") + result.append((cat, emoji, category_data[cat])) - for chunk_start in range(0, len(cmds), MAX_FIELDS_PER_PAGE): - chunk = cmds[chunk_start: chunk_start + MAX_FIELDS_PER_PAGE] - page_components: List[Any] = [] - container = _make_category_container( - cat, chunk, accent_color=accent_color, emoji=emoji - ) - page_components.append(container) - pages.append(page_components) - - return pages, select_options + return result, total -async def build_command_help_page( - ctx: commands.Context, - cmd: commands.Command, - *, - accent_color: int, -) -> List[Any]: - """Build a single-page help view for a specific command.""" - prefix = ctx.clean_prefix - return [_make_command_container(cmd, prefix, accent_color=accent_color)] - - -async def build_cog_help_page( +async def gather_cog_help_data( ctx: commands.Context, cog: commands.Cog, *, - accent_color: int, show_hidden: bool = False, -) -> List[Any]: - """Build a help page for a specific cog.""" +) -> Tuple[str, str, List[Tuple[str, str, str]]]: + """Gather help data for a cog. + + Returns: + (cog_name, cog_doc, [(qualified_name, signature, short_doc), ...]) + """ prefix = ctx.clean_prefix - components: List[Any] = [] - cog_name = cog.qualified_name - cog_doc = cog.help or cog.__doc__ or "No description." + cog_doc = "" + if hasattr(cog, 'format_help_for_context'): + cog_doc = cog.format_help_for_context(ctx) + if not cog_doc: + cog_doc = cog.help or cog.__doc__ or "No description." - cmds: List[Tuple[str, str]] = [] + cmds = [] for cmd in sorted(cog.get_commands(), key=lambda c: c.qualified_name): if cmd.hidden and not show_hidden: continue @@ -536,19 +124,652 @@ async def build_cog_help_page( continue except Exception: continue - short = cmd.short_doc or "No description" - cmds.append((f"{prefix}{cmd.qualified_name}", short)) + sig = cmd.signature or "" + short = cmd.format_shortdoc_for_context(ctx) if hasattr(cmd, 'format_shortdoc_for_context') else (cmd.short_doc or "No description") + cmds.append((f"{prefix}{cmd.qualified_name}", sig, short)) - header = ui.Container(accent_colour=discord.Colour(accent_color)) + return cog_name, cog_doc, cmds + + +async def gather_command_help_data( + ctx: commands.Context, + cmd: commands.Command, +) -> Dict[str, Any]: + """Gather help data for a single command. + + Returns dict with: name, signature, description, aliases, subcommands, cooldown + """ + prefix = ctx.clean_prefix + parent_sig = "" + parent = cmd.parent + entries = [] + while parent is not None: + if not parent.signature or parent.invoke_without_command: + entries.append(parent.name) + else: + entries.append(parent.name + " " + parent.signature) + parent = parent.parent + if entries: + parent_sig = " ".join(reversed(entries)) + " " + + signature = f"{prefix}{parent_sig}{cmd.name} {cmd.signature}".strip() + + help_text = "" + if hasattr(cmd, 'format_help_for_context'): + help_text = cmd.format_help_for_context(ctx) + if not help_text: + help_text = cmd.help or cmd.description or cmd.short_doc or "No description provided." + + data = { + "name": cmd.qualified_name, + "signature": signature, + "description": help_text, + "aliases": [], + "subcommands": [], + "cooldown": None, + "is_group": isinstance(cmd, commands.Group), + } + + if cmd.aliases: + data["aliases"] = sorted(cmd.aliases, key=len) + + if isinstance(cmd, commands.Group) and cmd.commands: + for sub in sorted(cmd.commands, key=lambda c: c.name): + if sub.hidden: + continue + sub_short = sub.format_shortdoc_for_context(ctx) if hasattr(sub, 'format_shortdoc_for_context') else (sub.short_doc or "No description") + data["subcommands"].append((f"{prefix}{sub.qualified_name}", sub_short)) + + if cmd.cooldown: + cd = cmd.cooldown + data["cooldown"] = f"{cd.rate} use{'s' if cd.rate > 1 else ''} per {cd.per:.0f}s ({cd.type.name})" + + return data + + +# ══════════════════════════════════════════════════════════════════ +# EMBED MODE — Rich embeds with discord.ui.View buttons/selects +# ══════════════════════════════════════════════════════════════════ + + +class EmbedHelpView(discord.ui.View): + """An interactive embed-based help menu with buttons and category select.""" + + def __init__( + self, + pages: List[discord.Embed], + *, + author_id: int, + timeout: float = 180.0, + category_options: Optional[List[discord.SelectOption]] = None, + ): + super().__init__(timeout=timeout) + self.pages = pages + self.current_page = 0 + self.author_id = author_id + self.message: Optional[discord.Message] = None + + # Add category select if we have categories + if category_options and len(category_options) > 1: + select = discord.ui.Select( + placeholder="Jump to category…", + options=category_options[:25], + custom_id="help_cat_select", + row=0, + ) + select.callback = self._select_callback + self.add_item(select) + + # Navigation buttons + self.first_btn = discord.ui.Button(emoji="⏮️", style=discord.ButtonStyle.secondary, row=1) + self.first_btn.callback = self._first + self.add_item(self.first_btn) + + self.prev_btn = discord.ui.Button(emoji="◀️", style=discord.ButtonStyle.primary, row=1) + self.prev_btn.callback = self._prev + self.add_item(self.prev_btn) + + self.indicator = discord.ui.Button( + label=f"1/{len(pages)}", style=discord.ButtonStyle.secondary, disabled=True, row=1 + ) + self.add_item(self.indicator) + + self.next_btn = discord.ui.Button(emoji="▶️", style=discord.ButtonStyle.primary, row=1) + self.next_btn.callback = self._next + self.add_item(self.next_btn) + + self.last_btn = discord.ui.Button(emoji="⏭️", style=discord.ButtonStyle.secondary, row=1) + self.last_btn.callback = self._last + self.add_item(self.last_btn) + + self.close_btn = discord.ui.Button( + label="Close", emoji="🗑️", style=discord.ButtonStyle.danger, row=2 + ) + self.close_btn.callback = self._close + self.add_item(self.close_btn) + + self._update_buttons() + + def _update_buttons(self): + at_start = self.current_page == 0 + at_end = self.current_page >= len(self.pages) - 1 + self.first_btn.disabled = at_start + self.prev_btn.disabled = at_start + self.next_btn.disabled = at_end + self.last_btn.disabled = at_end + self.indicator.label = f"{self.current_page + 1}/{len(self.pages)}" + + async def _go_to(self, interaction: discord.Interaction, page: int): + self.current_page = max(0, min(page, len(self.pages) - 1)) + self._update_buttons() + await interaction.response.edit_message(embed=self.pages[self.current_page], view=self) + + async def _first(self, interaction: discord.Interaction): + await self._go_to(interaction, 0) + + async def _prev(self, interaction: discord.Interaction): + await self._go_to(interaction, self.current_page - 1) + + async def _next(self, interaction: discord.Interaction): + await self._go_to(interaction, self.current_page + 1) + + async def _last(self, interaction: discord.Interaction): + await self._go_to(interaction, len(self.pages) - 1) + + async def _close(self, interaction: discord.Interaction): + self.stop() + if self.message: + try: + await self.message.delete() + except (discord.NotFound, discord.HTTPException): + pass + + async def _select_callback(self, interaction: discord.Interaction): + values = interaction.data.get("values", []) if interaction.data else [] + if values: + try: + page_idx = int(values[0]) + await self._go_to(interaction, page_idx) + except (ValueError, IndexError): + await interaction.response.defer() + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.author_id: + await interaction.response.send_message("This help menu isn't yours!", ephemeral=True) + return False + return True + + async def on_timeout(self): + for item in self.children: + if hasattr(item, "disabled"): + item.disabled = True + if self.message: + try: + await self.message.edit(view=self) + except (discord.NotFound, discord.HTTPException): + pass + + +def build_bot_help_embeds( + ctx, + bot, + category_data: List[Tuple[str, str, List[Tuple[str, str]]]], + total_commands: int, + *, + accent_color: int, +) -> Tuple[List[discord.Embed], List[discord.SelectOption]]: + """Build embed pages for the full bot help. + + Returns (embeds, select_options). + """ + prefix = ctx.clean_prefix + bot_name = bot.user.display_name if bot.user else "Bot" + bot_avatar = bot.user.display_avatar.url if bot.user else None + color = discord.Colour(accent_color) + + pages: List[discord.Embed] = [] + select_options: List[discord.SelectOption] = [] + + # Page 0: Overview + overview = discord.Embed( + title=f"{bot_name} — Help", + description=( + f"Use `{prefix}help ` for details on a command.\n" + f"Use `{prefix}help ` for a category overview.\n\n" + f"**{len(category_data)} categories · {total_commands} commands**" + ), + color=color, + ) + if bot_avatar: + overview.set_thumbnail(url=bot_avatar) + + for cat_name, emoji, cmds in category_data: + cmd_names = ", ".join(f"`{name.split()[-1]}`" for name, _ in cmds[:15]) + if len(cmds) > 15: + cmd_names += f" *+{len(cmds) - 15} more*" + overview.add_field( + name=f"{emoji} {cat_name} ({len(cmds)})", + value=cmd_names or "No commands", + inline=False, + ) + + overview.set_footer(text=f"Type {prefix}help for more info on a command.") + pages.append(overview) + + # Category pages + for idx, (cat_name, emoji, cmds) in enumerate(category_data): + select_options.append(discord.SelectOption( + label=cat_name[:100], + value=str(idx + 1), + emoji=emoji if len(emoji) <= 2 else None, + description=f"{len(cmds)} commands", + )) + + # Split into multiple pages if many commands + for chunk_start in range(0, len(cmds), MAX_FIELDS_PER_PAGE): + chunk = cmds[chunk_start:chunk_start + MAX_FIELDS_PER_PAGE] + embed = discord.Embed( + title=f"{emoji} {cat_name}", + color=color, + ) + if bot_avatar: + embed.set_thumbnail(url=bot_avatar) + + for cmd_name, short in chunk: + # Use inline=False for readability + embed.add_field( + name=cmd_name, + value=short[:100] if short else "No description", + inline=True, + ) + + embed.set_footer(text=f"Type {prefix}help for more details.") + pages.append(embed) + + return pages, select_options + + +def build_command_help_embed( + ctx, + data: Dict[str, Any], + *, + accent_color: int, +) -> discord.Embed: + """Build an embed for a single command.""" + prefix = ctx.clean_prefix + bot_avatar = ctx.bot.user.display_avatar.url if ctx.bot.user else None + color = discord.Colour(accent_color) + + title = f"{'📁' if data['is_group'] else '🔹'} {data['name']}" + embed = discord.Embed(title=title, color=color) + if bot_avatar: + embed.set_thumbnail(url=bot_avatar) + + # Syntax + embed.add_field(name="Syntax", value=f"```\n{data['signature']}\n```", inline=False) + + # Description + desc = data["description"] + if len(desc) > 1024: + desc = desc[:1021] + "..." + embed.add_field(name="Description", value=desc, inline=False) + + # Aliases + if data["aliases"]: + alias_text = ", ".join(f"`{prefix}{data['name'].rsplit(' ', 1)[0] + ' ' if ' ' in data['name'] else ''}{a}`" for a in data["aliases"][:10]) + embed.add_field(name="Aliases", value=alias_text, inline=False) + + # Cooldown + if data["cooldown"]: + embed.add_field(name="Cooldown", value=data["cooldown"], inline=True) + + # Subcommands + if data["subcommands"]: + sub_text = "\n".join(f"`{name}` — {short[:60]}" for name, short in data["subcommands"]) + if len(sub_text) > 1024: + sub_text = sub_text[:1021] + "..." + embed.add_field(name="Subcommands", value=sub_text, inline=False) + + embed.set_footer(text=f"Type {prefix}help for more details.") + return embed + + +def build_cog_help_embed( + ctx, + cog_name: str, + cog_doc: str, + cmds: List[Tuple[str, str, str]], + *, + accent_color: int, +) -> discord.Embed: + """Build an embed for a cog's help page.""" + prefix = ctx.clean_prefix + bot_avatar = ctx.bot.user.display_avatar.url if ctx.bot.user else None + color = discord.Colour(accent_color) + + embed = discord.Embed(title=f"📂 {cog_name}", description=cog_doc[:2048], color=color) + if bot_avatar: + embed.set_thumbnail(url=bot_avatar) + + if cmds: + cmd_lines = [] + for qname, sig, short in cmds: + cmd_lines.append(f"`{qname}` — {short[:80]}") + cmd_text = "\n".join(cmd_lines) + for i, chunk in enumerate(pagify(cmd_text, delims=["\n"], page_length=1024)): + name = "Commands" if i == 0 else "Commands (continued)" + embed.add_field(name=name, value=chunk, inline=False) + + embed.set_footer(text=f"Type {prefix}help for more details.") + return embed + + +# ══════════════════════════════════════════════════════════════════ +# CV2 MODE — Components V2 LayoutView with containers +# ══════════════════════════════════════════════════════════════════ + + +def _make_category_container( + category_name: str, + commands_list: List[Tuple[str, str]], + *, + accent_color: int = 0x5865F2, + emoji: str = "📂", +) -> ui.Container: + """Build a CV2 container for a category.""" + container = ui.Container(accent_colour=discord.Colour(accent_color)) + container.add_item(ui.TextDisplay(f"### {emoji} {category_name}")) + container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + + lines = [] + for cmd_name, cmd_short in commands_list: + short = cmd_short[:80] if cmd_short else "No description" + lines.append(f"`{cmd_name}` — {short}") + + text = "\n".join(lines) + for chunk in pagify(text, delims=["\n"], page_length=3900): + container.add_item(ui.TextDisplay(chunk)) + + return container + + +class HelpPaginatorView(ui.LayoutView): + """CV2 LayoutView paginator for help.""" + + def __init__( + self, + pages: List[List[Any]], + *, + author_id: int, + timeout: float = 180.0, + category_options: Optional[List[discord.SelectOption]] = None, + ): + super().__init__(timeout=timeout) + self.pages = pages + self.current_page = 0 + self.author_id = author_id + self.category_options = category_options + self.message: Optional[discord.Message] = None + self._build_page() + + def _build_page(self): + self.clear_items() + for component in self.pages[self.current_page]: + self.add_item(component) + + if len(self.pages) > 1: + self.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + nav_row = ui.ActionRow() + nav_row.add_item(ui.Button(emoji="⏮️", style=discord.ButtonStyle.secondary, custom_id="help_first", disabled=self.current_page == 0)) + nav_row.add_item(ui.Button(emoji="◀️", style=discord.ButtonStyle.primary, custom_id="help_prev", disabled=self.current_page == 0)) + nav_row.add_item(ui.Button(label=f"{self.current_page + 1}/{len(self.pages)}", style=discord.ButtonStyle.secondary, custom_id="help_page_ind", disabled=True)) + nav_row.add_item(ui.Button(emoji="▶️", style=discord.ButtonStyle.primary, custom_id="help_next", disabled=self.current_page >= len(self.pages) - 1)) + nav_row.add_item(ui.Button(emoji="⏭️", style=discord.ButtonStyle.secondary, custom_id="help_last", disabled=self.current_page >= len(self.pages) - 1)) + self.add_item(nav_row) + + close_row = ui.ActionRow() + close_row.add_item(ui.Button(label="Close", emoji="🗑️", style=discord.ButtonStyle.danger, custom_id="help_close")) + if self.category_options and len(self.category_options) > 1: + close_row.add_item(ui.Select(placeholder="Jump to category…", options=self.category_options[:25], custom_id="help_category_select")) + self.add_item(close_row) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.author_id: + await interaction.response.send_message("This help menu isn't yours!", ephemeral=True) + return False + return True + + async def on_timeout(self): + if self.message: + try: + self.clear_items() + for component in self.pages[self.current_page]: + self.add_item(component) + self.add_item(ui.TextDisplay("-# Help menu timed out.")) + await self.message.edit(view=self) + except (discord.NotFound, discord.HTTPException): + pass + + async def handle_interaction(self, interaction: discord.Interaction): + custom_id = interaction.data.get("custom_id", "") if interaction.data else "" + if custom_id == "help_first": + page = 0 + elif custom_id == "help_prev": + page = self.current_page - 1 + elif custom_id == "help_next": + page = self.current_page + 1 + elif custom_id == "help_last": + page = len(self.pages) - 1 + elif custom_id == "help_close": + self.stop() + if self.message: + try: + await self.message.delete() + except (discord.NotFound, discord.HTTPException): + pass + return + elif custom_id == "help_category_select": + values = interaction.data.get("values", []) if interaction.data else [] + if values: + try: + page = int(values[0]) + except (ValueError, IndexError): + return + else: + return + else: + return + + self.current_page = max(0, min(page, len(self.pages) - 1)) + self._build_page() + await interaction.response.edit_message(view=self) + + +def build_cv2_bot_help_pages( + ctx, + bot, + category_data: List[Tuple[str, str, List[Tuple[str, str]]]], + total_commands: int, + *, + accent_color: int, +) -> Tuple[List[List[Any]], List[discord.SelectOption]]: + """Build CV2 pages for the full bot help.""" + prefix = ctx.clean_prefix + bot_name = bot.user.display_name if bot.user else "Bot" + bot_avatar = bot.user.display_avatar.url if bot.user else None + color = discord.Colour(accent_color) + + pages: List[List[Any]] = [] + select_options: List[discord.SelectOption] = [] + + # Overview page + overview_components: List[Any] = [] + header = ui.Container(accent_colour=color) + header_md = f"## {bot_name} — Help\nUse `{prefix}help ` for details.\n\n**{len(category_data)} categories · {total_commands} commands**" + if bot_avatar: + section = ui.Section(accessory=ui.Thumbnail(bot_avatar)) + section.add_item(ui.TextDisplay(header_md)) + header.add_item(section) + else: + header.add_item(ui.TextDisplay(header_md)) + overview_components.append(header) + + summary = ui.Container(accent_colour=color) + summary.add_item(ui.TextDisplay("### 📚 Categories")) + summary.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + summary_lines = [] + for i, (cat_name, emoji, cmds) in enumerate(category_data): + summary_lines.append(f"{emoji} **{cat_name}** — {len(cmds)} command{'s' if len(cmds) != 1 else ''}") + select_options.append(discord.SelectOption( + label=cat_name[:100], value=str(i + 1), + emoji=emoji if len(emoji) <= 2 else None, + description=f"{len(cmds)} commands", + )) + summary.add_item(ui.TextDisplay("\n".join(summary_lines))) + overview_components.append(summary) + pages.append(overview_components) + + # Category pages + for cat_name, emoji, cmds in category_data: + for chunk_start in range(0, len(cmds), MAX_FIELDS_PER_PAGE): + chunk = cmds[chunk_start:chunk_start + MAX_FIELDS_PER_PAGE] + page_components: List[Any] = [_make_category_container(cat_name, chunk, accent_color=accent_color, emoji=emoji)] + pages.append(page_components) + + return pages, select_options + + +def build_cv2_command_help( + ctx, + data: Dict[str, Any], + *, + accent_color: int, +) -> List[Any]: + """Build CV2 components for a command.""" + prefix = ctx.clean_prefix + color = discord.Colour(accent_color) + container = ui.Container(accent_colour=color) + + icon = "📁" if data["is_group"] else "🔹" + container.add_item(ui.TextDisplay(f"## {icon} {data['name']}")) + container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + container.add_item(ui.TextDisplay(f"**Syntax:** `{data['signature']}`")) + container.add_item(ui.TextDisplay(data["description"][:3900])) + + if data["aliases"]: + aliases = ", ".join(f"`{a}`" for a in data["aliases"][:10]) + container.add_item(ui.TextDisplay(f"**Aliases:** {aliases}")) + + if data["cooldown"]: + container.add_item(ui.TextDisplay(f"**Cooldown:** {data['cooldown']}")) + + if data["subcommands"]: + container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + container.add_item(ui.TextDisplay("### Subcommands")) + sub_lines = [f"`{name}` — {short[:60]}" for name, short in data["subcommands"]] + container.add_item(ui.TextDisplay("\n".join(sub_lines))) + + return [container] + + +def build_cv2_cog_help( + ctx, + cog_name: str, + cog_doc: str, + cmds: List[Tuple[str, str, str]], + *, + accent_color: int, +) -> List[Any]: + """Build CV2 components for a cog.""" + color = discord.Colour(accent_color) + + header = ui.Container(accent_colour=color) header.add_item(ui.TextDisplay(f"## 📂 {cog_name}")) header.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) header.add_item(ui.TextDisplay(cog_doc[:3900])) - components.append(header) + components: List[Any] = [header] if cmds: - cmd_container = _make_category_container( - "Commands", cmds, accent_color=accent_color, emoji="🔹" - ) - components.append(cmd_container) + cmd_list = [(qname, short) for qname, _, short in cmds] + components.append(_make_category_container("Commands", cmd_list, accent_color=accent_color, emoji="🔹")) return components + + +# ══════════════════════════════════════════════════════════════════ +# CV2 MENU PAGINATOR (general purpose) +# ══════════════════════════════════════════════════════════════════ + + +class CV2MenuPaginator(ui.LayoutView): + """General-purpose CV2 paginator to replace Red's menu system.""" + + def __init__(self, pages: List[Any], *, author_id: int, timeout: float = 120.0, accent_color: int = 0x5865F2): + super().__init__(timeout=timeout) + self.raw_pages = pages + self.current_page = 0 + self.author_id = author_id + self.accent_color = accent_color + self.message: Optional[discord.Message] = None + self._build_page() + + def _convert_page(self, page: Any) -> List[Any]: + from .converter import embed_to_container + if isinstance(page, discord.Embed): + return [embed_to_container(page, accent_color=self.accent_color)] + elif isinstance(page, str): + container = ui.Container(accent_colour=discord.Colour(self.accent_color)) + container.add_item(ui.TextDisplay(page[:3900])) + return [container] + elif isinstance(page, list): + return page + return [ui.TextDisplay(str(page)[:3900])] + + def _build_page(self): + self.clear_items() + for comp in self._convert_page(self.raw_pages[self.current_page]): + self.add_item(comp) + if len(self.raw_pages) > 1: + self.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + nav_row = ui.ActionRow() + nav_row.add_item(ui.Button(emoji="◀️", style=discord.ButtonStyle.primary, custom_id="cv2menu_prev", disabled=self.current_page == 0)) + nav_row.add_item(ui.Button(label=f"{self.current_page + 1}/{len(self.raw_pages)}", style=discord.ButtonStyle.secondary, custom_id="cv2menu_ind", disabled=True)) + nav_row.add_item(ui.Button(emoji="▶️", style=discord.ButtonStyle.primary, custom_id="cv2menu_next", disabled=self.current_page >= len(self.raw_pages) - 1)) + nav_row.add_item(ui.Button(label="Close", emoji="🗑️", style=discord.ButtonStyle.danger, custom_id="cv2menu_close")) + self.add_item(nav_row) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.author_id: + await interaction.response.send_message("This menu isn't yours!", ephemeral=True) + return False + return True + + async def on_timeout(self): + if self.message: + try: + self.clear_items() + for comp in self._convert_page(self.raw_pages[self.current_page]): + self.add_item(comp) + self.add_item(ui.TextDisplay("-# Menu timed out.")) + await self.message.edit(view=self) + except (discord.NotFound, discord.HTTPException): + pass + + async def handle_interaction(self, interaction: discord.Interaction): + custom_id = interaction.data.get("custom_id", "") if interaction.data else "" + if custom_id == "cv2menu_prev": + self.current_page = max(0, self.current_page - 1) + elif custom_id == "cv2menu_next": + self.current_page = min(len(self.raw_pages) - 1, self.current_page + 1) + elif custom_id == "cv2menu_close": + self.stop() + if self.message: + try: + await self.message.delete() + except (discord.NotFound, discord.HTTPException): + pass + return + else: + return + self._build_page() + await interaction.response.edit_message(view=self) diff --git a/newhelpmenu/newhelpmenu.py b/newhelpmenu/newhelpmenu.py index 20ecf9a..5e87f3e 100644 --- a/newhelpmenu/newhelpmenu.py +++ b/newhelpmenu/newhelpmenu.py @@ -23,10 +23,18 @@ from redbot.core.utils.chat_formatting import box, humanize_list, pagify from .converter import embed_to_container, embeds_to_layout, view_items_to_action_rows from .formatter import ( CV2MenuPaginator, + EmbedHelpView, HelpPaginatorView, - build_bot_help_pages, - build_cog_help_page, - build_command_help_page, + gather_bot_help_data, + gather_cog_help_data, + gather_command_help_data, + build_bot_help_embeds, + build_command_help_embed, + build_cog_help_embed, + build_cv2_bot_help_pages, + build_cv2_command_help, + build_cv2_cog_help, + _make_category_container, ) log = logging.getLogger("red.evecogs.newhelpmenu") @@ -246,15 +254,18 @@ class NewHelpMenu(commands.Cog): log.info("NewHelpMenu: Restored original send methods") # ═══════════════════════════════════════════════════════════════ - # HELP SYSTEM + # HELP SYSTEM — Embeds by default, CV2 when enabled # ═══════════════════════════════════════════════════════════════ - async def _send_cv2_help( + async def _send_help( self, ctx: commands.Context, thing: Optional[str] = None, ): - """Send Components V2 help for the bot, a cog, or a command.""" + """Send help for the bot, a cog, or a command. + + Uses embeds by default. When CV2 is enabled, uses Components V2 containers. + """ settings = await self.config.guild(ctx.guild).all() accent = settings["accent_color"] categories = settings["categories"] @@ -263,71 +274,85 @@ class NewHelpMenu(commands.Cog): blacklisted_commands = settings["blacklisted_commands"] show_hidden = settings["show_hidden"] timeout = settings["help_timeout"] + use_cv2 = settings["enabled"] + destination = ctx.author if settings["help_in_dm"] else ctx.channel if thing is None: - # Full bot help - pages, select_options = await build_bot_help_pages( - ctx, - self.bot, + # ── Full bot help ── + cat_data, total = await gather_bot_help_data( + ctx, self.bot, categories=categories, category_emojis=category_emojis, - accent_color=accent, show_hidden=show_hidden, blacklisted_cogs=blacklisted_cogs, blacklisted_commands=blacklisted_commands, ) - view = HelpPaginatorView( - pages, - author_id=ctx.author.id, - timeout=float(timeout), - category_options=select_options, - ) - - destination = ctx.author if settings["help_in_dm"] else ctx.channel - msg = await destination.send(view=view) - view.message = msg - self._active_views[msg.id] = view + if use_cv2: + pages, select_opts = build_cv2_bot_help_pages( + ctx, self.bot, cat_data, total, accent_color=accent + ) + view = HelpPaginatorView( + pages, author_id=ctx.author.id, + timeout=float(timeout), category_options=select_opts, + ) + msg = await destination.send(view=view) + view.message = msg + self._active_views[msg.id] = view + else: + embeds, select_opts = build_bot_help_embeds( + ctx, self.bot, cat_data, total, accent_color=accent + ) + if len(embeds) == 1: + msg = await destination.send(embed=embeds[0]) + else: + view = EmbedHelpView( + embeds, author_id=ctx.author.id, + timeout=float(timeout), category_options=select_opts, + ) + msg = await destination.send(embed=embeds[0], view=view) + view.message = msg else: - # Try to find a command first, then a cog + # ── Specific thing ── cmd = self.bot.get_command(thing) if cmd: - components = await build_command_help_page( - ctx, cmd, accent_color=accent - ) - layout = ui.LayoutView() - for comp in components: - layout.add_item(comp) - - destination = ctx.author if settings["help_in_dm"] else ctx.channel - msg = await destination.send(view=layout) - self._active_views[msg.id] = layout + data = await gather_command_help_data(ctx, cmd) + if use_cv2: + components = build_cv2_command_help(ctx, data, accent_color=accent) + layout = ui.LayoutView() + for comp in components: + layout.add_item(comp) + msg = await destination.send(view=layout) + self._active_views[msg.id] = layout + else: + embed = build_command_help_embed(ctx, data, accent_color=accent) + await destination.send(embed=embed) return - # Try cog cog = self.bot.get_cog(thing) if cog: - components = await build_cog_help_page( - ctx, cog, accent_color=accent, show_hidden=show_hidden - ) - layout = ui.LayoutView() - for comp in components: - layout.add_item(comp) - - destination = ctx.author if settings["help_in_dm"] else ctx.channel - msg = await destination.send(view=layout) - self._active_views[msg.id] = layout + cog_name, cog_doc, cmds = await gather_cog_help_data(ctx, cog, show_hidden=show_hidden) + if use_cv2: + components = build_cv2_cog_help(ctx, cog_name, cog_doc, cmds, accent_color=accent) + layout = ui.LayoutView() + for comp in components: + layout.add_item(comp) + msg = await destination.send(view=layout) + self._active_views[msg.id] = layout + else: + embed = build_cog_help_embed(ctx, cog_name, cog_doc, cmds, accent_color=accent) + await destination.send(embed=embed) return # Try category for cat_name, cog_list in categories.items(): if cat_name.lower() == thing.lower(): all_cmds: List[Tuple[str, str]] = [] - for cog_name in cog_list: - cog = self.bot.get_cog(cog_name) - if cog: - for c in sorted(cog.get_commands(), key=lambda x: x.name): + for cog_name_str in cog_list: + cog_obj = self.bot.get_cog(cog_name_str) + if cog_obj: + for c in sorted(cog_obj.get_commands(), key=lambda x: x.name): if c.hidden and not show_hidden: continue try: @@ -336,32 +361,38 @@ class NewHelpMenu(commands.Cog): except Exception: continue short = c.short_doc or "No description" - all_cmds.append( - (f"{ctx.clean_prefix}{c.qualified_name}", short) - ) + all_cmds.append((f"{ctx.clean_prefix}{c.qualified_name}", short)) emoji = category_emojis.get(cat_name, "📂") - from .formatter import _make_category_container - - container = _make_category_container( - cat_name, all_cmds, accent_color=accent, emoji=emoji - ) - layout = ui.LayoutView() - layout.add_item(container) - - destination = ctx.author if settings["help_in_dm"] else ctx.channel - msg = await destination.send(view=layout) - self._active_views[msg.id] = layout + if use_cv2: + container = _make_category_container(cat_name, all_cmds, accent_color=accent, emoji=emoji) + layout = ui.LayoutView() + layout.add_item(container) + msg = await destination.send(view=layout) + self._active_views[msg.id] = layout + else: + embed = discord.Embed( + title=f"{emoji} {cat_name}", + color=discord.Colour(accent), + ) + for cmd_name, short in all_cmds: + embed.add_field(name=cmd_name, value=short[:100], inline=True) + await destination.send(embed=embed) return # Nothing found - layout = ui.LayoutView() - container = ui.Container(accent_colour=discord.Colour(0xED4245)) - container.add_item( - ui.TextDisplay(f"❌ No command, cog, or category named **{thing}** found.") - ) - layout.add_item(container) - await ctx.send(view=layout) + if use_cv2: + layout = ui.LayoutView() + container = ui.Container(accent_colour=discord.Colour(0xED4245)) + container.add_item(ui.TextDisplay(f"❌ No command, cog, or category named **{thing}** found.")) + layout.add_item(container) + await ctx.send(view=layout) + else: + embed = discord.Embed( + description=f"❌ No command, cog, or category named **{thing}** found.", + color=discord.Colour(0xED4245), + ) + await ctx.send(embed=embed) # ═══════════════════════════════════════════════════════════════ # LISTENERS @@ -396,20 +427,18 @@ class NewHelpMenu(commands.Cog): @commands.command(name="help") async def _help_command(self, ctx: commands.Context, *, thing: str = None): """Shows help for the bot, a command, a cog, or a category.""" - # Always use CV2 help in guilds — this cog IS the help replacement. - # The "enabled" toggle only controls global embed conversion. if ctx.guild: try: - await self._send_cv2_help(ctx, thing) + await self._send_help(ctx, thing) return except Exception as e: - log.warning(f"CV2 help failed, using text fallback: {e}") + log.exception(f"Help menu failed, using text fallback: {e}") # Fallback for DMs or on error — paginate to stay under 2000 chars if thing is None: prefix = ctx.clean_prefix header = f"**{ctx.bot.user.display_name} — Help**\nUse `{prefix}help ` for details.\n" - pages: List[str] = [] + chunks: List[str] = [] current = header cog_names = sorted(ctx.bot.cogs.keys()) for cog_name in cog_names: @@ -419,14 +448,14 @@ class NewHelpMenu(commands.Cog): cmd_names = ", ".join(f"`{c.name}`" for c in sorted(cmds, key=lambda c: c.name)) line = f"**{cog_name}** — {cmd_names}\n" if len(current) + len(line) > 1900: - pages.append(current) + chunks.append(current) current = line else: current += line if current: - pages.append(current) - for page in pages: - await ctx.send(page) + chunks.append(current) + for chunk in chunks: + await ctx.send(chunk) else: cmd = ctx.bot.get_command(thing) if cmd: @@ -438,11 +467,11 @@ class NewHelpMenu(commands.Cog): text += f"\n\n**Aliases:** {', '.join(cmd.aliases)}" await ctx.send(text) else: - cog = ctx.bot.get_cog(thing) - if cog: + cog_obj = ctx.bot.get_cog(thing) + if cog_obj: prefix = ctx.clean_prefix - lines = [f"**{cog.qualified_name}**\n{cog.help or cog.__doc__ or 'No description.'}\n"] - for c in sorted(cog.get_commands(), key=lambda c: c.name): + lines = [f"**{cog_obj.qualified_name}**\n{cog_obj.help or cog_obj.__doc__ or 'No description.'}\n"] + for c in sorted(cog_obj.get_commands(), key=lambda c: c.name): if not c.hidden: lines.append(f"`{prefix}{c.qualified_name}` — {c.short_doc or 'No description'}") await ctx.send("\n".join(lines))