2026-05-07 22:41:31 +00:00
"""
2026-05-07 23:28:40 +00:00
Help Formatter for NewHelpMenu — Red - DiscordBot .
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
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 .
2026-05-07 22:41:31 +00:00
"""
from __future__ import annotations
import asyncio
2026-05-07 23:28:40 +00:00
import logging
2026-05-08 02:44:22 +00:00
import math
2026-05-07 22:41:31 +00:00
from collections import defaultdict
from typing import Any , Dict , List , Optional , Sequence , Tuple , Union
import discord
from discord import ui
2026-05-07 23:28:40 +00:00
from discord . ext import commands as dpy_commands
2026-05-07 22:41:31 +00:00
from redbot . core import commands
from redbot . core . commands import Command , Group
from redbot . core . utils . chat_formatting import humanize_list , pagify
2026-05-07 23:28:40 +00:00
log = logging . getLogger ( " red.evecogs.newhelpmenu.formatter " )
2026-05-07 22:41:31 +00:00
2026-05-22 21:15:16 +00:00
# Discord limits: 25 fields per embed, 6000 chars total
# We use conservative limits to ensure clean formatting
2026-05-08 02:44:22 +00:00
MAX_FIELDS_PER_PAGE = 6 # Keep pages clean, not cramped
2026-05-22 21:15:16 +00:00
EMBED_CHAR_LIMIT = 5800 # Leave buffer below Discord's 6000 limit
MAX_CATEGORIES_IN_OVERVIEW = 18 # Stay well under 25 field limit
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
# ══════════════════════════════════════════════════════════════════
# DATA GATHERING (shared by both embed and CV2 modes)
# ══════════════════════════════════════════════════════════════════
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
async def gather_bot_help_data (
ctx : commands . Context ,
bot ,
2026-05-07 22:41:31 +00:00
* ,
2026-05-07 23:28:40 +00:00
categories : Dict [ str , List [ str ] ] ,
category_emojis : Dict [ str , str ] ,
show_hidden : bool = False ,
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 :
( 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
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
cog_commands : Dict [ str , List [ commands . Command ] ] = defaultdict ( list )
2026-05-07 22:41:31 +00:00
2026-05-22 21:15:16 +00:00
# Gather all commands first, then batch permission checks for efficiency
pending_checks : List [ Tuple [ commands . Command , str ] ] = [ ]
2026-05-07 23:28:40 +00:00
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
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
cog_name = cmd . cog_name or " No Category "
if cog_name in blacklisted_cogs :
continue
2026-05-22 21:15:16 +00:00
pending_checks . append ( ( cmd , cog_name ) )
2026-07-29 00:13:26 +01:00
# Run permission checks concurrently — each is an independent await (often
# a Config lookup via Red's permission system), and with a few hundred
# bot commands doing them one at a time made every single [p]help call
# pay for that latency serially.
async def _safe_can_run ( cmd : commands . Command ) - > bool :
2026-05-22 21:15:16 +00:00
try :
2026-07-29 00:13:26 +01:00
return await cmd . can_run ( ctx )
2026-05-22 21:15:16 +00:00
except Exception :
2026-07-29 00:13:26 +01:00
return False
allowed_flags = await asyncio . gather ( * ( _safe_can_run ( cmd ) for cmd , _ in pending_checks ) )
for ( cmd , cog_name ) , allowed in zip ( pending_checks , allowed_flags ) :
if allowed :
cog_commands [ cog_name ] . append ( cmd )
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
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
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
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 :
2026-05-08 02:44:22 +00:00
short = cmd . format_shortdoc_for_context ( ctx ) if hasattr ( cmd , " format_shortdoc_for_context " ) else ( cmd . short_doc or " No description " )
2026-05-07 23:28:40 +00:00
category_data [ cat ] . append ( ( f " { prefix } { cmd . qualified_name } " , short ) )
total + = 1
sorted_cats = sorted ( category_data . keys ( ) )
result = [ ]
for cat in sorted_cats :
emoji = category_emojis . get ( cat , " 📂 " )
result . append ( ( cat , emoji , category_data [ cat ] ) )
return result , total
async def gather_cog_help_data (
ctx : commands . Context ,
cog : commands . Cog ,
2026-05-07 22:41:31 +00:00
* ,
2026-05-07 23:28:40 +00:00
show_hidden : bool = False ,
) - > Tuple [ str , str , List [ Tuple [ str , str , str ] ] ] :
""" Gather help data for a cog.
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
Returns :
( cog_name , cog_doc , [ ( qualified_name , signature , short_doc ) , . . . ] )
"""
prefix = ctx . clean_prefix
cog_name = cog . qualified_name
cog_doc = " "
2026-05-08 02:44:22 +00:00
if hasattr ( cog , " format_help_for_context " ) :
2026-05-07 23:28:40 +00:00
cog_doc = cog . format_help_for_context ( ctx )
if not cog_doc :
cog_doc = cog . help or cog . __doc__ or " No description. "
2026-05-07 22:41:31 +00:00
2026-07-29 00:13:26 +01:00
candidates = [
c for c in sorted ( cog . get_commands ( ) , key = lambda c : c . qualified_name )
if not ( c . hidden and not show_hidden )
]
async def _safe_can_run ( cmd : commands . Command ) - > bool :
2026-05-07 23:28:40 +00:00
try :
2026-07-29 00:13:26 +01:00
return await cmd . can_run ( ctx )
2026-05-07 23:28:40 +00:00
except Exception :
2026-07-29 00:13:26 +01:00
return False
allowed_flags = await asyncio . gather ( * ( _safe_can_run ( c ) for c in candidates ) )
cmds = [ ]
for cmd , allowed in zip ( candidates , allowed_flags ) :
if not allowed :
2026-05-07 23:28:40 +00:00
continue
sig = cmd . signature or " "
2026-05-08 02:44:22 +00:00
short = cmd . format_shortdoc_for_context ( ctx ) if hasattr ( cmd , " format_shortdoc_for_context " ) else ( cmd . short_doc or " No description " )
2026-05-07 23:28:40 +00:00
cmds . append ( ( f " { prefix } { cmd . qualified_name } " , sig , short ) )
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
return cog_name , cog_doc , cmds
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
async def gather_command_help_data (
ctx : commands . Context ,
2026-05-07 22:41:31 +00:00
cmd : commands . Command ,
2026-05-07 23:28:40 +00:00
) - > Dict [ str , Any ] :
2026-05-08 02:44:22 +00:00
""" Gather help data for a single command. """
2026-05-07 23:28:40 +00:00
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 = " "
2026-05-08 02:44:22 +00:00
if hasattr ( cmd , " format_help_for_context " ) :
2026-05-07 23:28:40 +00:00
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
2026-05-08 02:44:22 +00:00
sub_short = sub . format_shortdoc_for_context ( ctx ) if hasattr ( sub , " format_shortdoc_for_context " ) else ( sub . short_doc or " No description " )
2026-05-07 23:28:40 +00:00
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
# ══════════════════════════════════════════════════════════════════
2026-05-08 02:44:22 +00:00
# EMBED MODE — Polished embeds with discord.ui.View
2026-05-07 23:28:40 +00:00
# ══════════════════════════════════════════════════════════════════
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
2026-05-08 02:44:22 +00:00
# Category select (row 0)
2026-05-07 23:28:40 +00:00
if category_options and len ( category_options ) > 1 :
select = discord . ui . Select (
2026-05-08 02:44:22 +00:00
placeholder = " ⏩ Jump to category… " ,
2026-05-07 23:28:40 +00:00
options = category_options [ : 25 ] ,
custom_id = " help_cat_select " ,
row = 0 ,
)
select . callback = self . _select_callback
self . add_item ( select )
2026-05-08 02:44:22 +00:00
# Navigation buttons (row 1)
self . first_btn = discord . ui . Button ( emoji = " ⏮ " , style = discord . ButtonStyle . secondary , row = 1 , disabled = True )
2026-05-07 23:28:40 +00:00
self . first_btn . callback = self . _first
self . add_item ( self . first_btn )
2026-05-08 02:44:22 +00:00
self . prev_btn = discord . ui . Button ( emoji = " ◀️ " , style = discord . ButtonStyle . primary , row = 1 , disabled = True )
2026-05-07 23:28:40 +00:00
self . prev_btn . callback = self . _prev
self . add_item ( self . prev_btn )
self . indicator = discord . ui . Button (
2026-05-08 02:44:22 +00:00
label = f " Page 1 of { len ( pages ) } " , style = discord . ButtonStyle . secondary , disabled = True , row = 1
2026-05-07 23:28:40 +00:00
)
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 )
2026-05-08 02:44:22 +00:00
self . last_btn = discord . ui . Button ( emoji = " ⏭ " , style = discord . ButtonStyle . secondary , row = 1 )
2026-05-07 23:28:40 +00:00
self . last_btn . callback = self . _last
self . add_item ( self . last_btn )
2026-05-08 02:44:22 +00:00
# Close button (row 2)
self . close_btn = discord . ui . Button ( label = " Close " , emoji = " ✖️ " , style = discord . ButtonStyle . danger , row = 2 )
2026-05-07 23:28:40 +00:00
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
2026-05-08 02:44:22 +00:00
self . indicator . label = f " Page { self . current_page + 1 } of { len ( self . pages ) } "
2026-05-07 23:28:40 +00:00
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
2026-05-08 02:44:22 +00:00
# ─── Embed builders ───────────────────────────────────────────────
def _cmd_table ( cmds : List [ Tuple [ str , str ] ] , * , prefix : str = " " ) - > str :
""" Format commands as a clean aligned table inside a description. """
lines = [ ]
for cmd_name , short in cmds :
# Strip prefix from display name for cleaner look
display = cmd_name
if prefix and display . startswith ( prefix ) :
display = display [ len ( prefix ) : ]
desc = short [ : 50 ] if short else " No description "
lines . append ( f " ` { display } ` · { desc } " )
return " \n " . join ( lines )
2026-05-07 23:28:40 +00:00
def build_bot_help_embeds (
ctx ,
bot ,
category_data : List [ Tuple [ str , str , List [ Tuple [ str , str ] ] ] ] ,
total_commands : int ,
2026-05-07 22:41:31 +00:00
* ,
2026-05-07 23:28:40 +00:00
accent_color : int ,
) - > Tuple [ List [ discord . Embed ] , List [ discord . SelectOption ] ] :
2026-05-08 02:44:22 +00:00
""" Build polished embed pages for the full bot help. """
2026-05-07 23:28:40 +00:00
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 )
2026-05-08 02:44:22 +00:00
total_pages_estimate = 1 + sum ( max ( 1 , math . ceil ( len ( cmds ) / MAX_FIELDS_PER_PAGE ) ) for _ , _ , cmds in category_data )
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
pages : List [ discord . Embed ] = [ ]
select_options : List [ discord . SelectOption ] = [ ]
2026-05-08 02:44:22 +00:00
# ── Page 0: Overview ─────────────────────────────────
overview = discord . Embed ( color = color )
overview . set_author ( name = f " { bot_name } · Help Menu " , icon_url = bot_avatar )
desc_lines = [
f " Welcome! I have ** { total_commands } ** commands across ** { len ( category_data ) } ** categories. " ,
" " ,
f " ▸ ` { prefix } help <command>` — details on a command " ,
f " ▸ ` { prefix } help <category>` — browse a category " ,
" " ,
" **╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍** " ,
]
overview . description = " \n " . join ( desc_lines )
2026-05-07 23:28:40 +00:00
if bot_avatar :
overview . set_thumbnail ( url = bot_avatar )
2026-05-08 02:44:22 +00:00
# Show categories as compact inline fields
2026-05-07 23:31:15 +00:00
for i , ( cat_name , emoji , cmds ) in enumerate ( category_data ) :
2026-05-22 21:15:16 +00:00
if i > = MAX_CATEGORIES_IN_OVERVIEW :
2026-05-07 23:31:15 +00:00
overview . add_field (
2026-05-22 21:15:16 +00:00
name = f " … + { len ( category_data ) - MAX_CATEGORIES_IN_OVERVIEW } more " ,
2026-05-08 02:44:22 +00:00
value = f " Use the dropdown to browse. " ,
2026-05-07 23:31:15 +00:00
inline = False ,
)
break
2026-05-08 02:44:22 +00:00
cmd_preview = " , " . join ( f " ` { n . split ( ) [ - 1 ] } ` " for n , _ in cmds [ : 6 ] )
if len ( cmds ) > 6 :
cmd_preview + = f " + { len ( cmds ) - 6 } "
2026-05-07 23:28:40 +00:00
overview . add_field (
2026-05-08 02:44:22 +00:00
name = f " { emoji } { cat_name } · { len ( cmds ) } " ,
value = cmd_preview or " *No commands* " ,
inline = True ,
2026-05-07 23:28:40 +00:00
)
2026-05-08 02:44:22 +00:00
# Pad with empty inline field if odd number for cleaner grid
2026-05-22 21:15:16 +00:00
field_count = min ( len ( category_data ) , MAX_CATEGORIES_IN_OVERVIEW )
2026-05-08 02:44:22 +00:00
if field_count % 2 == 1 :
overview . add_field ( name = " \u200b " , value = " \u200b " , inline = True )
overview . set_footer ( text = f " Page 1 of { total_pages_estimate } · Use the buttons below to navigate " )
2026-05-07 23:28:40 +00:00
pages . append ( overview )
2026-05-08 02:44:22 +00:00
# ── Category pages ───────────────────────────────────
page_idx = 1
for cat_i , ( cat_name , emoji , cmds ) in enumerate ( category_data ) :
2026-05-07 23:28:40 +00:00
select_options . append ( discord . SelectOption (
label = cat_name [ : 100 ] ,
2026-05-08 02:44:22 +00:00
value = str ( page_idx ) ,
2026-05-07 23:28:40 +00:00
emoji = emoji if len ( emoji ) < = 2 else None ,
2026-05-08 02:44:22 +00:00
description = f " { len ( cmds ) } command { ' s ' if len ( cmds ) != 1 else ' ' } " ,
2026-05-07 23:28:40 +00:00
) )
2026-05-08 02:44:22 +00:00
chunks = [ cmds [ i : i + MAX_FIELDS_PER_PAGE ] for i in range ( 0 , len ( cmds ) , MAX_FIELDS_PER_PAGE ) ]
for chunk_i , chunk in enumerate ( chunks ) :
embed = discord . Embed ( color = color )
2026-05-07 23:28:40 +00:00
2026-05-08 02:44:22 +00:00
title_suffix = f " ( { chunk_i + 1 } / { len ( chunks ) } ) " if len ( chunks ) > 1 else " "
embed . set_author ( name = f " { emoji } { cat_name } { title_suffix } " , icon_url = bot_avatar )
# Build clean command list
cmd_lines = [ ]
2026-05-07 23:28:40 +00:00
for cmd_name , short in chunk :
2026-05-08 02:44:22 +00:00
display = cmd_name
if prefix and display . startswith ( prefix ) :
display = display [ len ( prefix ) : ]
desc = short [ : 70 ] if short else " No description "
cmd_lines . append ( f " **` { display } `** \n ╰ { desc } " )
embed . description = " \n \n " . join ( cmd_lines )
embed . set_footer ( text = f " Page { page_idx + 1 } of { total_pages_estimate } · { prefix } help <command> for details " )
2026-05-07 23:28:40 +00:00
pages . append ( embed )
2026-05-08 02:44:22 +00:00
page_idx + = 1
# Fix page counts now that we know the real total
real_total = len ( pages )
for i , page in enumerate ( pages ) :
if page . footer and page . footer . text :
old_footer = page . footer . text
# Replace estimated page count with real one
import re
new_footer = re . sub ( r " of \ d+ " , f " of { real_total } " , old_footer )
new_footer = re . sub ( r " Page \ d+ " , f " Page { i + 1 } " , new_footer )
page . set_footer ( text = new_footer )
2026-05-07 23:28:40 +00:00
return pages , select_options
def build_command_help_embed (
ctx ,
data : Dict [ str , Any ] ,
* ,
accent_color : int ,
) - > discord . Embed :
2026-05-08 02:44:22 +00:00
""" Build a polished embed for a single command. """
2026-05-07 23:28:40 +00:00
prefix = ctx . clean_prefix
bot_avatar = ctx . bot . user . display_avatar . url if ctx . bot . user else None
color = discord . Colour ( accent_color )
2026-05-08 02:44:22 +00:00
icon = " 📁 " if data [ " is_group " ] else " 🔹 "
embed = discord . Embed ( color = color )
embed . set_author ( name = f " { icon } { prefix } { data [ ' name ' ] } " , icon_url = bot_avatar )
2026-05-07 23:28:40 +00:00
2026-05-08 02:44:22 +00:00
# Syntax block
embed . description = f " ```yaml \n { data [ ' signature ' ] } \n ``` "
2026-05-07 22:41:31 +00:00
# Description
2026-05-07 23:28:40 +00:00
desc = data [ " description " ]
if len ( desc ) > 1024 :
2026-05-08 02:44:22 +00:00
desc = desc [ : 1021 ] + " … "
embed . add_field ( name = " 📝 Description " , value = desc , inline = False )
2026-05-07 22:41:31 +00:00
# Aliases
2026-05-07 23:28:40 +00:00
if data [ " aliases " ] :
2026-05-08 02:44:22 +00:00
alias_parts = [ ]
for a in data [ " aliases " ] [ : 10 ] :
# Build proper alias with parent chain
name_parts = data [ " name " ] . split ( )
if len ( name_parts ) > 1 :
alias_parts . append ( f " ` { prefix } { ' ' . join ( name_parts [ : - 1 ] ) } { a } ` " )
else :
alias_parts . append ( f " ` { prefix } { a } ` " )
embed . add_field ( name = " 🔀 Aliases " , value = " " . join ( alias_parts ) , inline = True )
2026-05-07 22:41:31 +00:00
# Cooldown
2026-05-07 23:28:40 +00:00
if data [ " cooldown " ] :
2026-05-08 02:44:22 +00:00
embed . add_field ( name = " ⏱️ Cooldown " , value = data [ " cooldown " ] , inline = True )
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
# Subcommands
if data [ " subcommands " ] :
2026-05-08 02:44:22 +00:00
sub_lines = [ ]
for name , short in data [ " subcommands " ] :
display = name
if prefix and display . startswith ( prefix ) :
display = display [ len ( prefix ) : ]
sub_lines . append ( f " **` { display } `** · { short [ : 50 ] } " )
sub_text = " \n " . join ( sub_lines )
2026-05-07 23:28:40 +00:00
if len ( sub_text ) > 1024 :
2026-05-08 02:44:22 +00:00
sub_text = sub_text [ : 1021 ] + " … "
embed . add_field ( name = f " 📁 Subcommands ( { len ( data [ ' subcommands ' ] ) } ) " , value = sub_text , inline = False )
2026-05-07 22:41:31 +00:00
2026-05-08 02:44:22 +00:00
embed . set_footer ( text = f " <required> [optional] · { prefix } help for main menu " )
2026-05-07 23:28:40 +00:00
return embed
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
def build_cog_help_embed (
ctx ,
cog_name : str ,
cog_doc : str ,
cmds : List [ Tuple [ str , str , str ] ] ,
* ,
accent_color : int ,
) - > discord . Embed :
2026-05-08 02:44:22 +00:00
""" Build a polished embed for a cog ' s help page. """
2026-05-07 23:28:40 +00:00
prefix = ctx . clean_prefix
bot_avatar = ctx . bot . user . display_avatar . url if ctx . bot . user else None
color = discord . Colour ( accent_color )
2026-05-08 02:44:22 +00:00
embed = discord . Embed ( color = color )
embed . set_author ( name = f " 📂 { cog_name } " , icon_url = bot_avatar )
if cog_doc :
embed . description = cog_doc [ : 2048 ]
2026-05-07 23:28:40 +00:00
if cmds :
cmd_lines = [ ]
for qname , sig , short in cmds :
2026-05-08 02:44:22 +00:00
display = qname
if prefix and display . startswith ( prefix ) :
display = display [ len ( prefix ) : ]
cmd_lines . append ( f " **` { display } `** · { short [ : 60 ] } " )
2026-05-07 23:28:40 +00:00
cmd_text = " \n " . join ( cmd_lines )
for i , chunk in enumerate ( pagify ( cmd_text , delims = [ " \n " ] , page_length = 1024 ) ) :
2026-05-08 02:44:22 +00:00
name = f " 🔹 Commands ( { len ( cmds ) } ) " if i == 0 else " ㅤ "
2026-05-07 23:28:40 +00:00
embed . add_field ( name = name , value = chunk , inline = False )
2026-05-08 02:44:22 +00:00
embed . set_footer ( text = f " { prefix } help <command> for details · { prefix } help for main menu " )
2026-05-07 23:28:40 +00:00
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 = " 📂 " ,
2026-05-08 02:44:22 +00:00
prefix : str = " " ,
2026-05-07 23:28:40 +00:00
) - > ui . Container :
2026-05-08 02:44:22 +00:00
""" Build a polished CV2 container for a category. """
2026-05-07 23:28:40 +00:00
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 :
2026-05-08 02:44:22 +00:00
display = cmd_name
if prefix and display . startswith ( prefix ) :
display = display [ len ( prefix ) : ]
short = cmd_short [ : 70 ] if cmd_short else " No description "
lines . append ( f " **` { display } `** \n ╰ { short } " )
text = " \n \n " . join ( lines )
for chunk in pagify ( text , delims = [ " \n \n " , " \n " ] , page_length = 3900 ) :
2026-05-07 23:28:40 +00:00
container . add_item ( ui . TextDisplay ( chunk ) )
return container
2026-05-07 22:41:31 +00:00
class HelpPaginatorView ( ui . LayoutView ) :
2026-05-07 23:28:40 +00:00
""" CV2 LayoutView paginator for help. """
2026-05-07 22:41:31 +00:00
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 ) )
2026-05-08 02:44:22 +00:00
2026-05-07 22:41:31 +00:00
nav_row = ui . ActionRow ( )
2026-05-08 02:44:22 +00:00
nav_row . add_item ( ui . Button ( emoji = " ⏮ " , style = discord . ButtonStyle . secondary , custom_id = " help_first " , disabled = self . current_page == 0 ) )
2026-05-07 23:28:40 +00:00
nav_row . add_item ( ui . Button ( emoji = " ◀️ " , style = discord . ButtonStyle . primary , custom_id = " help_prev " , disabled = self . current_page == 0 ) )
2026-05-08 02:44:22 +00:00
nav_row . add_item ( ui . Button ( label = f " Page { self . current_page + 1 } of { len ( self . pages ) } " , style = discord . ButtonStyle . secondary , custom_id = " help_page_ind " , disabled = True ) )
2026-05-07 23:28:40 +00:00
nav_row . add_item ( ui . Button ( emoji = " ▶️ " , style = discord . ButtonStyle . primary , custom_id = " help_next " , disabled = self . current_page > = len ( self . pages ) - 1 ) )
2026-05-08 02:44:22 +00:00
nav_row . add_item ( ui . Button ( emoji = " ⏭ " , style = discord . ButtonStyle . secondary , custom_id = " help_last " , disabled = self . current_page > = len ( self . pages ) - 1 ) )
2026-05-07 22:41:31 +00:00
self . add_item ( nav_row )
2026-05-08 02:44:22 +00:00
# Close in its own row
2026-05-07 22:41:31 +00:00
close_row = ui . ActionRow ( )
2026-05-08 02:44:22 +00:00
close_row . add_item ( ui . Button ( label = " Close " , emoji = " ✖️ " , style = discord . ButtonStyle . danger , custom_id = " help_close " ) )
2026-05-07 22:41:31 +00:00
self . add_item ( close_row )
2026-05-08 02:44:22 +00:00
# Select in its own row (ActionRow can't mix buttons + selects)
2026-05-07 23:31:15 +00:00
if self . category_options and len ( self . category_options ) > 1 :
select_row = ui . ActionRow ( )
2026-05-08 02:44:22 +00:00
select_row . add_item ( ui . Select ( placeholder = " ⏩ Jump to category… " , options = self . category_options [ : 25 ] , custom_id = " help_category_select " ) )
2026-05-07 23:31:15 +00:00
self . add_item ( select_row )
2026-05-07 22:41:31 +00:00
async def interaction_check ( self , interaction : discord . Interaction ) - > bool :
if interaction . user . id != self . author_id :
2026-05-07 23:28:40 +00:00
await interaction . response . send_message ( " This help menu isn ' t yours! " , ephemeral = True )
2026-05-07 22:41:31 +00:00
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 )
2026-05-07 23:28:40 +00:00
self . add_item ( ui . TextDisplay ( " -# Help menu timed out. " ) )
2026-05-07 22:41:31 +00:00
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 " :
2026-05-07 23:28:40 +00:00
page = 0
2026-05-07 22:41:31 +00:00
elif custom_id == " help_prev " :
2026-05-07 23:28:40 +00:00
page = self . current_page - 1
2026-05-07 22:41:31 +00:00
elif custom_id == " help_next " :
2026-05-07 23:28:40 +00:00
page = self . current_page + 1
2026-05-07 22:41:31 +00:00
elif custom_id == " help_last " :
2026-05-07 23:28:40 +00:00
page = len ( self . pages ) - 1
2026-05-07 22:41:31 +00:00
elif custom_id == " help_close " :
self . stop ( )
if self . message :
try :
await self . message . delete ( )
except ( discord . NotFound , discord . HTTPException ) :
pass
2026-05-07 23:28:40 +00:00
return
2026-05-07 22:41:31 +00:00
elif custom_id == " help_category_select " :
values = interaction . data . get ( " values " , [ ] ) if interaction . data else [ ]
if values :
try :
2026-05-07 23:28:40 +00:00
page = int ( values [ 0 ] )
2026-05-07 22:41:31 +00:00
except ( ValueError , IndexError ) :
2026-05-07 23:28:40 +00:00
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 ] ] :
2026-05-08 02:44:22 +00:00
""" Build polished CV2 pages for the full bot help. """
2026-05-07 23:28:40 +00:00
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 ] = [ ]
2026-05-08 02:44:22 +00:00
# ── Overview page ─────────────────────────────
overview : List [ Any ] = [ ]
2026-05-07 23:28:40 +00:00
header = ui . Container ( accent_colour = color )
2026-05-08 02:44:22 +00:00
header_md = (
f " ## { bot_name } · Help Menu \n "
f " ** { total_commands } ** commands across ** { len ( category_data ) } ** categories \n \n "
f " ▸ ` { prefix } help <command>` — details on a command \n "
f " ▸ ` { prefix } help <category>` — browse a category "
)
2026-05-07 23:28:40 +00:00
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 ) )
2026-05-08 02:44:22 +00:00
overview . append ( header )
2026-05-07 22:41:31 +00:00
2026-05-08 02:44:22 +00:00
# Categories summary — cap how many to avoid component limits
cats_to_show = category_data [ : 20 ]
2026-05-07 23:28:40 +00:00
summary = ui . Container ( accent_colour = color )
2026-05-08 02:44:22 +00:00
summary . add_item ( ui . TextDisplay ( " ### 📚 Categories " ) )
2026-05-07 23:28:40 +00:00
summary . add_item ( ui . Separator ( spacing = discord . SeparatorSpacing . small ) )
summary_lines = [ ]
2026-05-08 02:44:22 +00:00
for i , ( cat_name , emoji , cmds ) in enumerate ( cats_to_show ) :
summary_lines . append ( f " { emoji } ** { cat_name } ** — { len ( cmds ) } command { ' s ' if len ( cmds ) != 1 else ' ' } " )
2026-05-07 23:28:40 +00:00
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 " ,
) )
2026-05-08 02:44:22 +00:00
if len ( category_data ) > 20 :
summary_lines . append ( f " … *+ { len ( category_data ) - 20 } more categories* " )
2026-05-07 23:28:40 +00:00
summary . add_item ( ui . TextDisplay ( " \n " . join ( summary_lines ) ) )
2026-05-08 02:44:22 +00:00
overview . append ( summary )
pages . append ( overview )
2026-05-07 22:41:31 +00:00
2026-05-08 02:44:22 +00:00
# ── Category pages ────────────────────────────
2026-05-07 23:28:40 +00:00
for cat_name , emoji , cmds in category_data :
2026-05-08 02:44:22 +00:00
chunks = [ cmds [ i : i + MAX_FIELDS_PER_PAGE ] for i in range ( 0 , len ( cmds ) , MAX_FIELDS_PER_PAGE ) ]
for chunk in chunks :
pages . append ( [ _make_category_container ( cat_name , chunk , accent_color = accent_color , emoji = emoji , prefix = prefix ) ] )
2026-05-07 23:28:40 +00:00
return pages , select_options
def build_cv2_command_help (
ctx ,
data : Dict [ str , Any ] ,
* ,
accent_color : int ,
) - > List [ Any ] :
2026-05-08 02:44:22 +00:00
""" Build polished CV2 components for a command. """
2026-05-07 23:28:40 +00:00
prefix = ctx . clean_prefix
color = discord . Colour ( accent_color )
container = ui . Container ( accent_colour = color )
icon = " 📁 " if data [ " is_group " ] else " 🔹 "
2026-05-08 02:44:22 +00:00
container . add_item ( ui . TextDisplay ( f " ## { icon } { prefix } { data [ ' name ' ] } " ) )
2026-05-07 23:28:40 +00:00
container . add_item ( ui . Separator ( spacing = discord . SeparatorSpacing . small ) )
2026-05-08 02:44:22 +00:00
# Syntax
container . add_item ( ui . TextDisplay ( f " ```yaml \n { data [ ' signature ' ] } \n ``` " ) )
# Description
container . add_item ( ui . TextDisplay ( f " 📝 **Description** \n { data [ ' description ' ] [ : 3800 ] } " ) )
# Aliases
2026-05-07 23:28:40 +00:00
if data [ " aliases " ] :
2026-05-08 02:44:22 +00:00
name_parts = data [ " name " ] . split ( )
alias_parts = [ ]
for a in data [ " aliases " ] [ : 10 ] :
if len ( name_parts ) > 1 :
alias_parts . append ( f " ` { prefix } { ' ' . join ( name_parts [ : - 1 ] ) } { a } ` " )
else :
alias_parts . append ( f " ` { prefix } { a } ` " )
container . add_item ( ui . TextDisplay ( f " 🔀 **Aliases:** { ' ' . join ( alias_parts ) } " ) )
2026-05-07 23:28:40 +00:00
2026-05-08 02:44:22 +00:00
# Cooldown
2026-05-07 23:28:40 +00:00
if data [ " cooldown " ] :
2026-05-08 02:44:22 +00:00
container . add_item ( ui . TextDisplay ( f " ⏱️ **Cooldown:** { data [ ' cooldown ' ] } " ) )
2026-05-07 23:28:40 +00:00
2026-05-08 02:44:22 +00:00
# Subcommands
2026-05-07 23:28:40 +00:00
if data [ " subcommands " ] :
container . add_item ( ui . Separator ( spacing = discord . SeparatorSpacing . small ) )
2026-05-08 02:44:22 +00:00
sub_lines = [ ]
for name , short in data [ " subcommands " ] :
display = name
if prefix and display . startswith ( prefix ) :
display = display [ len ( prefix ) : ]
sub_lines . append ( f " **` { display } `** · { short [ : 50 ] } " )
container . add_item ( ui . TextDisplay ( f " ### 📁 Subcommands ( { len ( data [ ' subcommands ' ] ) } ) \n \n " + " \n " . join ( sub_lines ) ) )
container . add_item ( ui . Separator ( spacing = discord . SeparatorSpacing . small ) )
container . add_item ( ui . TextDisplay ( f " -# <required> [optional] · { prefix } help for main menu " ) )
2026-05-07 23:28:40 +00:00
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 ] :
2026-05-08 02:44:22 +00:00
""" Build polished CV2 components for a cog. """
prefix = ctx . clean_prefix
2026-05-07 23:28:40 +00:00
color = discord . Colour ( accent_color )
header = ui . Container ( accent_colour = color )
2026-05-08 02:44:22 +00:00
header . add_item ( ui . TextDisplay ( f " ## 📂 { cog_name } " ) )
2026-05-07 23:28:40 +00:00
header . add_item ( ui . Separator ( spacing = discord . SeparatorSpacing . small ) )
2026-05-08 02:44:22 +00:00
if cog_doc :
header . add_item ( ui . TextDisplay ( cog_doc [ : 3900 ] ) )
2026-05-07 23:28:40 +00:00
components : List [ Any ] = [ header ]
if cmds :
cmd_list = [ ( qname , short ) for qname , _ , short in cmds ]
2026-05-08 02:44:22 +00:00
components . append ( _make_category_container ( " Commands " , cmd_list , accent_color = accent_color , emoji = " 🔹 " , prefix = prefix ) )
2026-05-07 23:28:40 +00:00
return components
# ══════════════════════════════════════════════════════════════════
# CV2 MENU PAGINATOR (general purpose)
# ══════════════════════════════════════════════════════════════════
2026-05-07 22:41:31 +00:00
class CV2MenuPaginator ( ui . LayoutView ) :
2026-05-07 23:28:40 +00:00
""" General-purpose CV2 paginator to replace Red ' s menu system. """
2026-05-07 22:41:31 +00:00
2026-05-07 23:28:40 +00:00
def __init__ ( self , pages : List [ Any ] , * , author_id : int , timeout : float = 120.0 , accent_color : int = 0x5865F2 ) :
2026-05-07 22:41:31 +00:00
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 ) )
2026-05-07 23:28:40 +00:00
container . add_item ( ui . TextDisplay ( page [ : 3900 ] ) )
2026-05-07 22:41:31 +00:00
return [ container ]
elif isinstance ( page , list ) :
return page
2026-05-07 23:28:40 +00:00
return [ ui . TextDisplay ( str ( page ) [ : 3900 ] ) ]
2026-05-07 22:41:31 +00:00
def _build_page ( self ) :
self . clear_items ( )
2026-05-07 23:28:40 +00:00
for comp in self . _convert_page ( self . raw_pages [ self . current_page ] ) :
2026-05-07 22:41:31 +00:00
self . add_item ( comp )
if len ( self . raw_pages ) > 1 :
self . add_item ( ui . Separator ( spacing = discord . SeparatorSpacing . small ) )
nav_row = ui . ActionRow ( )
2026-05-07 23:28:40 +00:00
nav_row . add_item ( ui . Button ( emoji = " ◀️ " , style = discord . ButtonStyle . primary , custom_id = " cv2menu_prev " , disabled = self . current_page == 0 ) )
2026-05-08 02:44:22 +00:00
nav_row . add_item ( ui . Button ( label = f " Page { self . current_page + 1 } of { len ( self . raw_pages ) } " , style = discord . ButtonStyle . secondary , custom_id = " cv2menu_ind " , disabled = True ) )
2026-05-07 23:28:40 +00:00
nav_row . add_item ( ui . Button ( emoji = " ▶️ " , style = discord . ButtonStyle . primary , custom_id = " cv2menu_next " , disabled = self . current_page > = len ( self . raw_pages ) - 1 ) )
2026-05-08 02:44:22 +00:00
nav_row . add_item ( ui . Button ( label = " Close " , emoji = " ✖️ " , style = discord . ButtonStyle . danger , custom_id = " cv2menu_close " ) )
2026-05-07 22:41:31 +00:00
self . add_item ( nav_row )
async def interaction_check ( self , interaction : discord . Interaction ) - > bool :
if interaction . user . id != self . author_id :
2026-05-07 23:28:40 +00:00
await interaction . response . send_message ( " This menu isn ' t yours! " , ephemeral = True )
2026-05-07 22:41:31 +00:00
return False
return True
async def on_timeout ( self ) :
if self . message :
try :
self . clear_items ( )
2026-05-07 23:28:40 +00:00
for comp in self . _convert_page ( self . raw_pages [ self . current_page ] ) :
2026-05-07 22:41:31 +00:00
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
2026-05-07 23:28:40 +00:00
return
else :
return
self . _build_page ( )
await interaction . response . edit_message ( view = self )