EveCogs/newhelpmenu/formatter.py
everestmcarthur 324524fc77 refactor: NewHelpMenu → Components V2 mega cog (complete rewrite)
Replaces embed-based help with full Components V2 system:
- converter.py: Embed → Container conversion engine
- formatter.py: CV2 help page builder + paginator views
- newhelpmenu.py: Main cog with settings, monkey-patching, public API
- 1,747 lines across 4 files, default off
2026-05-07 22:41:31 +00:00

554 lines
20 KiB
Python

"""
Components V2 Help Formatter for Red-DiscordBot.
Replaces the default embed-based help with a fully Components V2
layout featuring containers, sections, buttons, and select menus.
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
import discord
from discord import ui
from redbot.core import commands
from redbot.core.commands import Command, Group
from redbot.core.utils.chat_formatting import humanize_list, pagify
# ──────────────────────────── constants ────────────────────────────
MAX_FIELDS_PER_PAGE = 12
MAX_COMPONENTS = 38 # Leave room for nav action row + safety margin
# ──────────────────────────── layout builders ────────────────────────────
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(
ctx: commands.Context,
bot: commands.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.
Returns (pages, category_select_options).
"""
blacklisted_cogs = blacklisted_cogs or []
blacklisted_commands = blacklisted_commands or []
prefix = ctx.clean_prefix
# Gather all visible cogs and their commands
cog_commands: Dict[str, List[commands.Command]] = defaultdict(list)
for cmd in sorted(bot.commands, key=lambda c: c.qualified_name):
if cmd.hidden and not show_hidden:
continue
if cmd.qualified_name in blacklisted_commands:
continue
try:
if not await cmd.can_run(ctx):
continue
except Exception:
continue
cog_name = cmd.cog_name or "No Category"
if cog_name in blacklisted_cogs:
continue
cog_commands[cog_name].append(cmd)
# Build category mappings
cog_to_category: Dict[str, str] = {}
for cat_name, cog_list in categories.items():
for cog_name in cog_list:
cog_to_category[cog_name] = cat_name
# Group by category
category_data: Dict[str, List[Tuple[str, str]]] = defaultdict(list)
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"
category_data[cat].append((f"{prefix}{cmd.qualified_name}", short))
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 <command>` for details on a command.\nUse `{prefix}help <category>` 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
for cat in sorted_cats:
cmds = category_data[cat]
emoji = category_emojis.get(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
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(
ctx: commands.Context,
cog: commands.Cog,
*,
accent_color: int,
show_hidden: bool = False,
) -> List[Any]:
"""Build a help page for a specific cog."""
prefix = ctx.clean_prefix
components: List[Any] = []
cog_name = cog.qualified_name
cog_doc = cog.help or cog.__doc__ or "No description."
cmds: List[Tuple[str, str]] = []
for cmd in sorted(cog.get_commands(), key=lambda c: c.qualified_name):
if cmd.hidden and not show_hidden:
continue
try:
if not await cmd.can_run(ctx):
continue
except Exception:
continue
short = cmd.short_doc or "No description"
cmds.append((f"{prefix}{cmd.qualified_name}", short))
header = ui.Container(accent_colour=discord.Colour(accent_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)
if cmds:
cmd_container = _make_category_container(
"Commands", cmds, accent_color=accent_color, emoji="🔹"
)
components.append(cmd_container)
return components