From c5317b5d27afab9fce3c88e08cc5545aa14af733 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 01:28:18 +0200 Subject: [PATCH 01/10] Create help command implementation --- didier/cogs/currency.py | 16 ++--- didier/cogs/help.py | 143 ++++++++++++++++++++++++++++++++++++---- didier/cogs/tasks.py | 2 + 3 files changed, 139 insertions(+), 22 deletions(-) diff --git a/didier/cogs/currency.py b/didier/cogs/currency.py index f7204a8..ea96187 100644 --- a/didier/cogs/currency.py +++ b/didier/cogs/currency.py @@ -25,7 +25,7 @@ class Currency(commands.Cog): super().__init__() self.client = client - @commands.command(name="Award") + @commands.command(name="award") @commands.check(is_owner) async def award( self, @@ -38,11 +38,11 @@ class Currency(commands.Cog): await crud.add_dinks(session, user.id, amount) plural = pluralize("Didier Dink", amount) await ctx.reply( - f"**{ctx.author.display_name}** heeft **{user.display_name}** **{amount}** {plural} geschonken.", + f"**{ctx.author.display_name}** has awarded **{user.display_name}** **{amount}** {plural}.", mention_author=False, ) - @commands.group(name="bank", aliases=["B"], case_insensitive=True, invoke_without_command=True) + @commands.group(name="bank", aliases=["b"], case_insensitive=True, invoke_without_command=True) async def bank(self, ctx: commands.Context): """Show your Didier Bank information""" async with self.client.postgres_session as session: @@ -57,7 +57,7 @@ class Currency(commands.Cog): await ctx.reply(embed=embed, mention_author=False) - @bank.group(name="Upgrade", aliases=["U", "Upgrades"], case_insensitive=True, invoke_without_command=True) + @bank.group(name="upgrade", aliases=["u", "upgrades"], case_insensitive=True, invoke_without_command=True) async def bank_upgrades(self, ctx: commands.Context): """List the upgrades you can buy & their prices""" async with self.client.postgres_session as session: @@ -77,7 +77,7 @@ class Currency(commands.Cog): await ctx.reply(embed=embed, mention_author=False) - @bank_upgrades.command(name="Capacity", aliases=["C"]) + @bank_upgrades.command(name="capacity", aliases=["c"]) async def bank_upgrade_capacity(self, ctx: commands.Context): """Upgrade the capacity level of your bank""" async with self.client.postgres_session as session: @@ -88,7 +88,7 @@ class Currency(commands.Cog): await ctx.reply("You don't have enough Didier Dinks to do this.", mention_author=False) await self.client.reject_message(ctx.message) - @bank_upgrades.command(name="Interest", aliases=["I"]) + @bank_upgrades.command(name="interest", aliases=["i"]) async def bank_upgrade_interest(self, ctx: commands.Context): """Upgrade the interest level of your bank""" async with self.client.postgres_session as session: @@ -99,7 +99,7 @@ class Currency(commands.Cog): await ctx.reply("You don't have enough Didier Dinks to do this.", mention_author=False) await self.client.reject_message(ctx.message) - @bank_upgrades.command(name="Rob", aliases=["R"]) + @bank_upgrades.command(name="rob", aliases=["r"]) async def bank_upgrade_rob(self, ctx: commands.Context): """Upgrade the rob level of your bank""" async with self.client.postgres_session as session: @@ -118,7 +118,7 @@ class Currency(commands.Cog): plural = pluralize("Didier Dink", bank.dinks) await ctx.reply(f"**{ctx.author.display_name}** has **{bank.dinks}** {plural}.", mention_author=False) - @commands.command(name="Invest", aliases=["Deposit", "Dep"]) + @commands.command(name="invest", aliases=["deposit", "dep"]) async def invest(self, ctx: commands.Context, amount: typing.Annotated[typing.Union[str, int], abbreviated_number]): """Invest a given amount of Didier Dinks""" async with self.client.postgres_session as session: diff --git a/didier/cogs/help.py b/didier/cogs/help.py index 73c66e5..dc2ba84 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -1,10 +1,11 @@ -from typing import List, Mapping, Optional +from typing import Mapping, Optional import discord from discord.ext import commands from overrides import overrides from didier import Didier +from didier.utils.discord.colours import error_red class CustomHelpCommand(commands.MinimalHelpCommand): @@ -13,32 +14,146 @@ class CustomHelpCommand(commands.MinimalHelpCommand): The default is ugly as hell, so we do some fiddling with it """ + @overrides + async def command_callback(self, ctx: commands.Context, /, *, command: Optional[str] = None): + """Slightly modify the original command_callback to better suit my needs""" + + # No argument provided: send a list of all cogs + if command is None: + mapping = self.get_bot_mapping() + return await self.send_bot_help(mapping) + + command = command.lower() + + # Hide cogs the user is not allowed to see + cogs = list(ctx.bot.cogs.values()) + cogs = await self._filter_cogs(cogs) + # Allow fetching cogs case-insensitively + cog = self._get_cog(cogs, command) + if cog is not None: + return await self.send_cog_help(cog) + + # Traverse tree of commands + keys = command.split(" ") + current_command = ctx.bot.all_commands.get(keys[0]) + + # No command found + if current_command is None: + return await self.send_error_message(self.command_not_found(keys[0])) + + # Look for subcommands + for key in keys[1:]: + try: + found = current_command.all_commands.get(key) # type: ignore + except AttributeError: + return await self.send_error_message(self.subcommand_not_found(current_command, key)) + else: + if found is None: + return await self.send_error_message(self.subcommand_not_found(current_command, key)) + + current_command = found + + if isinstance(current_command, commands.Group): + return await self.send_group_help(current_command) + else: + return await self.send_command_help(current_command) + + @overrides + def command_not_found(self, string: str, /) -> str: + return f"Found no command named `{string}`." + + @overrides + async def send_bot_help(self, mapping: Mapping[Optional[commands.Cog], list[commands.Command]], /): + embed = self._help_embed_base("Categories") + filtered_cogs = await self._filter_cogs(list(mapping.keys())) + embed.description = "\n".join(list(map(lambda cog: cog.qualified_name, filtered_cogs))) + await self.context.reply(embed=embed, mention_author=False) + + @overrides + async def send_cog_help(self, cog: commands.Cog, /): + embed = self._help_embed_base(cog.qualified_name) + embed.description = cog.description + + commands_names = list(map(lambda c: c.qualified_name, cog.get_commands())) + commands_names.sort() + + embed.add_field(name="Commands", value=", ".join(commands_names), inline=False) + + return await self.context.reply(embed=embed, mention_author=False) + + @overrides + async def send_command_help(self, command: commands.Command, /): + embed = self._help_embed_base(command.qualified_name) + self._add_command_help(embed, command) + + return await self.context.reply(embed=embed, mention_author=False) + + @overrides + async def send_group_help(self, group: commands.Group, /): + embed = self._help_embed_base(group.qualified_name) + + if group.invoke_without_command: + self._add_command_help(embed, group) + + subcommand_names = list(map(lambda c: c.name, group.commands)) + subcommand_names.sort() + + embed.add_field(name="Subcommands", value=", ".join(subcommand_names)) + + return await self.context.reply(embed=embed, mention_author=False) + + @overrides + async def send_error_message(self, error: str, /): + embed = discord.Embed(colour=error_red(), title="Help", description=error) + return await self.context.reply(embed=embed, mention_author=False) + + @overrides + def subcommand_not_found(self, command: commands.Command, string: str, /) -> str: + return f"Found no subcommand named `{string}` for command `{command.qualified_name}`." + def _help_embed_base(self, title: str) -> discord.Embed: """Create the base structure for the embeds that get sent with the Help commands""" - embed = discord.Embed(title=title, colour=discord.Colour.blue()) - embed.set_footer(text="Syntax: Didier Help [Categorie] of Didier Help [Commando]") + embed = discord.Embed(title=title.title(), colour=discord.Colour.blue()) return embed - async def _filter_cogs(self, cogs: List[commands.Cog]) -> List[commands.Cog]: + def _add_command_help(self, embed: discord.Embed, command: commands.Command): + """Add command-related information to an embed + + This allows re-using this logic for Group commands that can be invoked by themselves. + """ + embed.description = command.help + + if command.usage: + embed.add_field(name="Signature", value=f"{command.name} {command.usage}", inline=False) + + if command.aliases: + embed.add_field(name="Aliases", value=", ".join(command.aliases), inline=False) + + def _get_cog(self, cogs: list[commands.Cog], name: str) -> Optional[commands.Cog]: + """Try to find a cog, case-insensitively""" + for cog in cogs: + if cog.qualified_name.lower() == name: + return cog + + return None + + async def _filter_cogs(self, cogs: list[commands.Cog]) -> list[commands.Cog]: """Filter the list of cogs down to all those that the user can see""" # Remove cogs that we never want to see in the help page because they # don't contain commands - filtered_cogs = list(filter(lambda cog: cog is not None and cog.qualified_name.lower() not in ("tasks",), cogs)) + filtered_cogs = list( + filter(lambda cog: cog is not None and cog.qualified_name.lower() not in ("tasks", "debugcog"), cogs) + ) - # Remove owner-only cogs + # Remove owner-only cogs for people that shouldn't see them if not await self.context.bot.is_owner(self.context.author): filtered_cogs = list(filter(lambda cog: cog.qualified_name.lower() not in ("owner",), filtered_cogs)) return list(sorted(filtered_cogs, key=lambda cog: cog.qualified_name)) - @overrides - async def send_bot_help(self, mapping: Mapping[Optional[commands.Cog], List[commands.Command]], /): - embed = self._help_embed_base("Categorieën") - filtered_cogs = await self._filter_cogs(list(mapping.keys())) - embed.description = "\n".join(list(map(lambda cog: cog.qualified_name, filtered_cogs))) - await self.get_destination().send(embed=embed) - async def setup(client: Didier): """Load the cog""" - client.help_command = CustomHelpCommand() + attributes = {"aliases": ["h", "man"]} + + client.help_command = CustomHelpCommand(command_attrs=attributes) diff --git a/didier/cogs/tasks.py b/didier/cogs/tasks.py index 518f3f5..eaeaebe 100644 --- a/didier/cogs/tasks.py +++ b/didier/cogs/tasks.py @@ -190,8 +190,10 @@ class Tasks(commands.Cog): await self.client.wait_until_ready() @check_birthdays.error + @pull_schedules.error @pull_ufora_announcements.error @remove_old_ufora_announcements.error + @reset_wordle_word.error async def _on_tasks_error(self, error: BaseException): """Error handler for all tasks""" print("".join(traceback.format_exception(type(error), error, error.__traceback__))) From 9c36f59e04c77aafffda09bdbfd3d5688f9cb8ab Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 01:50:18 +0200 Subject: [PATCH 02/10] Improve help messages --- didier/cogs/discord.py | 23 +++++++++++------------ didier/cogs/fun.py | 6 +++--- didier/cogs/help.py | 15 ++++++++++++--- didier/cogs/meta.py | 17 ++++++++++------- 4 files changed, 36 insertions(+), 25 deletions(-) diff --git a/didier/cogs/discord.py b/didier/cogs/discord.py index d90752a..a6467bc 100644 --- a/didier/cogs/discord.py +++ b/didier/cogs/discord.py @@ -43,23 +43,22 @@ class Discord(commands.Cog): self.client.tree.remove_command(self._bookmark_ctx_menu.name, type=self._bookmark_ctx_menu.type) self.client.tree.remove_command(self._pin_ctx_menu.name, type=self._pin_ctx_menu.type) - @commands.group(name="Birthday", aliases=["Bd", "Birthdays"], case_insensitive=True, invoke_without_command=True) + @commands.group(name="birthday", aliases=["bd", "birthdays"], case_insensitive=True, invoke_without_command=True) async def birthday(self, ctx: commands.Context, user: discord.User = None): """Command to check the birthday of a user""" user_id = (user and user.id) or ctx.author.id async with self.client.postgres_session as session: birthday = await birthdays.get_birthday_for_user(session, user_id) - name = "Your" if user is None else f"{user.display_name}'s" + name: Optional[str] = user and f"{user.display_name}'s" if birthday is None: - return await ctx.reply(f"I don't know {name} birthday.", mention_author=False) + return await ctx.reply(f"I don't know {name or 'your'} birthday.", mention_author=False) day, month = leading("0", str(birthday.birthday.day)), leading("0", str(birthday.birthday.month)) + return await ctx.reply(f"{name or 'Your'} birthday is set to **{day}/{month}**.", mention_author=False) - return await ctx.reply(f"{name} birthday is set to **{day}/{month}**.", mention_author=False) - - @birthday.command(name="Set", aliases=["Config"]) + @birthday.command(name="set", aliases=["config"]) async def birthday_set(self, ctx: commands.Context, date_str: str): """Command to set your birthday""" try: @@ -77,7 +76,7 @@ class Discord(commands.Cog): await birthdays.add_birthday(session, ctx.author.id, date) await self.client.confirm_message(ctx.message) - @commands.group(name="Bookmark", aliases=["Bm", "Bookmarks"], case_insensitive=True, invoke_without_command=True) + @commands.group(name="bookmark", aliases=["bm", "bookmarks"], case_insensitive=True, invoke_without_command=True) async def bookmark(self, ctx: commands.Context, *, label: Optional[str] = None): """Post a bookmarked message""" # No label: shortcut to display bookmarks @@ -92,7 +91,7 @@ class Discord(commands.Cog): ) await ctx.reply(result.jump_url, mention_author=False) - @bookmark.command(name="Create", aliases=["New"]) + @bookmark.command(name="create", aliases=["new"]) async def bookmark_create(self, ctx: commands.Context, label: str, message: Optional[discord.Message]): """Create a new bookmark""" # If no message was passed, allow replying to the message that should be bookmarked @@ -116,7 +115,7 @@ class Discord(commands.Cog): # Label isn't allowed return await ctx.reply(f"Bookmarks cannot be named `{label}`.", mention_author=False) - @bookmark.command(name="Delete", aliases=["Rm"]) + @bookmark.command(name="delete", aliases=["rm"]) async def bookmark_delete(self, ctx: commands.Context, bookmark_id: str): """Delete a bookmark by its id""" # The bookmarks are displayed with a hashtag in front of the id @@ -138,7 +137,7 @@ class Discord(commands.Cog): return await ctx.reply(f"Successfully deleted bookmark `#{bookmark_id_int}`.", mention_author=False) - @bookmark.command(name="Search", aliases=["List", "Ls"]) + @bookmark.command(name="search", aliases=["list", "ls"]) async def bookmark_search(self, ctx: commands.Context, *, query: Optional[str] = None): """Search through the list of bookmarks""" async with self.client.postgres_session as session: @@ -160,13 +159,13 @@ class Discord(commands.Cog): modal = CreateBookmark(self.client, message.jump_url) await interaction.response.send_modal(modal) - @commands.command(name="Join", usage="[Thread]") + @commands.command(name="join", usage="[Thread]") async def join(self, ctx: commands.Context, thread: discord.Thread): """Make Didier join a thread""" if thread.me is not None: return await ctx.reply() - @commands.command(name="Pin", usage="[Message]") + @commands.command(name="pin", usage="[Message]") async def pin(self, ctx: commands.Context, message: Optional[discord.Message] = None): """Pin a message in the current channel""" # If no message was passed, allow replying to the message that should be pinned diff --git a/didier/cogs/fun.py b/didier/cogs/fun.py index d604b36..9caefa5 100644 --- a/didier/cogs/fun.py +++ b/didier/cogs/fun.py @@ -31,7 +31,7 @@ class Fun(commands.Cog): @commands.hybrid_command( name="dadjoke", - aliases=["Dad", "Dj"], + aliases=["dad", "dj"], description="Why does Yoda's code always crash? Because there is no try.", ) async def dad_joke(self, ctx: commands.Context): @@ -40,14 +40,14 @@ class Fun(commands.Cog): joke = await get_random_dad_joke(session) return await ctx.reply(joke.joke, mention_author=False) - @commands.group(name="Memegen", aliases=["Meme", "Memes"], invoke_without_command=True, case_insensitive=True) + @commands.group(name="memegen", aliases=["meme", "memes"], invoke_without_command=True, case_insensitive=True) async def memegen_msg(self, ctx: commands.Context, meme_name: str, *, fields: str): """Command group for meme-related commands""" async with ctx.typing(): meme = await self._do_generate_meme(meme_name, shlex.split(fields)) return await ctx.reply(meme, mention_author=False) - @memegen_msg.command(name="Preview", aliases=["P"]) + @memegen_msg.command(name="preview", aliases=["p"]) async def memegen_preview_msg(self, ctx: commands.Context, meme_name: str): """Generate a preview for a meme, to see how the fields are structured""" async with ctx.typing(): diff --git a/didier/cogs/help.py b/didier/cogs/help.py index dc2ba84..8bb46d1 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -9,9 +9,10 @@ from didier.utils.discord.colours import error_red class CustomHelpCommand(commands.MinimalHelpCommand): - """Customised Help command to override the default implementation + """Customised Help command that overrides the default implementation - The default is ugly as hell, so we do some fiddling with it + The default is ugly as hell, so we do some fiddling with it and put everything + in fancy embeds """ @overrides @@ -154,6 +155,14 @@ class CustomHelpCommand(commands.MinimalHelpCommand): async def setup(client: Didier): """Load the cog""" - attributes = {"aliases": ["h", "man"]} + help_str = ( + "Shows the help page for a category or command. " + "`/commands` are not included, as they already have built-in descriptions in the UI." + "\n\nThe command signatures follow the POSIX-standard format for help messages:" + "\n- `required_positional_argument`" + "\n- `[optional_positional_argument]`" + ) + + attributes = {"aliases": ["h", "man"], "usage": "[category or command]", "help": help_str} client.help_command = CustomHelpCommand(command_attrs=attributes) diff --git a/didier/cogs/meta.py b/didier/cogs/meta.py index 69a3805..133ffd8 100644 --- a/didier/cogs/meta.py +++ b/didier/cogs/meta.py @@ -8,21 +8,24 @@ from didier import Didier class Meta(commands.Cog): - """Cog for Didier-related commands""" + """Commands related to Didier himself.""" client: Didier def __init__(self, client: Didier): self.client = client - @commands.command(name="Marco") + @commands.command(name="marco") async def marco(self, ctx: commands.Context): - """Ping command to get the delay of the bot""" + """Ping command to get Didier's latency.""" return await ctx.reply(f"Polo! {round(self.client.latency * 1000)}ms", mention_author=False) - @commands.command(name="Source", aliases=["Src"]) + @commands.command(name="source", aliases=["src"]) async def source(self, ctx: commands.Context, *, command_name: Optional[str] = None): - """Command to get links to the source code of Didier""" + """Get a link to the source code of Didier. + + If a value for `command_name` is passed, the source for `command_name` is shown instead. + """ repo_home = "https://github.com/stijndcl/didier" if command_name is None: @@ -38,12 +41,12 @@ class Meta(commands.Cog): filename = src.co_filename if command is None: - return await ctx.reply(f"Geen commando gevonden voor ``{command_name}``.", mention_author=False) + return await ctx.reply(f"Found no command named `{command_name}`.", mention_author=False) lines, first_line = inspect.getsourcelines(src) if filename is None: - return await ctx.reply(f"Geen code gevonden voor ``{command_name}``.", mention_author=False) + return await ctx.reply(f"Found no source file for `{command_name}`.", mention_author=False) file_location = os.path.relpath(filename).replace("\\", "/") From 65e1034372570bff1f62a28adeb0d8e121aa55cf Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 14:29:14 +0200 Subject: [PATCH 03/10] Generate help embed from function signature --- didier/cogs/help.py | 36 +++++++++++++++++++++++++++++++++--- didier/cogs/school.py | 20 +++++++++++--------- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/didier/cogs/help.py b/didier/cogs/help.py index 8bb46d1..0693f50 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -1,3 +1,4 @@ +import re from typing import Mapping, Optional import discord @@ -63,6 +64,30 @@ class CustomHelpCommand(commands.MinimalHelpCommand): def command_not_found(self, string: str, /) -> str: return f"Found no command named `{string}`." + @overrides + def get_command_signature(self, command: commands.Command, /) -> str: + signature_list = [command.name] + + # Perform renaming for hybrid commands + if hasattr(command.callback, "__discord_app_commands_param_rename__"): + renames = command.callback.__discord_app_commands_param_rename__ + else: + renames = {} + + sig = command.params + + for name, param in sig.items(): + name = renames.get(name, name) + is_optional = param.default is not param.empty + + # Wrap optional arguments in square brackets + if is_optional: + name = f"[{name}]" + + signature_list.append(name) + + return " ".join(signature_list) + @overrides async def send_bot_help(self, mapping: Mapping[Optional[commands.Cog], list[commands.Command]], /): embed = self._help_embed_base("Categories") @@ -122,10 +147,15 @@ class CustomHelpCommand(commands.MinimalHelpCommand): This allows re-using this logic for Group commands that can be invoked by themselves. """ - embed.description = command.help + # Regex borrowed from https://stackoverflow.com/a/59843498/13568999 + # Remove single newlines but keep double newlines + # This allows short lines in the docstring, but joins them together for the help description + embed.description = re.sub( + r"([^\S\n]*\n(?:[^\S\n]*\n)+[^\S\n]*)|[^\S\n]*\n[^\S\n]*", lambda x: x.group(1) or " ", command.help + ) - if command.usage: - embed.add_field(name="Signature", value=f"{command.name} {command.usage}", inline=False) + signature = self.get_command_signature(command) + embed.add_field(name="Signature", value=signature, inline=False) if command.aliases: embed.add_field(name="Aliases", value=", ".join(command.aliases), inline=False) diff --git a/didier/cogs/school.py b/didier/cogs/school.py index 6b2d835..d90b905 100644 --- a/didier/cogs/school.py +++ b/didier/cogs/school.py @@ -27,21 +27,23 @@ class School(commands.Cog): def __init__(self, client: Didier): self.client = client - @commands.hybrid_command(name="deadlines", description="Show upcoming deadlines") + @commands.hybrid_command(name="deadlines") async def deadlines(self, ctx: commands.Context): - """Show upcoming deadlines""" + """Show upcoming deadlines.""" async with self.client.postgres_session as session: deadlines = await get_deadlines(session) embed = Deadlines(deadlines).to_embed() await ctx.reply(embed=embed, mention_author=False, ephemeral=False) - @commands.hybrid_command( - name="les", description="Show your personalized schedule for a given day.", aliases=["Sched", "Schedule"] - ) + @commands.hybrid_command(name="les", aliases=["sched", "schedule"]) @app_commands.rename(day_dt="date") async def les(self, ctx: commands.Context, day_dt: Optional[app_commands.Transform[date, DateTransformer]] = None): - """Show your personalized schedule for a given day.""" + """Show your personalized schedule for a given day. + + If no day is provided, this defaults to the schedule for the current day. When invoked during a weekend, + it will skip forward to the next weekday instead. + """ if day_dt is None: day_dt = date.today() @@ -62,14 +64,14 @@ class School(commands.Cog): @commands.hybrid_command( name="menu", - description="Show the menu in the Ghent University restaurants.", - aliases=["Eten", "Food"], + aliases=["eten", "food"], ) @app_commands.rename(day_dt="date") async def menu(self, ctx: commands.Context, day_dt: Optional[app_commands.Transform[date, DateTransformer]] = None): """Show the menu in the Ghent University restaurants. - Menus are Dutch, as a lot of dishes have very weird translations + If no day is provided, this defaults to the schedule for the current day. + Menus are shown in Dutch by default, as a lot of dishes have very weird translations. """ if day_dt is None: day_dt = date.today() From da365e3bc1f7f90f57e8c19de628c945ff8e0f1b Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 15:59:12 +0200 Subject: [PATCH 04/10] Don't split code in codeblocks --- didier/cogs/currency.py | 16 ++++++++++++++-- didier/cogs/help.py | 30 ++++++++++++++++++++++++------ didier/utils/types/string.py | 26 +++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/didier/cogs/currency.py b/didier/cogs/currency.py index ea96187..87ea898 100644 --- a/didier/cogs/currency.py +++ b/didier/cogs/currency.py @@ -120,7 +120,19 @@ class Currency(commands.Cog): @commands.command(name="invest", aliases=["deposit", "dep"]) async def invest(self, ctx: commands.Context, amount: typing.Annotated[typing.Union[str, int], abbreviated_number]): - """Invest a given amount of Didier Dinks""" + """Invest a given `amount` Didier Dinks. + + The `amount`-parameter can take both raw numbers, and abbreviations of big numbers. Passing `all` as the + value will invest all of your Didier Dinks. + + Example usage: + ``` + didier invest all + didier invest 500 + didier invest 25k + didier invest 5.3b + ``` + """ async with self.client.postgres_session as session: invested = await crud.invest(session, ctx.author.id, amount) plural = pluralize("Didier Dink", invested) @@ -134,7 +146,7 @@ class Currency(commands.Cog): @commands.hybrid_command(name="nightly") async def nightly(self, ctx: commands.Context): - """Claim nightly Didier Dinks""" + """Claim nightly Didier Dinks.""" async with self.client.postgres_session as session: try: await crud.claim_nightly(session, ctx.author.id) diff --git a/didier/cogs/help.py b/didier/cogs/help.py index 0693f50..a43a7f1 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -7,6 +7,7 @@ from overrides import overrides from didier import Didier from didier.utils.discord.colours import error_red +from didier.utils.types.string import re_find_all, re_replace_with_list class CustomHelpCommand(commands.MinimalHelpCommand): @@ -142,17 +143,34 @@ class CustomHelpCommand(commands.MinimalHelpCommand): embed = discord.Embed(title=title.title(), colour=discord.Colour.blue()) return embed + def _clean_command_help(self, command: commands.Command) -> str: + """Clean up a help docstring + + This will strip out single newlines, because these are only there for readability and line length. + These are instead replaced with spaces. + + Code in codeblocks is ignored, as it is used to create examples. + """ + description = command.help + codeblocks = re_find_all(r"\n?```.*?```", description, flags=re.DOTALL) + + # Regex borrowed from https://stackoverflow.com/a/59843498/13568999 + description = re.sub( + r"([^\S\n]*\n(?:[^\S\n]*\n)+[^\S\n]*)|[^\S\n]*\n[^\S\n]*", lambda x: x.group(1) or " ", description + ) + + # Replace codeblocks with their original form + if codeblocks: + description = re_replace_with_list(r"```.*?```", description, codeblocks) + + return description + def _add_command_help(self, embed: discord.Embed, command: commands.Command): """Add command-related information to an embed This allows re-using this logic for Group commands that can be invoked by themselves. """ - # Regex borrowed from https://stackoverflow.com/a/59843498/13568999 - # Remove single newlines but keep double newlines - # This allows short lines in the docstring, but joins them together for the help description - embed.description = re.sub( - r"([^\S\n]*\n(?:[^\S\n]*\n)+[^\S\n]*)|[^\S\n]*\n[^\S\n]*", lambda x: x.group(1) or " ", command.help - ) + embed.description = self._clean_command_help(command) signature = self.get_command_signature(command) embed.add_field(name="Signature", value=signature, inline=False) diff --git a/didier/utils/types/string.py b/didier/utils/types/string.py index 4e02573..b8c5e04 100644 --- a/didier/utils/types/string.py +++ b/didier/utils/types/string.py @@ -1,7 +1,8 @@ import math +import re from typing import Optional -__all__ = ["abbreviate", "leading", "pluralize", "get_edu_year_name"] +__all__ = ["abbreviate", "leading", "pluralize", "re_find_all", "re_replace_with_list", "get_edu_year_name"] def abbreviate(text: str, max_length: int) -> str: @@ -45,6 +46,29 @@ def pluralize(word: str, amount: int, plural_form: Optional[str] = None) -> str: return plural_form or (word + "s") +def re_find_all(pattern: str, string: str, flags: re.RegexFlag = 0) -> list[str]: + """Find all matches of a regex in a string""" + matches = [] + + while True: + match = re.search(pattern, string, flags=flags) + if not match: + break + + matches.append(match.group(0)) + string = string[match.end() :] + + return matches + + +def re_replace_with_list(pattern: str, string: str, replacements: list[str]) -> str: + """Replace all matches of a pattern one by one using a list of replacements""" + for replacement in replacements: + string = re.sub(pattern, replacement, string, count=1) + + return string + + def get_edu_year_name(year: int) -> str: # pragma: no cover """Get the string representation of a university year""" years = ["1st Bachelor", "2nd Bachelor", "3rd Bachelor", "1st Master", "2nd Master"] From 5cdb6c3f44867de22b77cda7374516f20eddfe09 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 17:23:37 +0200 Subject: [PATCH 05/10] More docstrings --- didier/cogs/currency.py | 18 +++++------ didier/cogs/discord.py | 68 ++++++++++++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/didier/cogs/currency.py b/didier/cogs/currency.py index 87ea898..a5d17cd 100644 --- a/didier/cogs/currency.py +++ b/didier/cogs/currency.py @@ -17,7 +17,7 @@ from didier.utils.types.string import pluralize class Currency(commands.Cog): - """Everything Dinks-related""" + """Everything Dinks-related.""" client: Didier @@ -33,7 +33,7 @@ class Currency(commands.Cog): user: discord.User, amount: typing.Annotated[int, abbreviated_number], ): - """Award a user a given amount of Didier Dinks""" + """Award a user `amount` Didier Dinks.""" async with self.client.postgres_session as session: await crud.add_dinks(session, user.id, amount) plural = pluralize("Didier Dink", amount) @@ -44,7 +44,7 @@ class Currency(commands.Cog): @commands.group(name="bank", aliases=["b"], case_insensitive=True, invoke_without_command=True) async def bank(self, ctx: commands.Context): - """Show your Didier Bank information""" + """Show your Didier Bank information.""" async with self.client.postgres_session as session: bank = await crud.get_bank(session, ctx.author.id) @@ -59,7 +59,7 @@ class Currency(commands.Cog): @bank.group(name="upgrade", aliases=["u", "upgrades"], case_insensitive=True, invoke_without_command=True) async def bank_upgrades(self, ctx: commands.Context): - """List the upgrades you can buy & their prices""" + """List the upgrades you can buy & their prices.""" async with self.client.postgres_session as session: bank = await crud.get_bank(session, ctx.author.id) @@ -79,7 +79,7 @@ class Currency(commands.Cog): @bank_upgrades.command(name="capacity", aliases=["c"]) async def bank_upgrade_capacity(self, ctx: commands.Context): - """Upgrade the capacity level of your bank""" + """Upgrade the capacity level of your bank.""" async with self.client.postgres_session as session: try: await crud.upgrade_capacity(session, ctx.author.id) @@ -90,7 +90,7 @@ class Currency(commands.Cog): @bank_upgrades.command(name="interest", aliases=["i"]) async def bank_upgrade_interest(self, ctx: commands.Context): - """Upgrade the interest level of your bank""" + """Upgrade the interest level of your bank.""" async with self.client.postgres_session as session: try: await crud.upgrade_interest(session, ctx.author.id) @@ -101,7 +101,7 @@ class Currency(commands.Cog): @bank_upgrades.command(name="rob", aliases=["r"]) async def bank_upgrade_rob(self, ctx: commands.Context): - """Upgrade the rob level of your bank""" + """Upgrade the rob level of your bank.""" async with self.client.postgres_session as session: try: await crud.upgrade_rob(session, ctx.author.id) @@ -112,7 +112,7 @@ class Currency(commands.Cog): @commands.hybrid_command(name="dinks") async def dinks(self, ctx: commands.Context): - """Check your Didier Dinks""" + """Check your Didier Dinks.""" async with self.client.postgres_session as session: bank = await crud.get_bank(session, ctx.author.id) plural = pluralize("Didier Dink", bank.dinks) @@ -120,7 +120,7 @@ class Currency(commands.Cog): @commands.command(name="invest", aliases=["deposit", "dep"]) async def invest(self, ctx: commands.Context, amount: typing.Annotated[typing.Union[str, int], abbreviated_number]): - """Invest a given `amount` Didier Dinks. + """Invest `amount` Didier Dinks into your bank. The `amount`-parameter can take both raw numbers, and abbreviations of big numbers. Passing `all` as the value will invest all of your Didier Dinks. diff --git a/didier/cogs/discord.py b/didier/cogs/discord.py index a6467bc..60a987a 100644 --- a/didier/cogs/discord.py +++ b/didier/cogs/discord.py @@ -22,7 +22,7 @@ from didier.views.modals import CreateBookmark class Discord(commands.Cog): - """Cog for commands related to Discord, servers, and members""" + """Commands related to Discord itself, which work with resources like servers and members.""" client: Didier @@ -45,7 +45,10 @@ class Discord(commands.Cog): @commands.group(name="birthday", aliases=["bd", "birthdays"], case_insensitive=True, invoke_without_command=True) async def birthday(self, ctx: commands.Context, user: discord.User = None): - """Command to check the birthday of a user""" + """Command to check the birthday of `user`. + + Not passing an argument for `user` will show yours instead. + """ user_id = (user and user.id) or ctx.author.id async with self.client.postgres_session as session: birthday = await birthdays.get_birthday_for_user(session, user_id) @@ -59,18 +62,22 @@ class Discord(commands.Cog): return await ctx.reply(f"{name or 'Your'} birthday is set to **{day}/{month}**.", mention_author=False) @birthday.command(name="set", aliases=["config"]) - async def birthday_set(self, ctx: commands.Context, date_str: str): - """Command to set your birthday""" + async def birthday_set(self, ctx: commands.Context, day: str): + """Set your birthday to `day`. + + Parsing of the `day`-parameter happens in the following order: `DD/MM/YYYY`, `DD/MM/YY`, `DD/MM`. + Other formats will not be accepted. + """ try: default_year = 2001 - date = str_to_date(date_str, formats=["%d/%m/%Y", "%d/%m/%y", "%d/%m"]) + date = str_to_date(day, formats=["%d/%m/%Y", "%d/%m/%y", "%d/%m"]) # If no year was passed, make it 2001 by default - if date_str.count("/") == 1: + if day.count("/") == 1: date.replace(year=default_year) except ValueError: - return await ctx.reply(f"`{date_str}` is not a valid date.", mention_author=False) + return await ctx.reply(f"`{day}` is not a valid date.", mention_author=False) async with self.client.postgres_session as session: await birthdays.add_birthday(session, ctx.author.id, date) @@ -78,7 +85,15 @@ class Discord(commands.Cog): @commands.group(name="bookmark", aliases=["bm", "bookmarks"], case_insensitive=True, invoke_without_command=True) async def bookmark(self, ctx: commands.Context, *, label: Optional[str] = None): - """Post a bookmarked message""" + """Post the message bookmarked with `label`. + + The `label` argument can contain spaces and does not require quotes around it. For example: + ``` + didier bookmark some label with multiple words + ``` + + If no argument for `label` is provided, this is a shortcut to `bookmark search`. + """ # No label: shortcut to display bookmarks if label is None: return await self.bookmark_search(ctx, query=None) @@ -93,7 +108,13 @@ class Discord(commands.Cog): @bookmark.command(name="create", aliases=["new"]) async def bookmark_create(self, ctx: commands.Context, label: str, message: Optional[discord.Message]): - """Create a new bookmark""" + """Create a new bookmark for message `message` with label `label`. + + Instead of the link to a message, you can also reply to the message you wish to bookmark. In this case, + the `message`-parameter can be left out. + + `label` can not be names (or aliases) of subcommands. + """ # If no message was passed, allow replying to the message that should be bookmarked if message is None and ctx.message.reference is not None: message = await self.client.resolve_message(ctx.message.reference) @@ -117,7 +138,10 @@ class Discord(commands.Cog): @bookmark.command(name="delete", aliases=["rm"]) async def bookmark_delete(self, ctx: commands.Context, bookmark_id: str): - """Delete a bookmark by its id""" + """Delete the bookmark with id `bookmark_id`. + + You can only delete your own bookmarks. + """ # The bookmarks are displayed with a hashtag in front of the id # so strip it out in case people want to try and use this bookmark_id = bookmark_id.removeprefix("#") @@ -139,7 +163,11 @@ class Discord(commands.Cog): @bookmark.command(name="search", aliases=["list", "ls"]) async def bookmark_search(self, ctx: commands.Context, *, query: Optional[str] = None): - """Search through the list of bookmarks""" + """Search through the list of bookmarks. + + If a value for `query` was provided, results will be filtered down to only labels that include `query`. + Otherwise, all bookmarks are displayed. + """ async with self.client.postgres_session as session: results = await bookmarks.get_bookmarks(session, ctx.author.id, query=query) @@ -159,15 +187,25 @@ class Discord(commands.Cog): modal = CreateBookmark(self.client, message.jump_url) await interaction.response.send_modal(modal) - @commands.command(name="join", usage="[Thread]") + @commands.command(name="join") async def join(self, ctx: commands.Context, thread: discord.Thread): - """Make Didier join a thread""" + """Make Didier join `thread`. + + This command should generally not be necessary, as Didier automatically joins threads. However, it's possible + that Didier is offline at the moment of a thread being created. + + Alternatively, you can also `@mention` Didier to pull him into the thread instead. + """ if thread.me is not None: return await ctx.reply() - @commands.command(name="pin", usage="[Message]") + @commands.command(name="pin") async def pin(self, ctx: commands.Context, message: Optional[discord.Message] = None): - """Pin a message in the current channel""" + """Pin `message` in the current channel. + + Instead of the link to a message, you can also reply to the message you wish to pin. In this case, + the `message`-parameter can be left out. + """ # If no message was passed, allow replying to the message that should be pinned if message is None and ctx.message.reference is not None: message = await self.client.resolve_message(ctx.message.reference) From 7035f0773fb579fdb5c445ebaabb235eb12b11a4 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 17:42:51 +0200 Subject: [PATCH 06/10] Help messages for Fun --- didier/cogs/currency.py | 4 ++-- didier/cogs/discord.py | 6 ++--- didier/cogs/fun.py | 51 ++++++++++++++++++++++------------------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/didier/cogs/currency.py b/didier/cogs/currency.py index a5d17cd..709a461 100644 --- a/didier/cogs/currency.py +++ b/didier/cogs/currency.py @@ -122,8 +122,8 @@ class Currency(commands.Cog): async def invest(self, ctx: commands.Context, amount: typing.Annotated[typing.Union[str, int], abbreviated_number]): """Invest `amount` Didier Dinks into your bank. - The `amount`-parameter can take both raw numbers, and abbreviations of big numbers. Passing `all` as the - value will invest all of your Didier Dinks. + The `amount`-argument can take both raw numbers, and abbreviations of big numbers. Additionally, passing + `all` as the value will invest all of your Didier Dinks. Example usage: ``` diff --git a/didier/cogs/discord.py b/didier/cogs/discord.py index 60a987a..9e0cce1 100644 --- a/didier/cogs/discord.py +++ b/didier/cogs/discord.py @@ -65,7 +65,7 @@ class Discord(commands.Cog): async def birthday_set(self, ctx: commands.Context, day: str): """Set your birthday to `day`. - Parsing of the `day`-parameter happens in the following order: `DD/MM/YYYY`, `DD/MM/YY`, `DD/MM`. + Parsing of the `day`-argument happens in the following order: `DD/MM/YYYY`, `DD/MM/YY`, `DD/MM`. Other formats will not be accepted. """ try: @@ -111,7 +111,7 @@ class Discord(commands.Cog): """Create a new bookmark for message `message` with label `label`. Instead of the link to a message, you can also reply to the message you wish to bookmark. In this case, - the `message`-parameter can be left out. + the `message`-argument can be left out. `label` can not be names (or aliases) of subcommands. """ @@ -204,7 +204,7 @@ class Discord(commands.Cog): """Pin `message` in the current channel. Instead of the link to a message, you can also reply to the message you wish to pin. In this case, - the `message`-parameter can be left out. + the `message`-argument can be left out. """ # If no message was passed, allow replying to the message that should be pinned if message is None and ctx.message.reference is not None: diff --git a/didier/cogs/fun.py b/didier/cogs/fun.py index 9caefa5..f320ac4 100644 --- a/didier/cogs/fun.py +++ b/didier/cogs/fun.py @@ -18,7 +18,7 @@ class Fun(commands.Cog): client: Didier # Slash groups - memes_slash = app_commands.Group(name="meme", description="Commands to generate memes", guild_only=False) + memes_slash = app_commands.Group(name="meme", description="Commands to generate memes.", guild_only=False) def __init__(self, client: Didier): self.client = client @@ -32,56 +32,61 @@ class Fun(commands.Cog): @commands.hybrid_command( name="dadjoke", aliases=["dad", "dj"], - description="Why does Yoda's code always crash? Because there is no try.", ) async def dad_joke(self, ctx: commands.Context): - """Get a random dad joke""" + """Why does Yoda's code always crash? Because there is no try.""" async with self.client.postgres_session as session: joke = await get_random_dad_joke(session) return await ctx.reply(joke.joke, mention_author=False) @commands.group(name="memegen", aliases=["meme", "memes"], invoke_without_command=True, case_insensitive=True) - async def memegen_msg(self, ctx: commands.Context, meme_name: str, *, fields: str): - """Command group for meme-related commands""" + async def memegen_msg(self, ctx: commands.Context, template: str, *, fields: str): + """Generate a meme with template `template` and fields `fields`. + + The arguments are parsed based on spaces. Arguments that contain spaces should be wrapped in "quotes". + + Example: `memegen a b c d` will be parsed as `template: "a"`, `fields: ["b", "c", "d"]` + + Example: `memegen "a b" "c d"` will be parsed as `template: "a b"`, `fields: ["c d"]` + """ async with ctx.typing(): - meme = await self._do_generate_meme(meme_name, shlex.split(fields)) + meme = await self._do_generate_meme(template, shlex.split(fields)) return await ctx.reply(meme, mention_author=False) @memegen_msg.command(name="preview", aliases=["p"]) - async def memegen_preview_msg(self, ctx: commands.Context, meme_name: str): - """Generate a preview for a meme, to see how the fields are structured""" + async def memegen_preview_msg(self, ctx: commands.Context, template: str): + """Generate a preview for the meme template `template`, to see how the fields are structured.""" async with ctx.typing(): fields = [f"Field #{i + 1}" for i in range(20)] - meme = await self._do_generate_meme(meme_name, fields) + meme = await self._do_generate_meme(template, fields) return await ctx.reply(meme, mention_author=False) - @memes_slash.command(name="generate", description="Generate a meme") - async def memegen_slash(self, interaction: discord.Interaction, meme: str): - """Slash command to generate a meme""" + @memes_slash.command(name="generate") + async def memegen_slash(self, interaction: discord.Interaction, template: str): + """Generate a meme with template `template`.""" async with self.client.postgres_session as session: - result = expect(await get_meme_by_name(session, meme), entity_type="meme", argument=meme) + result = expect(await get_meme_by_name(session, template), entity_type="meme", argument=template) modal = GenerateMeme(self.client, result) await interaction.response.send_modal(modal) - @memes_slash.command( - name="preview", description="Generate a preview for a meme, to see how the fields are structured" - ) - async def memegen_preview_slash(self, interaction: discord.Interaction, meme: str): - """Slash command to generate a meme preview""" + @memes_slash.command(name="preview") + @app_commands.describe(template="The meme template to use in the preview.") + async def memegen_preview_slash(self, interaction: discord.Interaction, template: str): + """Generate a preview for a meme, to see how the fields are structured.""" await interaction.response.defer() fields = [f"Field #{i + 1}" for i in range(20)] - meme_url = await self._do_generate_meme(meme, fields) + meme_url = await self._do_generate_meme(template, fields) await interaction.followup.send(meme_url, ephemeral=True) - @memegen_slash.autocomplete("meme") - @memegen_preview_slash.autocomplete("meme") - async def _memegen_slash_autocomplete_meme( + @memegen_slash.autocomplete("template") + @memegen_preview_slash.autocomplete("template") + async def _memegen_slash_autocomplete_template( self, _: discord.Interaction, current: str ) -> list[app_commands.Choice[str]]: - """Autocompletion for the 'meme'-parameter""" + """Autocompletion for the 'template'-parameter""" return self.client.database_caches.memes.get_autocomplete_suggestions(current) From 5d2d7c49c2cbf3c4392e2d3968013721990a2292 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 17:50:33 +0200 Subject: [PATCH 07/10] Hide empty cogs --- didier/cogs/help.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/didier/cogs/help.py b/didier/cogs/help.py index a43a7f1..752a836 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -143,7 +143,7 @@ class CustomHelpCommand(commands.MinimalHelpCommand): embed = discord.Embed(title=title.title(), colour=discord.Colour.blue()) return embed - def _clean_command_help(self, command: commands.Command) -> str: + def _clean_command_doc(self, command: commands.Command) -> str: """Clean up a help docstring This will strip out single newlines, because these are only there for readability and line length. @@ -170,7 +170,7 @@ class CustomHelpCommand(commands.MinimalHelpCommand): This allows re-using this logic for Group commands that can be invoked by themselves. """ - embed.description = self._clean_command_help(command) + embed.description = self._clean_command_doc(command) signature = self.get_command_signature(command) embed.add_field(name="Signature", value=signature, inline=False) @@ -188,16 +188,27 @@ class CustomHelpCommand(commands.MinimalHelpCommand): async def _filter_cogs(self, cogs: list[commands.Cog]) -> list[commands.Cog]: """Filter the list of cogs down to all those that the user can see""" - # Remove cogs that we never want to see in the help page because they - # don't contain commands - filtered_cogs = list( - filter(lambda cog: cog is not None and cog.qualified_name.lower() not in ("tasks", "debugcog"), cogs) - ) - # Remove owner-only cogs for people that shouldn't see them - if not await self.context.bot.is_owner(self.context.author): - filtered_cogs = list(filter(lambda cog: cog.qualified_name.lower() not in ("owner",), filtered_cogs)) + async def _predicate(cog: Optional[commands.Cog]) -> bool: + if cog is None: + return False + # Remove cogs that we never want to see in the help page because they + # don't contain commands, or shouldn't be visible at all + if not cog.get_commands(): + return False + + if cog.qualified_name.lower() in ("tasks", "debugcog"): + return False + + # Hide owner-only cogs if you're not the owner + if not await self.context.bot.is_owner(self.context.author): + return cog.qualified_name.lower() not in ("owner",) + + return True + + # Filter list of cogs down + filtered_cogs = [cog for cog in cogs if await _predicate(cog)] return list(sorted(filtered_cogs, key=lambda cog: cog.qualified_name)) From 5511046e359ca495a41e2b3ffb9f7f6bb238c688 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 18:15:10 +0200 Subject: [PATCH 08/10] Final docstrings --- didier/cogs/discord.py | 14 ++++++++++---- didier/cogs/meta.py | 9 ++++++++- didier/cogs/other.py | 29 +++++++++++++++++++---------- didier/cogs/school.py | 22 ++++++++++++++++++---- didier/utils/types/string.py | 4 ++-- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/didier/cogs/discord.py b/didier/cogs/discord.py index 9e0cce1..c13c782 100644 --- a/didier/cogs/discord.py +++ b/didier/cogs/discord.py @@ -53,7 +53,7 @@ class Discord(commands.Cog): async with self.client.postgres_session as session: birthday = await birthdays.get_birthday_for_user(session, user_id) - name: Optional[str] = user and f"{user.display_name}'s" + name: Optional[str] = f"{user.display_name}'s" if user is not None else None if birthday is None: return await ctx.reply(f"I don't know {name or 'your'} birthday.", mention_author=False) @@ -87,12 +87,12 @@ class Discord(commands.Cog): async def bookmark(self, ctx: commands.Context, *, label: Optional[str] = None): """Post the message bookmarked with `label`. - The `label` argument can contain spaces and does not require quotes around it. For example: + The `label`-argument can contain spaces and does not require quotes around it. For example: ``` didier bookmark some label with multiple words ``` - If no argument for `label` is provided, this is a shortcut to `bookmark search`. + When no value for `label` is provided, this is a shortcut to `bookmark search`. """ # No label: shortcut to display bookmarks if label is None: @@ -113,7 +113,8 @@ class Discord(commands.Cog): Instead of the link to a message, you can also reply to the message you wish to bookmark. In this case, the `message`-argument can be left out. - `label` can not be names (or aliases) of subcommands. + `label` can not be names (or aliases) of subcommands. However, names with spaces are allowed. If you wish + to use a name with spaces, it must be wrapped in "quotes". """ # If no message was passed, allow replying to the message that should be bookmarked if message is None and ctx.message.reference is not None: @@ -167,6 +168,11 @@ class Discord(commands.Cog): If a value for `query` was provided, results will be filtered down to only labels that include `query`. Otherwise, all bookmarks are displayed. + + The `query`-argument can contain spaces and does not require quotes around it. For example: + ``` + didier bookmark search some query with multiple words + ``` """ async with self.client.postgres_session as session: results = await bookmarks.get_bookmarks(session, ctx.author.id, query=query) diff --git a/didier/cogs/meta.py b/didier/cogs/meta.py index 133ffd8..7b7a732 100644 --- a/didier/cogs/meta.py +++ b/didier/cogs/meta.py @@ -17,7 +17,7 @@ class Meta(commands.Cog): @commands.command(name="marco") async def marco(self, ctx: commands.Context): - """Ping command to get Didier's latency.""" + """Get Didier's latency.""" return await ctx.reply(f"Polo! {round(self.client.latency * 1000)}ms", mention_author=False) @commands.command(name="source", aliases=["src"]) @@ -25,6 +25,13 @@ class Meta(commands.Cog): """Get a link to the source code of Didier. If a value for `command_name` is passed, the source for `command_name` is shown instead. + + Example usage: + ``` + didier source + didier source dinks + didier source source + ``` """ repo_home = "https://github.com/stijndcl/didier" diff --git a/didier/cogs/other.py b/didier/cogs/other.py index dea9cd2..06ec2a4 100644 --- a/didier/cogs/other.py +++ b/didier/cogs/other.py @@ -13,16 +13,18 @@ from didier.data.scrapers import google class Other(commands.Cog): - """Cog for commands that don't really belong anywhere else""" + """Commands that don't really belong anywhere else.""" client: Didier def __init__(self, client: Didier): self.client = client - @commands.hybrid_command(name="define", description="Urban Dictionary", aliases=["Ud", "Urban"], usage="[Term]") + @commands.hybrid_command( + name="define", aliases=["ud", "urban"], description="Look up the definition of a word on the Urban Dictionary" + ) async def define(self, ctx: commands.Context, *, query: str): - """Look up the definition of a word on the Urban Dictionary""" + """Look up the definition of `query` on the Urban Dictionary.""" async with ctx.typing(): status_code, definitions = await urban_dictionary.lookup(self.client.http_session, query) if not definitions: @@ -30,10 +32,17 @@ class Other(commands.Cog): await ctx.reply(embed=definitions[0].to_embed(), mention_author=False) - @commands.hybrid_command(name="google", description="Google search", usage="[Query]") + @commands.hybrid_command(name="google", description="Google search") @app_commands.describe(query="Search query") async def google(self, ctx: commands.Context, *, query: str): - """Google something""" + """Show the Google search results for `query`. + + The `query`-argument can contain spaces and does not require quotes around it. For example: + ``` + didier query didier source github + didier query "didier source github" + ``` + """ async with ctx.typing(): results = await google.google_search(self.client.http_session, query) embed = GoogleSearch(results).to_embed() @@ -43,9 +52,9 @@ class Other(commands.Cog): async with self.client.postgres_session as session: return await get_link_by_name(session, name.lower()) - @commands.command(name="Link", aliases=["Links"], usage="[Name]") + @commands.command(name="Link", aliases=["Links"]) async def link_msg(self, ctx: commands.Context, name: str): - """Message command to get the link to something""" + """Get the link to the resource named `name`.""" link = await self._get_link(name) if link is None: return await ctx.reply(f"Found no links matching `{name}`.", mention_author=False) @@ -53,10 +62,10 @@ class Other(commands.Cog): target_message = await self.client.get_reply_target(ctx) await target_message.reply(link.url, mention_author=False) - @app_commands.command(name="link", description="Get the link to something") - @app_commands.describe(name="The name of the link") + @app_commands.command(name="link") + @app_commands.describe(name="The name of the resource") async def link_slash(self, interaction: discord.Interaction, name: str): - """Slash command to get the link to something""" + """Get the link to something.""" link = await self._get_link(name) if link is None: return await interaction.response.send_message(f"Found no links matching `{name}`.", ephemeral=True) diff --git a/didier/cogs/school.py b/didier/cogs/school.py index d90b905..877cc3f 100644 --- a/didier/cogs/school.py +++ b/didier/cogs/school.py @@ -43,6 +43,9 @@ class School(commands.Cog): If no day is provided, this defaults to the schedule for the current day. When invoked during a weekend, it will skip forward to the next weekday instead. + + Schedules are personalized based on your roles in the server. If your schedule doesn't look right, make sure + that you've got the correct roles selected. In case you do, ping D STIJN. """ if day_dt is None: day_dt = date.today() @@ -68,9 +71,9 @@ class School(commands.Cog): ) @app_commands.rename(day_dt="date") async def menu(self, ctx: commands.Context, day_dt: Optional[app_commands.Transform[date, DateTransformer]] = None): - """Show the menu in the Ghent University restaurants. + """Show the menu in the Ghent University restaurants on `date`. - If no day is provided, this defaults to the schedule for the current day. + If no value for `date` is provided, this defaults to the schedule for the current day. Menus are shown in Dutch by default, as a lot of dishes have very weird translations. """ if day_dt is None: @@ -85,11 +88,22 @@ class School(commands.Cog): await ctx.reply(embed=embed, mention_author=False) @commands.hybrid_command( - name="fiche", description="Sends the link to the study guide for [Course]", aliases=["guide", "studiefiche"] + name="fiche", description="Sends the link to study guides", aliases=["guide", "studiefiche"] ) @app_commands.describe(course="The name of the course to fetch the study guide for (aliases work too)") async def study_guide(self, ctx: commands.Context, course: str, *, flags: StudyGuideFlags): - """Create links to study guides""" + """Sends the link to the study guide for `course`. + + The value for `course` can contain spaces, but must be wrapped in "quotes". + + Aliases (nicknames) for courses are also accepted, given that they are known and in the database. + + Example usage: + ``` + didier fiche ad2 + didier fiche "algoritmen en datastructuren 2" + ``` + """ async with self.client.postgres_session as session: ufora_course = await ufora_courses.get_course_by_name(session, course) diff --git a/didier/utils/types/string.py b/didier/utils/types/string.py index b8c5e04..5cc5f93 100644 --- a/didier/utils/types/string.py +++ b/didier/utils/types/string.py @@ -1,6 +1,6 @@ import math import re -from typing import Optional +from typing import Optional, Union __all__ = ["abbreviate", "leading", "pluralize", "re_find_all", "re_replace_with_list", "get_edu_year_name"] @@ -46,7 +46,7 @@ def pluralize(word: str, amount: int, plural_form: Optional[str] = None) -> str: return plural_form or (word + "s") -def re_find_all(pattern: str, string: str, flags: re.RegexFlag = 0) -> list[str]: +def re_find_all(pattern: str, string: str, flags: Union[int, re.RegexFlag] = 0) -> list[str]: """Find all matches of a regex in a string""" matches = [] From bef8742459fc94eafaf6c7e65f3c51e7ed4f8e25 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 18:50:03 +0200 Subject: [PATCH 09/10] Add flags to help pages --- didier/cogs/help.py | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/didier/cogs/help.py b/didier/cogs/help.py index 752a836..5ae09f0 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -1,5 +1,5 @@ import re -from typing import Mapping, Optional +from typing import Mapping, Optional, Type import discord from discord.ext import commands @@ -7,6 +7,7 @@ from overrides import overrides from didier import Didier from didier.utils.discord.colours import error_red +from didier.utils.discord.flags import PosixFlags from didier.utils.types.string import re_find_all, re_replace_with_list @@ -85,7 +86,10 @@ class CustomHelpCommand(commands.MinimalHelpCommand): if is_optional: name = f"[{name}]" - signature_list.append(name) + if issubclass(param.annotation, PosixFlags): + signature_list.append("[--OPTIONS]") + else: + signature_list.append(name) return " ".join(signature_list) @@ -163,6 +167,11 @@ class CustomHelpCommand(commands.MinimalHelpCommand): if codeblocks: description = re_replace_with_list(r"```.*?```", description, codeblocks) + # Add flag help in + flags_class = self._get_flags_class(command) + if flags_class is not None: + description += f"\n\n{self.get_flags_help(flags_class)}" + return description def _add_command_help(self, embed: discord.Embed, command: commands.Command): @@ -211,6 +220,34 @@ class CustomHelpCommand(commands.MinimalHelpCommand): filtered_cogs = [cog for cog in cogs if await _predicate(cog)] return list(sorted(filtered_cogs, key=lambda cog: cog.qualified_name)) + def _get_flags_class(self, command: commands.Command) -> Optional[Type[PosixFlags]]: + """Check if a command has flags""" + flag_param = command.params.get("flags", None) + if flag_param is None: + return None + + if issubclass(flag_param.annotation, PosixFlags): + return flag_param.annotation + + return None + + def get_flags_help(self, flags_class: Type[PosixFlags]) -> str: + """Get the description for flag arguments""" + help_data = [] + + # Present flags in alphabetical order, as dicts have no set ordering + flag_mapping = flags_class.__commands_flags__ + flags = list(flag_mapping.items()) + flags.sort(key=lambda f: f[0]) + + for name, flag in flags: + flag_names = [name, *flag.aliases] + # Add the --prefix in front of all flags + flag_names = list(map(lambda n: f"--{n}", flag_names)) + help_data.append(f"{', '.join(flag_names)} [default `{flag.default}`]") + + return "Options:\n" + "\n".join(help_data) + async def setup(client: Didier): """Load the cog""" From 6ef4007f13a13c7c2622e1e8cb353ab7f7b91cdd Mon Sep 17 00:00:00 2001 From: stijndcl Date: Mon, 19 Sep 2022 18:54:25 +0200 Subject: [PATCH 10/10] Small bugfix --- didier/cogs/help.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/didier/cogs/help.py b/didier/cogs/help.py index 5ae09f0..c6d88d6 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -1,3 +1,4 @@ +import inspect import re from typing import Mapping, Optional, Type @@ -86,7 +87,9 @@ class CustomHelpCommand(commands.MinimalHelpCommand): if is_optional: name = f"[{name}]" - if issubclass(param.annotation, PosixFlags): + # If there are options/flags, add them + # The hardcoded name-check is done for performance reasons + if name == "flags" and inspect.isclass(param.annotation) and issubclass(param.annotation, PosixFlags): signature_list.append("[--OPTIONS]") else: signature_list.append(name)