diff --git a/didier/cogs/currency.py b/didier/cogs/currency.py index f7204a8..709a461 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 @@ -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, @@ -33,18 +33,18 @@ 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) 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""" + """Show your Didier Bank information.""" async with self.client.postgres_session as session: bank = await crud.get_bank(session, ctx.author.id) @@ -57,9 +57,9 @@ 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""" + """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) @@ -77,9 +77,9 @@ 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""" + """Upgrade the capacity level of your bank.""" async with self.client.postgres_session as session: try: await crud.upgrade_capacity(session, ctx.author.id) @@ -88,9 +88,9 @@ 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""" + """Upgrade the interest level of your bank.""" async with self.client.postgres_session as session: try: await crud.upgrade_interest(session, ctx.author.id) @@ -99,9 +99,9 @@ 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""" + """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,15 +112,27 @@ 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) 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""" + """Invest `amount` Didier Dinks into your bank. + + 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: + ``` + 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/discord.py b/didier/cogs/discord.py index d90752a..c13c782 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 @@ -43,43 +43,57 @@ 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""" + """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) - name = "Your" if user is None else 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} 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"]) + async def birthday_set(self, ctx: commands.Context, day: str): + """Set your birthday to `day`. - @birthday.command(name="Set", aliases=["Config"]) - async def birthday_set(self, ctx: commands.Context, date_str: str): - """Command to set your birthday""" + 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: 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) 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""" + """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 + ``` + + When no value 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) @@ -92,9 +106,16 @@ 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""" + """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`-argument can be left out. + + `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: message = await self.client.resolve_message(ctx.message.reference) @@ -116,9 +137,12 @@ 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""" + """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("#") @@ -138,9 +162,18 @@ 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""" + """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. + + 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) @@ -160,15 +193,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`-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: message = await self.client.resolve_message(ctx.message.reference) diff --git a/didier/cogs/fun.py b/didier/cogs/fun.py index d604b36..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 @@ -31,57 +31,62 @@ 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.", + aliases=["dad", "dj"], ) 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""" + @commands.group(name="memegen", aliases=["meme", "memes"], invoke_without_command=True, case_insensitive=True) + 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""" + @memegen_msg.command(name="preview", aliases=["p"]) + 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) diff --git a/didier/cogs/help.py b/didier/cogs/help.py index 73c66e5..c6d88d6 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -1,44 +1,267 @@ -from typing import List, Mapping, Optional +import inspect +import re +from typing import Mapping, Optional, Type import discord from discord.ext import commands 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 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 + 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 + 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}]" + + # 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) + + 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") + 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 _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. + 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) + + # 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): + """Add command-related information to an embed + + This allows re-using this logic for Group commands that can be invoked by themselves. + """ + embed.description = self._clean_command_doc(command) + + 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) + + 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)) - # Remove owner-only cogs - 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)) - @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) + 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""" - client.help_command = CustomHelpCommand() + 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..7b7a732 100644 --- a/didier/cogs/meta.py +++ b/didier/cogs/meta.py @@ -8,21 +8,31 @@ 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""" + """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. + + Example usage: + ``` + didier source + didier source dinks + didier source source + ``` + """ repo_home = "https://github.com/stijndcl/didier" if command_name is None: @@ -38,12 +48,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("\\", "/") 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 6b2d835..877cc3f 100644 --- a/didier/cogs/school.py +++ b/didier/cogs/school.py @@ -27,21 +27,26 @@ 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. + + 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() @@ -62,14 +67,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. + """Show the menu in the Ghent University restaurants on `date`. - Menus are Dutch, as a lot of dishes have very weird translations + 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: day_dt = date.today() @@ -83,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/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__))) diff --git a/didier/utils/types/string.py b/didier/utils/types/string.py index 4e02573..5cc5f93 100644 --- a/didier/utils/types/string.py +++ b/didier/utils/types/string.py @@ -1,7 +1,8 @@ import math -from typing import Optional +import re +from typing import Optional, Union -__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: Union[int, 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"]