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
This commit is contained in:
parent
e1718139d4
commit
324524fc77
5 changed files with 1684 additions and 1558 deletions
|
|
@ -1,13 +1,7 @@
|
|||
"""New Help Menu — Fully customisable interactive help system for Red-DiscordBot."""
|
||||
|
||||
from .newhelpmenu import NewHelpMenu
|
||||
|
||||
__red_end_user_data_statement__ = (
|
||||
"This cog stores Discord user IDs and guild IDs for per-user favourite commands "
|
||||
"and per-guild help menu configuration."
|
||||
)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
cog = NewHelpMenu(bot)
|
||||
await bot.add_cog(cog)
|
||||
await cog.initialize()
|
||||
|
|
|
|||
256
newhelpmenu/converter.py
Normal file
256
newhelpmenu/converter.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""
|
||||
Embed → Components V2 converter.
|
||||
|
||||
Translates discord.Embed objects into discord.ui LayoutView trees
|
||||
using Containers, Sections, TextDisplays, Thumbnails, MediaGalleries,
|
||||
Separators, and ActionRows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import discord
|
||||
from discord import ui
|
||||
|
||||
|
||||
# ──────────────────────────── helpers ────────────────────────────
|
||||
|
||||
def _truncate(text: str, limit: int = 4000) -> str:
|
||||
"""Truncate to Discord's TextDisplay limit."""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 3] + "..."
|
||||
|
||||
|
||||
def _field_to_md(field: Any) -> str:
|
||||
"""Convert an embed field to markdown."""
|
||||
name = getattr(field, "name", "") or ""
|
||||
value = getattr(field, "value", "") or ""
|
||||
if name:
|
||||
return f"**{name}**\n{value}"
|
||||
return value
|
||||
|
||||
|
||||
# ──────────────────────────── core ────────────────────────────
|
||||
|
||||
|
||||
def embed_to_container(
|
||||
embed: discord.Embed,
|
||||
*,
|
||||
accent_color: Optional[int] = None,
|
||||
show_thumbnail: bool = True,
|
||||
show_footer: bool = True,
|
||||
compact: bool = False,
|
||||
) -> ui.Container:
|
||||
"""Convert a ``discord.Embed`` into a ``discord.ui.Container``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
embed:
|
||||
The embed to convert.
|
||||
accent_color:
|
||||
Override accent colour. Falls back to embed colour, then None.
|
||||
show_thumbnail:
|
||||
Whether to render the thumbnail as a Thumbnail accessory.
|
||||
show_footer:
|
||||
Whether to render the footer line.
|
||||
compact:
|
||||
If True, merges fields into fewer TextDisplays.
|
||||
"""
|
||||
# Resolve colour
|
||||
color = accent_color
|
||||
if color is None and embed.color and embed.color.value:
|
||||
color = embed.color.value
|
||||
if color is None:
|
||||
color = discord.Color.blurple().value
|
||||
|
||||
container = ui.Container(accent_colour=discord.Colour(color))
|
||||
|
||||
# ── Author line ──
|
||||
author_text = ""
|
||||
if embed.author and embed.author.name:
|
||||
author_name = embed.author.name
|
||||
if embed.author.url:
|
||||
author_text = f"[{author_name}]({embed.author.url})"
|
||||
else:
|
||||
author_text = author_name
|
||||
|
||||
# ── Title + URL ──
|
||||
title_text = ""
|
||||
if embed.title:
|
||||
title = embed.title
|
||||
if embed.url:
|
||||
title_text = f"### [{title}]({embed.url})"
|
||||
else:
|
||||
title_text = f"### {title}"
|
||||
|
||||
# ── Description ──
|
||||
desc_text = embed.description or ""
|
||||
|
||||
# Build the header block — combine author, title, description
|
||||
header_parts = []
|
||||
if author_text:
|
||||
header_parts.append(author_text)
|
||||
if title_text:
|
||||
header_parts.append(title_text)
|
||||
if desc_text:
|
||||
header_parts.append(desc_text)
|
||||
|
||||
header_md = "\n".join(header_parts)
|
||||
|
||||
# ── Thumbnail? → use Section with accessory ──
|
||||
thumbnail_url = None
|
||||
if show_thumbnail and embed.thumbnail and embed.thumbnail.url:
|
||||
thumbnail_url = embed.thumbnail.url
|
||||
|
||||
if header_md:
|
||||
if thumbnail_url:
|
||||
# Section: header text + thumbnail accessory
|
||||
section = ui.Section(
|
||||
accessory=ui.Thumbnail(thumbnail_url),
|
||||
)
|
||||
section.add_item(ui.TextDisplay(_truncate(header_md)))
|
||||
container.add_item(section)
|
||||
else:
|
||||
container.add_item(ui.TextDisplay(_truncate(header_md)))
|
||||
|
||||
# ── Fields ──
|
||||
if embed.fields:
|
||||
if header_md:
|
||||
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
|
||||
|
||||
if compact:
|
||||
# Merge all fields into one or two text displays
|
||||
inline_row: List[str] = []
|
||||
blocks: List[str] = []
|
||||
for field in embed.fields:
|
||||
md = _field_to_md(field)
|
||||
if getattr(field, "inline", False):
|
||||
inline_row.append(md)
|
||||
if len(inline_row) >= 3:
|
||||
blocks.append(" · ".join(inline_row))
|
||||
inline_row = []
|
||||
else:
|
||||
if inline_row:
|
||||
blocks.append(" · ".join(inline_row))
|
||||
inline_row = []
|
||||
blocks.append(md)
|
||||
if inline_row:
|
||||
blocks.append(" · ".join(inline_row))
|
||||
combined = "\n\n".join(blocks)
|
||||
container.add_item(ui.TextDisplay(_truncate(combined)))
|
||||
else:
|
||||
# Group inline fields, render non-inline as standalone
|
||||
inline_buffer: List[str] = []
|
||||
for field in embed.fields:
|
||||
md = _field_to_md(field)
|
||||
if getattr(field, "inline", False):
|
||||
inline_buffer.append(md)
|
||||
if len(inline_buffer) >= 3:
|
||||
joined = " · ".join(inline_buffer)
|
||||
container.add_item(ui.TextDisplay(_truncate(joined)))
|
||||
inline_buffer = []
|
||||
else:
|
||||
if inline_buffer:
|
||||
joined = " · ".join(inline_buffer)
|
||||
container.add_item(ui.TextDisplay(_truncate(joined)))
|
||||
inline_buffer = []
|
||||
container.add_item(ui.TextDisplay(_truncate(md)))
|
||||
if inline_buffer:
|
||||
joined = " · ".join(inline_buffer)
|
||||
container.add_item(ui.TextDisplay(_truncate(joined)))
|
||||
|
||||
# ── Image ──
|
||||
if embed.image and embed.image.url:
|
||||
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
|
||||
container.add_item(
|
||||
ui.MediaGallery(
|
||||
ui.MediaGalleryItem(embed.image.url)
|
||||
)
|
||||
)
|
||||
|
||||
# ── Footer + timestamp ──
|
||||
if show_footer:
|
||||
footer_parts: List[str] = []
|
||||
if embed.footer and embed.footer.text:
|
||||
footer_parts.append(embed.footer.text)
|
||||
if embed.timestamp:
|
||||
footer_parts.append(
|
||||
f"<t:{int(embed.timestamp.timestamp())}:R>"
|
||||
)
|
||||
if footer_parts:
|
||||
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
|
||||
footer_md = "-# " + " · ".join(footer_parts)
|
||||
container.add_item(ui.TextDisplay(_truncate(footer_md, 4000)))
|
||||
|
||||
return container
|
||||
|
||||
|
||||
def embeds_to_layout(
|
||||
embeds: List[discord.Embed],
|
||||
content: Optional[str] = None,
|
||||
*,
|
||||
accent_color: Optional[int] = None,
|
||||
show_thumbnail: bool = True,
|
||||
show_footer: bool = True,
|
||||
compact: bool = False,
|
||||
existing_action_rows: Optional[List[ui.ActionRow]] = None,
|
||||
) -> ui.LayoutView:
|
||||
"""Convert a list of embeds (and optional content) to a full LayoutView.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
embeds:
|
||||
List of embeds to convert.
|
||||
content:
|
||||
Optional text content to add before embeds.
|
||||
accent_color:
|
||||
Global accent colour override.
|
||||
show_thumbnail:
|
||||
Whether to render thumbnails.
|
||||
show_footer:
|
||||
Whether to render footers.
|
||||
compact:
|
||||
Compact field rendering.
|
||||
existing_action_rows:
|
||||
Pre-built action rows (buttons/selects) to append.
|
||||
"""
|
||||
layout = ui.LayoutView()
|
||||
|
||||
# Optional leading text
|
||||
if content:
|
||||
layout.add_item(ui.TextDisplay(_truncate(content)))
|
||||
|
||||
# Convert each embed to a container
|
||||
for i, embed in enumerate(embeds):
|
||||
if i > 0:
|
||||
layout.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
|
||||
container = embed_to_container(
|
||||
embed,
|
||||
accent_color=accent_color,
|
||||
show_thumbnail=show_thumbnail,
|
||||
show_footer=show_footer,
|
||||
compact=compact,
|
||||
)
|
||||
layout.add_item(container)
|
||||
|
||||
# Append any action rows (buttons, selects)
|
||||
if existing_action_rows:
|
||||
for row in existing_action_rows:
|
||||
layout.add_item(row)
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
def view_items_to_action_rows(view: ui.View) -> List[ui.ActionRow]:
|
||||
"""Extract action rows from a regular View for re-use in LayoutView."""
|
||||
rows: List[ui.ActionRow] = []
|
||||
for child in view.children:
|
||||
if isinstance(child, ui.ActionRow):
|
||||
rows.append(child)
|
||||
elif isinstance(child, (ui.Button, ui.Select)):
|
||||
row = ui.ActionRow()
|
||||
row.add_item(child)
|
||||
rows.append(row)
|
||||
return rows
|
||||
554
newhelpmenu/formatter.py
Normal file
554
newhelpmenu/formatter.py
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
"""
|
||||
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
|
||||
|
|
@ -1,30 +1,15 @@
|
|||
{
|
||||
"author": ["everestmcarthur"],
|
||||
"install_msg": "Thanks for installing **New Help Menu v1.0.0** — the ultimate customisable help system!\n\nThe cog automatically replaces your default help command with an interactive, button-driven menu.\n\nGet started:\n• `[p]helpmenu theme <name>` — choose a theme (default, minimal, compact, dark)\n• `[p]helpmenu category create <name> <emoji> <description>` — build custom categories\n• `[p]helpmenu category addcog <category> <CogName>` — assign cogs to categories\n• `[p]helpmenu settings` — view all current settings\n• `[p]helpmenu preview` — preview the menu\n\nFull docs: `[p]help helpmenu`",
|
||||
"install_msg": "**New Help Menu** installed!\n\nThis cog replaces Red's help system and optionally converts ALL embeds & menus to Discord Components V2.\n\nGet started:\n- `[p]cv2 toggle` — Enable Components V2 (off by default)\n- `[p]cv2 settings` — View all current settings\n- `[p]cv2 color <hex>` — Set accent colour\n- `[p]cv2 category add <name> <cog1> <cog2>...` — Group cogs into categories\n\nComponents V2 is **off by default** — enable it when you're ready!",
|
||||
"name": "NewHelpMenu",
|
||||
"disabled": false,
|
||||
"short": "Fully customisable interactive help menu with categories, buttons, select menus, themes, search & favourites.",
|
||||
"description": "New Help Menu v1.0.0 completely replaces Red's default help formatter with a modern, interactive experience. Features include: custom categories with emoji/colour/thumbnail/image per category, 4 built-in themes (default/minimal/compact/dark) plus full custom mode, button and select-menu navigation, paginated command lists, command search via modal, per-user favourite commands, role-gated category visibility, quick-link buttons (URLs), custom home page fields/images/tagline, per-command detail views with subcommand browsing, cooldown/permission/alias display toggles, DM help mode, ephemeral mode, auto-delete, hidden cogs/commands blacklists, uncategorised section control, and 50+ admin configuration commands.",
|
||||
"tags": [
|
||||
"help",
|
||||
"menu",
|
||||
"buttons",
|
||||
"select",
|
||||
"interactive",
|
||||
"categories",
|
||||
"customise",
|
||||
"customize",
|
||||
"theme",
|
||||
"embed",
|
||||
"search",
|
||||
"favourites",
|
||||
"navigation",
|
||||
"ui",
|
||||
"paginator"
|
||||
],
|
||||
"type": "COG",
|
||||
"end_user_data_statement": "This cog stores Discord user IDs and guild IDs for per-user favourite commands and per-guild help menu configuration including categories, themes, and display preferences.",
|
||||
"min_bot_version": "3.5.0",
|
||||
"short": "Components V2 help menu + global embed/menu override",
|
||||
"description": "A mega cog that replaces Red's entire help system with Discord Components V2 (containers, sections, text displays, thumbnails, media galleries, separators) and optionally converts ALL bot embeds and menus into Components V2 layouts. Fully customisable with categories, accent colours, layout modes, per-server settings. Toggle on/off with default off.",
|
||||
"end_user_data_statement": "This cog stores per-guild configuration. No personal user data is stored.",
|
||||
"min_bot_version": "3.5.21",
|
||||
"min_python_version": [3, 9, 0],
|
||||
"requirements": []
|
||||
"requirements": [],
|
||||
"tags": ["help", "menu", "components", "components-v2", "embed", "ui", "layout", "customization", "override"],
|
||||
"type": "COG",
|
||||
"disabled": false,
|
||||
"hidden": false
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue