1
Fork 0
mirror of https://github.com/wlinator/luminara.git synced 2024-10-02 16:23:12 +00:00

feat: Add fully functional help command

This commit is contained in:
wlinator 2024-09-02 04:16:43 -04:00
parent c9688ed0e0
commit bfe718b50a
30 changed files with 281 additions and 73 deletions

View file

@ -41,6 +41,12 @@ async def log_command_error(
log_msg = f"{user_name} executed {command_type}{command_name or 'Unknown'}"
log_msg += " in DMs" if guild_id is None else f" | guild: {guild_id}"
if CONST.INSTANCE == "dev":
logger.exception(
f"{log_msg} | {error.__module__}.{error.__class__.__name__} | {''.join(traceback.format_exception(type(error), error, error.__traceback__))}",
)
else:
logger.error(f"{log_msg} | {error.__module__}.{error.__class__.__name__}")

View file

@ -1,4 +1,6 @@
import inspect
import textwrap
from typing import Any
import discord
from discord.ext import commands
@ -140,3 +142,57 @@ def format_seconds_to_duration_string(seconds: int) -> str:
return f"{hours}h{minutes}m" if minutes > 0 else f"{hours}h"
return f"{minutes}m"
def generate_usage(
command: commands.Command[Any, Any, Any],
flag_converter: type[commands.FlagConverter] | None = None,
) -> str:
"""
Generate a usage string for a command with flags.
Credit to https://github.com/allthingslinux/tux (thanks kaizen ;p)
Parameters
----------
command : commands.Command
The command for which to generate the usage string.
flag_converter : type[commands.FlagConverter]
The flag converter class for the command.
Returns
-------
str
The usage string for the command. Example: "ban [target] -[reason] -<silent>"
"""
# Get the name of the command
command_name = command.qualified_name
# Start the usage string with the command name
usage = f"{command_name}"
# Get the parameters of the command (excluding the `ctx` and `flags` parameters)
parameters: dict[str, commands.Parameter] = command.clean_params
flag_prefix = getattr(flag_converter, "__commands_flag_prefix__", "-")
flags: dict[str, commands.Flag] = flag_converter.get_flags() if flag_converter else {}
# Add non-flag arguments to the usage string
for param_name, param in parameters.items():
# Ignore these parameters
if param_name in ["ctx", "flags"]:
continue
# Determine if the parameter is required
is_required = param.default == inspect.Parameter.empty
# Add the parameter to the usage string with required or optional wrapping
usage += f" <{param_name}>" if is_required else f" [{param_name}]"
# Add flag arguments to the usage string
for flag_name, flag_obj in flags.items():
# Determine if the flag is required or optional
if flag_obj.required:
usage += f" {flag_prefix}<{flag_name}>"
else:
usage += f" {flag_prefix}[{flag_name}]"
return usage

View file

@ -3,81 +3,178 @@ from collections.abc import Mapping
from pathlib import Path
from typing import Any
import discord
from discord.ext import commands
from lib.const import CONST
from lib.exceptions import LumiException
from ui.embeds import Builder
class LumiHelp(commands.HelpCommand):
def __init__(self, **options: Any) -> None:
super().__init__(**options)
self.verify_checks: bool = True
self.command_attrs: dict[str, list[str] | str | bool] = {
class LuminaraHelp(commands.HelpCommand):
def __init__(self):
"""Initializes the LuminaraHelp command with necessary attributes."""
super().__init__(
command_attrs={
"help": "Lists all commands and sub-commands.",
"aliases": ["h"],
"help": "Show a list of commands, or information about a specific command when an argument is passed.",
"name": "help",
"hidden": True,
}
def get_command_qualified_name(self, command: commands.Command[Any, Any, Any]) -> str:
return f"`{self.context.clean_prefix}{command.qualified_name}`"
async def send_bot_help(self, mapping: Mapping[commands.Cog | None, list[commands.Command[Any, ..., Any]]]) -> None:
embed = Builder.create_embed(
theme="success",
author_text="Help Command",
user_name=self.context.author.name,
hide_name_in_description=True,
"usage": "$help <command> or <sub-command>",
},
)
modules_dir = Path(__file__).parent.parent / "modules"
module_names = [name for name in os.listdir(modules_dir) if Path(modules_dir / name).is_dir()]
async def _get_prefix(self) -> str:
"""
Dynamically fetches the prefix from the context or uses a default prefix constant.
for module_name in module_names:
module_commands: list[commands.Command[Any, ..., Any]] = []
for cog, lumi_commands in mapping.items():
if cog and cog.__module__.startswith(f"modules.{module_name}"):
filtered = await self.filter_commands(lumi_commands, sort=True)
module_commands.extend(filtered)
Returns
-------
str
The prefix used to invoke the bot.
"""
return "."
def _embed_base(self, author: str, description: str | None = None) -> discord.Embed:
"""
Creates a base embed with uniform styling.
Parameters
----------
title : str
The title of the embed.
description : str | None
The description of the embed.
Returns
-------
discord.Embed
The created embed.
"""
return Builder.create_embed(
theme="info",
author_text=author,
description=description,
footer_text=CONST.STRINGS["help_footer"],
)
def _get_cog_groups(self) -> list[str]:
"""
Retrieves a list of cog groups from the 'modules' folder.
Returns
-------
list[str]
A list of cog groups.
"""
cog_groups = sorted(
[
d
for d in os.listdir("./modules")
if Path(f"./modules/{d}").is_dir() and d not in ("__pycache__", "admin")
],
)
if "moderation" in cog_groups:
cog_groups.remove("moderation")
cog_groups.insert(0, "moderation")
return cog_groups
async def send_bot_help(
self,
mapping: Mapping[commands.Cog | None, list[commands.Command[Any, Any, Any]]],
) -> None:
"""
Sends an overview of all commands in a single embed, grouped by module.
Parameters
----------
mapping : Mapping[commands.Cog | None, list[commands.Command[Any, Any, Any]]]
The mapping of cogs to commands.
"""
embed = self._embed_base("Luminara Help Overview")
cog_groups = self._get_cog_groups()
for group in cog_groups:
group_commands: list[commands.Command[Any, Any, Any]] = []
for cog, commands_list in mapping.items():
if cog and commands_list and cog.__module__.startswith(f"modules.{group}"):
group_commands.extend(commands_list)
if group_commands:
command_list = ", ".join(f"`{c.name}`" for c in group_commands)
embed.add_field(name=group.capitalize(), value=command_list, inline=False)
await self.get_destination().send(embed=embed)
async def _add_command_help_fields(self, embed: discord.Embed, command: commands.Command[Any, Any, Any]) -> None:
"""
Adds fields with usage and alias information for a command to an embed.
Parameters
----------
embed : discord.Embed
The embed to which the fields will be added.
command : commands.Command[Any, Any, Any]
The command whose details are to be added.
"""
prefix = await self._get_prefix()
if module_commands:
command_signatures = [self.get_command_qualified_name(c) for c in module_commands]
unique_command_signatures = list(set(command_signatures))
embed.add_field(
name=module_name.capitalize(),
value=", ".join(sorted(unique_command_signatures)),
name="Usage",
value=f"`{prefix}{command.usage or 'No usage.'}`",
inline=False,
)
channel = self.get_destination()
await channel.send(embed=embed)
async def send_command_help(self, command: commands.Command[Any, Any, Any]) -> None:
embed = Builder.create_embed(
theme="success",
author_text=f"{self.context.clean_prefix}{command.qualified_name}",
description=command.description,
user_name=self.context.author.name,
hide_name_in_description=True,
"""
Sends a help message for a specific command.
Parameters
----------
command : commands.Command[Any, Any, Any]
The command for which the help message is to be sent.
"""
prefix = await self._get_prefix()
author = f"{prefix}{command.qualified_name}"
author += f" ({', '.join(command.aliases)})" if command.aliases else ""
embed = self._embed_base(
author=author,
description=f"> {command.help}" or "No description available.",
)
usage_value: str = f"`{self.context.clean_prefix}{command.usage}`"
embed.add_field(name="Usage", value=usage_value, inline=False)
channel = self.get_destination()
await channel.send(embed=embed)
async def send_error_message(self, error: str) -> None:
raise LumiException(error)
await self._add_command_help_fields(embed, command)
await self.get_destination().send(embed=embed)
async def send_group_help(self, group: commands.Group[Any, Any, Any]) -> None:
raise LumiException(
CONST.STRINGS["error_command_not_found"].format(group.qualified_name),
"""
Sends a help message for a specific command group.
Parameters
----------
group : commands.Group[Any, Any, Any]
The group for which the help message is to be sent.
"""
prefix = await self._get_prefix()
embed = self._embed_base(
author=f"{prefix}{group.qualified_name}",
description=group.help or "No description available.",
)
async def send_cog_help(self, cog: commands.Cog) -> None:
raise LumiException(
CONST.STRINGS["error_command_not_found"].format(cog.qualified_name),
for command in group.commands:
embed.add_field(name=command.name, value=command.short_doc or "No description available.", inline=False)
await self.get_destination().send(embed=embed)
async def send_error_message(self, error: str) -> None:
"""
Sends an error message.
Parameters
----------
error : str
The error message to be sent.
"""
embed = Builder.create_embed(
theme="error",
title="Error in help command",
description=error,
)
await self.get_destination().send(embed=embed, delete_after=30)

View file

@ -190,6 +190,7 @@
"give_success": "you gave **${1}** to {2}.",
"greet_default_description": "_ _\n**Welcome** to **{0}**",
"greet_template_description": "\u2193\u2193\u2193\n{0}",
"help_footer": "Help Service",
"help_use_prefix": "Please use Lumi's prefix to get help. Type `{0}help`",
"info_api_version": "**discord.py:** v{0}\n",
"info_database_records": "**Database:** {0} records",

View file

@ -7,6 +7,7 @@ from loguru import logger
from lib.client import Luminara
from lib.const import CONST
from lib.help import LuminaraHelp
from services.config_service import GuildConfig
logger.remove()
@ -30,7 +31,7 @@ async def main() -> None:
allowed_mentions=discord.AllowedMentions(everyone=False),
case_insensitive=True,
strip_after_prefix=True,
help_command=None,
help_command=LuminaraHelp(),
)
try:

View file

@ -1,6 +1,7 @@
import mysql.connector
from discord.ext import commands
import lib.format
from db import database
from lib.const import CONST
from lib.format import shorten
@ -10,6 +11,8 @@ from ui.embeds import Builder
class Sql(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.select_cmd.usage = lib.format.generate_usage(self.select_cmd)
self.inject_cmd.usage = lib.format.generate_usage(self.inject_cmd)
@commands.command(name="sqlselect", aliases=["sqls"])
@commands.is_owner()

View file

@ -1,6 +1,7 @@
import discord
from discord.ext import commands
import lib.format
from lib.const import CONST
from services.currency_service import Currency
from ui.embeds import Builder
@ -9,6 +10,7 @@ from ui.embeds import Builder
class Award(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.award_command.usage = lib.format.generate_usage(self.award_command)
@commands.command(name="award", aliases=["aw"])
@commands.is_owner()

View file

@ -1,6 +1,7 @@
import discord
from discord.ext import commands
import lib.format
from lib.const import CONST
from services.blacklist_service import BlacklistUserService
from ui.embeds import Builder
@ -9,6 +10,7 @@ from ui.embeds import Builder
class Blacklist(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.blacklist_command.usage = lib.format.generate_usage(self.blacklist_command)
@commands.command(name="blacklist")
@commands.is_owner()

View file

@ -1,12 +1,15 @@
import discord
from discord.ext import commands
import lib.format
from lib.const import CONST
class Dev(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.sync.usage = lib.format.generate_usage(self.sync)
self.clear.usage = lib.format.generate_usage(self.clear)
@commands.group(name="dev", description="Lumi developer commands")
@commands.guild_only()
@ -44,7 +47,7 @@ class Dev(commands.Cog):
name="clear_tree",
aliases=["clear"],
)
async def sync_global(
async def clear(
self,
ctx: commands.Context[commands.Bot],
guild: discord.Guild | None = None,

View file

@ -1,5 +1,6 @@
from discord.ext import commands
import lib.format
from lib.const import CONST
from services.currency_service import Currency
from ui.embeds import Builder
@ -8,13 +9,14 @@ from ui.embeds import Builder
class Balance(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.balance.usage = lib.format.generate_usage(self.balance)
@commands.hybrid_command(
name="balance",
aliases=["bal", "$"],
)
@commands.guild_only()
async def daily(
async def balance(
self,
ctx: commands.Context[commands.Bot],
) -> None:

View file

@ -5,6 +5,7 @@ import discord
from discord.ext import commands
from loguru import logger
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from services.currency_service import Currency
@ -22,6 +23,7 @@ Hand = list[Card]
class Blackjack(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.blackjack.usage = lib.format.generate_usage(self.blackjack)
@commands.hybrid_command(
name="blackjack",

View file

@ -4,6 +4,7 @@ from zoneinfo import ZoneInfo
from discord import Embed
from discord.ext import commands
import lib.format
from lib.const import CONST
from services.currency_service import Currency
from services.daily_service import Dailies
@ -25,6 +26,7 @@ def seconds_until(hours: int, minutes: int) -> int:
class Daily(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.daily.usage = lib.format.generate_usage(self.daily)
@commands.hybrid_command(
name="daily",

View file

@ -1,6 +1,7 @@
import discord
from discord.ext import commands
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from services.currency_service import Currency
@ -10,6 +11,7 @@ from ui.embeds import Builder
class Give(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.give.usage = lib.format.generate_usage(self.give)
@commands.hybrid_command(
name="give",

View file

@ -7,6 +7,7 @@ from zoneinfo import ZoneInfo
import discord
from discord.ext import commands
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from services.currency_service import Currency
@ -18,6 +19,7 @@ est = ZoneInfo("US/Eastern")
class Slots(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.slots.usage = lib.format.generate_usage(self.slots)
@commands.hybrid_command(
name="slots",

View file

@ -3,6 +3,7 @@ from typing import cast
from discord import Embed, Guild, Member
from discord.ext import commands
import lib.format
from lib.const import CONST
from ui.embeds import Builder
from ui.views.leaderboard import LeaderboardCommandOptions, LeaderboardCommandView
@ -11,6 +12,7 @@ from ui.views.leaderboard import LeaderboardCommandOptions, LeaderboardCommandVi
class Leaderboard(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.leaderboard.usage = lib.format.generate_usage(self.leaderboard)
@commands.hybrid_command(
name="leaderboard",

View file

@ -1,6 +1,7 @@
from discord import Embed
from discord.ext import commands
import lib.format
from lib.const import CONST
from services.xp_service import XpService
from ui.embeds import Builder
@ -9,12 +10,13 @@ from ui.embeds import Builder
class Level(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.level.usage = lib.format.generate_usage(self.level)
@commands.hybrid_command(
name="level",
aliases=["rank", "lvl", "xp"],
)
async def ping(self, ctx: commands.Context[commands.Bot]) -> None:
async def level(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Get the level of the user.

View file

@ -5,6 +5,8 @@ import httpx
from discord import File
from discord.ext import commands
import lib.format
async def create_avatar_file(url: str) -> File:
"""
@ -32,6 +34,7 @@ async def create_avatar_file(url: str) -> File:
class Avatar(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.avatar.usage = lib.format.generate_usage(self.avatar)
@commands.hybrid_command(
name="avatar",
@ -45,7 +48,7 @@ class Avatar(commands.Cog):
"""
Get the avatar of a member.
Parameters:
Parameters
-----------
ctx : ApplicationContext
The discord context object.

View file

@ -90,11 +90,6 @@ class Backup(commands.Cog):
await self.bot.wait_until_ready()
await asyncio.sleep(30)
@commands.command()
async def backup(self, ctx: commands.Context[commands.Bot]) -> None:
await backup()
await ctx.send("Backup successful.")
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Backup(bot))

View file

@ -5,6 +5,7 @@ import discord
import psutil
from discord.ext import commands
import lib.format
from lib.const import CONST
from ui.embeds import Builder
@ -12,6 +13,7 @@ from ui.embeds import Builder
class Info(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.info.usage = lib.format.generate_usage(self.info)
@commands.hybrid_command(
name="info",

View file

@ -1,6 +1,7 @@
import discord
from discord.ext import commands
import lib.format
from lib.const import CONST
from ui.embeds import Builder
from ui.views.introduction import (
@ -12,6 +13,7 @@ from ui.views.introduction import (
class Introduction(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.introduction.usage = lib.format.generate_usage(self.introduction)
@commands.hybrid_command(name="introduction", aliases=["intro"])
async def introduction(self, ctx: commands.Context[commands.Bot]) -> None:

View file

@ -1,5 +1,6 @@
from discord.ext import commands
import lib.format
from lib.const import CONST
from ui.embeds import Builder
from ui.views.invite import InviteButton
@ -8,6 +9,7 @@ from ui.views.invite import InviteButton
class Invite(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.invite.usage = lib.format.generate_usage(self.invite)
@commands.hybrid_command(name="invite", aliases=["inv"])
async def invite(self, ctx: commands.Context[commands.Bot]) -> None:

View file

@ -1,5 +1,6 @@
from discord.ext import commands
import lib.format
from lib.const import CONST
from ui.embeds import Builder
@ -7,6 +8,7 @@ from ui.embeds import Builder
class Ping(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.ping.usage = lib.format.generate_usage(self.ping)
@commands.hybrid_command(name="ping")
async def ping(self, ctx: commands.Context[commands.Bot]) -> None:

View file

@ -4,6 +4,7 @@ import discord
from discord import Embed
from discord.ext import commands
import lib.format
from lib.const import CONST
from ui.embeds import Builder
@ -12,8 +13,9 @@ class Uptime(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.start_time: datetime = discord.utils.utcnow()
self.uptime.usage = lib.format.generate_usage(self.uptime)
@commands.hybrid_command(name="uptime", aliases=["ut"])
@commands.hybrid_command(name="uptime")
async def uptime(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Uptime command.

View file

@ -15,8 +15,10 @@ from ui.embeds import Builder
class Ban(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.ban.usage = lib.format.generate_usage(self.ban)
self.unban.usage = lib.format.generate_usage(self.unban)
@commands.hybrid_command(name="ban")
@commands.hybrid_command(name="ban", aliases=["b"])
@commands.has_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
@commands.guild_only()

View file

@ -4,6 +4,7 @@ import discord
from discord.ext import commands
from reactionmenu import ViewButton, ViewMenu
import lib.format
from lib.case_handler import edit_case_modlog
from lib.const import CONST
from lib.exceptions import LumiException
@ -46,6 +47,10 @@ def create_case_view_menu(ctx: commands.Context[commands.Bot]) -> ViewMenu:
class Cases(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.view_case_by_number.usage = lib.format.generate_usage(self.view_case_by_number)
self.view_all_cases_in_guild.usage = lib.format.generate_usage(self.view_all_cases_in_guild)
self.view_all_cases_by_mod.usage = lib.format.generate_usage(self.view_all_cases_by_mod)
self.edit_case_reason.usage = lib.format.generate_usage(self.edit_case_reason)
@commands.hybrid_command(name="case", aliases=["c", "ca"])
@commands.has_permissions(manage_messages=True)

View file

@ -14,6 +14,7 @@ from ui.embeds import Builder
class Kick(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.kick.usage = lib.format.generate_usage(self.kick)
@commands.hybrid_command(name="kick", aliases=["k"])
@commands.has_permissions(kick_members=True)

View file

@ -4,6 +4,7 @@ import discord
from discord import app_commands
from discord.ext import commands
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from lib.format import format_duration_to_seconds
@ -12,6 +13,7 @@ from lib.format import format_duration_to_seconds
class Slowmode(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.slowmode.usage = lib.format.generate_usage(self.slowmode)
async def _set_slowmode(
self,

View file

@ -4,7 +4,7 @@ from typing import cast
import discord
from discord.ext import commands
import lib.format as formatter
import lib.format
from lib.actionable import async_actionable
from lib.case_handler import create_case
from lib.const import CONST
@ -14,6 +14,7 @@ from ui.embeds import Builder
class Softban(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.softban.usage = lib.format.generate_usage(self.softban)
@commands.hybrid_command(name="softban", aliases=["sb"])
@commands.has_permissions(ban_members=True)
@ -67,7 +68,7 @@ class Softban(commands.Cog):
target,
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
lib.format.shorten(output_reason, 200),
),
delete_message_seconds=86400,
)

View file

@ -16,6 +16,8 @@ from ui.embeds import Builder
class Timeout(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.timeout.usage = lib.format.generate_usage(self.timeout)
self.untimeout.usage = lib.format.generate_usage(self.untimeout)
@commands.hybrid_command(name="timeout", aliases=["t", "to"])
@commands.has_permissions(moderate_members=True)

View file

@ -4,6 +4,7 @@ from typing import cast
import discord
from discord.ext import commands
import lib.format
from lib.actionable import async_actionable
from lib.case_handler import create_case
from lib.const import CONST
@ -14,6 +15,7 @@ from ui.embeds import Builder
class Warn(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.warn.usage = lib.format.generate_usage(self.warn)
@commands.hybrid_command(name="warn", aliases=["w"])
@commands.has_permissions(manage_messages=True)