Merge pull request #132 from stijndcl/help-v3

Rewrite help
pull/133/head
Stijn De Clercq 2022-09-19 19:00:04 +02:00 committed by GitHub
commit a23ee3671a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 462 additions and 118 deletions

View File

@ -17,7 +17,7 @@ from didier.utils.types.string import pluralize
class Currency(commands.Cog): class Currency(commands.Cog):
"""Everything Dinks-related""" """Everything Dinks-related."""
client: Didier client: Didier
@ -25,7 +25,7 @@ class Currency(commands.Cog):
super().__init__() super().__init__()
self.client = client self.client = client
@commands.command(name="Award") @commands.command(name="award")
@commands.check(is_owner) @commands.check(is_owner)
async def award( async def award(
self, self,
@ -33,18 +33,18 @@ class Currency(commands.Cog):
user: discord.User, user: discord.User,
amount: typing.Annotated[int, abbreviated_number], 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: async with self.client.postgres_session as session:
await crud.add_dinks(session, user.id, amount) await crud.add_dinks(session, user.id, amount)
plural = pluralize("Didier Dink", amount) plural = pluralize("Didier Dink", amount)
await ctx.reply( 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, 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): 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: async with self.client.postgres_session as session:
bank = await crud.get_bank(session, ctx.author.id) 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) 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): 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: async with self.client.postgres_session as session:
bank = await crud.get_bank(session, ctx.author.id) 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) 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): 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: async with self.client.postgres_session as session:
try: try:
await crud.upgrade_capacity(session, ctx.author.id) 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 ctx.reply("You don't have enough Didier Dinks to do this.", mention_author=False)
await self.client.reject_message(ctx.message) 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): 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: async with self.client.postgres_session as session:
try: try:
await crud.upgrade_interest(session, ctx.author.id) 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 ctx.reply("You don't have enough Didier Dinks to do this.", mention_author=False)
await self.client.reject_message(ctx.message) 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): 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: async with self.client.postgres_session as session:
try: try:
await crud.upgrade_rob(session, ctx.author.id) await crud.upgrade_rob(session, ctx.author.id)
@ -112,15 +112,27 @@ class Currency(commands.Cog):
@commands.hybrid_command(name="dinks") @commands.hybrid_command(name="dinks")
async def dinks(self, ctx: commands.Context): async def dinks(self, ctx: commands.Context):
"""Check your Didier Dinks""" """Check your Didier Dinks."""
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
bank = await crud.get_bank(session, ctx.author.id) bank = await crud.get_bank(session, ctx.author.id)
plural = pluralize("Didier Dink", bank.dinks) plural = pluralize("Didier Dink", bank.dinks)
await ctx.reply(f"**{ctx.author.display_name}** has **{bank.dinks}** {plural}.", mention_author=False) 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]): 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: async with self.client.postgres_session as session:
invested = await crud.invest(session, ctx.author.id, amount) invested = await crud.invest(session, ctx.author.id, amount)
plural = pluralize("Didier Dink", invested) plural = pluralize("Didier Dink", invested)
@ -134,7 +146,7 @@ class Currency(commands.Cog):
@commands.hybrid_command(name="nightly") @commands.hybrid_command(name="nightly")
async def nightly(self, ctx: commands.Context): async def nightly(self, ctx: commands.Context):
"""Claim nightly Didier Dinks""" """Claim nightly Didier Dinks."""
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
try: try:
await crud.claim_nightly(session, ctx.author.id) await crud.claim_nightly(session, ctx.author.id)

View File

@ -22,7 +22,7 @@ from didier.views.modals import CreateBookmark
class Discord(commands.Cog): 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 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._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) 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): 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 user_id = (user and user.id) or ctx.author.id
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
birthday = await birthdays.get_birthday_for_user(session, user_id) 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: 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)) 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"]) Parsing of the `day`-argument happens in the following order: `DD/MM/YYYY`, `DD/MM/YY`, `DD/MM`.
async def birthday_set(self, ctx: commands.Context, date_str: str): Other formats will not be accepted.
"""Command to set your birthday""" """
try: try:
default_year = 2001 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 no year was passed, make it 2001 by default
if date_str.count("/") == 1: if day.count("/") == 1:
date.replace(year=default_year) date.replace(year=default_year)
except ValueError: 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: async with self.client.postgres_session as session:
await birthdays.add_birthday(session, ctx.author.id, date) await birthdays.add_birthday(session, ctx.author.id, date)
await self.client.confirm_message(ctx.message) 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): 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 # No label: shortcut to display bookmarks
if label is None: if label is None:
return await self.bookmark_search(ctx, query=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) 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]): 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 no message was passed, allow replying to the message that should be bookmarked
if message is None and ctx.message.reference is not None: if message is None and ctx.message.reference is not None:
message = await self.client.resolve_message(ctx.message.reference) message = await self.client.resolve_message(ctx.message.reference)
@ -116,9 +137,12 @@ class Discord(commands.Cog):
# Label isn't allowed # Label isn't allowed
return await ctx.reply(f"Bookmarks cannot be named `{label}`.", mention_author=False) 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): 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 # 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 # so strip it out in case people want to try and use this
bookmark_id = bookmark_id.removeprefix("#") 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) 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): 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: async with self.client.postgres_session as session:
results = await bookmarks.get_bookmarks(session, ctx.author.id, query=query) 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) modal = CreateBookmark(self.client, message.jump_url)
await interaction.response.send_modal(modal) 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): 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: if thread.me is not None:
return await ctx.reply() 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): 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 no message was passed, allow replying to the message that should be pinned
if message is None and ctx.message.reference is not None: if message is None and ctx.message.reference is not None:
message = await self.client.resolve_message(ctx.message.reference) message = await self.client.resolve_message(ctx.message.reference)

View File

@ -18,7 +18,7 @@ class Fun(commands.Cog):
client: Didier client: Didier
# Slash groups # 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): def __init__(self, client: Didier):
self.client = client self.client = client
@ -31,57 +31,62 @@ class Fun(commands.Cog):
@commands.hybrid_command( @commands.hybrid_command(
name="dadjoke", 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): 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: async with self.client.postgres_session as session:
joke = await get_random_dad_joke(session) joke = await get_random_dad_joke(session)
return await ctx.reply(joke.joke, mention_author=False) 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): async def memegen_msg(self, ctx: commands.Context, template: str, *, fields: str):
"""Command group for meme-related commands""" """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(): 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) 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): async def memegen_preview_msg(self, ctx: commands.Context, template: str):
"""Generate a preview for a meme, to see how the fields are structured""" """Generate a preview for the meme template `template`, to see how the fields are structured."""
async with ctx.typing(): async with ctx.typing():
fields = [f"Field #{i + 1}" for i in range(20)] 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) return await ctx.reply(meme, mention_author=False)
@memes_slash.command(name="generate", description="Generate a meme") @memes_slash.command(name="generate")
async def memegen_slash(self, interaction: discord.Interaction, meme: str): async def memegen_slash(self, interaction: discord.Interaction, template: str):
"""Slash command to generate a meme""" """Generate a meme with template `template`."""
async with self.client.postgres_session as session: 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) modal = GenerateMeme(self.client, result)
await interaction.response.send_modal(modal) await interaction.response.send_modal(modal)
@memes_slash.command( @memes_slash.command(name="preview")
name="preview", description="Generate a preview for a meme, to see how the fields are structured" @app_commands.describe(template="The meme template to use in the preview.")
) async def memegen_preview_slash(self, interaction: discord.Interaction, template: str):
async def memegen_preview_slash(self, interaction: discord.Interaction, meme: str): """Generate a preview for a meme, to see how the fields are structured."""
"""Slash command to generate a meme preview"""
await interaction.response.defer() await interaction.response.defer()
fields = [f"Field #{i + 1}" for i in range(20)] 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) await interaction.followup.send(meme_url, ephemeral=True)
@memegen_slash.autocomplete("meme") @memegen_slash.autocomplete("template")
@memegen_preview_slash.autocomplete("meme") @memegen_preview_slash.autocomplete("template")
async def _memegen_slash_autocomplete_meme( async def _memegen_slash_autocomplete_template(
self, _: discord.Interaction, current: str self, _: discord.Interaction, current: str
) -> list[app_commands.Choice[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) return self.client.database_caches.memes.get_autocomplete_suggestions(current)

View File

@ -1,44 +1,267 @@
from typing import List, Mapping, Optional import inspect
import re
from typing import Mapping, Optional, Type
import discord import discord
from discord.ext import commands from discord.ext import commands
from overrides import overrides from overrides import overrides
from didier import Didier 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): 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: def _help_embed_base(self, title: str) -> discord.Embed:
"""Create the base structure for the embeds that get sent with the Help commands""" """Create the base structure for the embeds that get sent with the Help commands"""
embed = discord.Embed(title=title, colour=discord.Colour.blue()) embed = discord.Embed(title=title.title(), colour=discord.Colour.blue())
embed.set_footer(text="Syntax: Didier Help [Categorie] of Didier Help [Commando]")
return embed 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""" """Filter the list of cogs down to all those that the user can see"""
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 # Remove cogs that we never want to see in the help page because they
# don't contain commands # don't contain commands, or shouldn't be visible at all
filtered_cogs = list(filter(lambda cog: cog is not None and cog.qualified_name.lower() not in ("tasks",), cogs)) if not cog.get_commands():
return False
# Remove owner-only cogs 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): 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 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)) return list(sorted(filtered_cogs, key=lambda cog: cog.qualified_name))
@overrides def _get_flags_class(self, command: commands.Command) -> Optional[Type[PosixFlags]]:
async def send_bot_help(self, mapping: Mapping[Optional[commands.Cog], List[commands.Command]], /): """Check if a command has flags"""
embed = self._help_embed_base("Categorieën") flag_param = command.params.get("flags", None)
filtered_cogs = await self._filter_cogs(list(mapping.keys())) if flag_param is None:
embed.description = "\n".join(list(map(lambda cog: cog.qualified_name, filtered_cogs))) return None
await self.get_destination().send(embed=embed)
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): async def setup(client: Didier):
"""Load the cog""" """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)

View File

@ -8,21 +8,31 @@ from didier import Didier
class Meta(commands.Cog): class Meta(commands.Cog):
"""Cog for Didier-related commands""" """Commands related to Didier himself."""
client: Didier client: Didier
def __init__(self, client: Didier): def __init__(self, client: Didier):
self.client = client self.client = client
@commands.command(name="Marco") @commands.command(name="marco")
async def marco(self, ctx: commands.Context): 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) 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): 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" repo_home = "https://github.com/stijndcl/didier"
if command_name is None: if command_name is None:
@ -38,12 +48,12 @@ class Meta(commands.Cog):
filename = src.co_filename filename = src.co_filename
if command is None: 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) lines, first_line = inspect.getsourcelines(src)
if filename is None: 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("\\", "/") file_location = os.path.relpath(filename).replace("\\", "/")

View File

@ -13,16 +13,18 @@ from didier.data.scrapers import google
class Other(commands.Cog): class Other(commands.Cog):
"""Cog for commands that don't really belong anywhere else""" """Commands that don't really belong anywhere else."""
client: Didier client: Didier
def __init__(self, client: Didier): def __init__(self, client: Didier):
self.client = client 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): 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(): async with ctx.typing():
status_code, definitions = await urban_dictionary.lookup(self.client.http_session, query) status_code, definitions = await urban_dictionary.lookup(self.client.http_session, query)
if not definitions: if not definitions:
@ -30,10 +32,17 @@ class Other(commands.Cog):
await ctx.reply(embed=definitions[0].to_embed(), mention_author=False) 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") @app_commands.describe(query="Search query")
async def google(self, ctx: commands.Context, *, query: str): 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(): async with ctx.typing():
results = await google.google_search(self.client.http_session, query) results = await google.google_search(self.client.http_session, query)
embed = GoogleSearch(results).to_embed() embed = GoogleSearch(results).to_embed()
@ -43,9 +52,9 @@ class Other(commands.Cog):
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
return await get_link_by_name(session, name.lower()) 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): 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) link = await self._get_link(name)
if link is None: if link is None:
return await ctx.reply(f"Found no links matching `{name}`.", mention_author=False) 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) target_message = await self.client.get_reply_target(ctx)
await target_message.reply(link.url, mention_author=False) await target_message.reply(link.url, mention_author=False)
@app_commands.command(name="link", description="Get the link to something") @app_commands.command(name="link")
@app_commands.describe(name="The name of the link") @app_commands.describe(name="The name of the resource")
async def link_slash(self, interaction: discord.Interaction, name: str): 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) link = await self._get_link(name)
if link is None: if link is None:
return await interaction.response.send_message(f"Found no links matching `{name}`.", ephemeral=True) return await interaction.response.send_message(f"Found no links matching `{name}`.", ephemeral=True)

View File

@ -27,21 +27,26 @@ class School(commands.Cog):
def __init__(self, client: Didier): def __init__(self, client: Didier):
self.client = client self.client = client
@commands.hybrid_command(name="deadlines", description="Show upcoming deadlines") @commands.hybrid_command(name="deadlines")
async def deadlines(self, ctx: commands.Context): async def deadlines(self, ctx: commands.Context):
"""Show upcoming deadlines""" """Show upcoming deadlines."""
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
deadlines = await get_deadlines(session) deadlines = await get_deadlines(session)
embed = Deadlines(deadlines).to_embed() embed = Deadlines(deadlines).to_embed()
await ctx.reply(embed=embed, mention_author=False, ephemeral=False) await ctx.reply(embed=embed, mention_author=False, ephemeral=False)
@commands.hybrid_command( @commands.hybrid_command(name="les", aliases=["sched", "schedule"])
name="les", description="Show your personalized schedule for a given day.", aliases=["Sched", "Schedule"]
)
@app_commands.rename(day_dt="date") @app_commands.rename(day_dt="date")
async def les(self, ctx: commands.Context, day_dt: Optional[app_commands.Transform[date, DateTransformer]] = None): 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: if day_dt is None:
day_dt = date.today() day_dt = date.today()
@ -62,14 +67,14 @@ class School(commands.Cog):
@commands.hybrid_command( @commands.hybrid_command(
name="menu", name="menu",
description="Show the menu in the Ghent University restaurants.", aliases=["eten", "food"],
aliases=["Eten", "Food"],
) )
@app_commands.rename(day_dt="date") @app_commands.rename(day_dt="date")
async def menu(self, ctx: commands.Context, day_dt: Optional[app_commands.Transform[date, DateTransformer]] = None): 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: if day_dt is None:
day_dt = date.today() day_dt = date.today()
@ -83,11 +88,22 @@ class School(commands.Cog):
await ctx.reply(embed=embed, mention_author=False) await ctx.reply(embed=embed, mention_author=False)
@commands.hybrid_command( @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)") @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): 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: async with self.client.postgres_session as session:
ufora_course = await ufora_courses.get_course_by_name(session, course) ufora_course = await ufora_courses.get_course_by_name(session, course)

View File

@ -190,8 +190,10 @@ class Tasks(commands.Cog):
await self.client.wait_until_ready() await self.client.wait_until_ready()
@check_birthdays.error @check_birthdays.error
@pull_schedules.error
@pull_ufora_announcements.error @pull_ufora_announcements.error
@remove_old_ufora_announcements.error @remove_old_ufora_announcements.error
@reset_wordle_word.error
async def _on_tasks_error(self, error: BaseException): async def _on_tasks_error(self, error: BaseException):
"""Error handler for all tasks""" """Error handler for all tasks"""
print("".join(traceback.format_exception(type(error), error, error.__traceback__))) print("".join(traceback.format_exception(type(error), error, error.__traceback__)))

View File

@ -1,7 +1,8 @@
import math 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: 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") 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 def get_edu_year_name(year: int) -> str: # pragma: no cover
"""Get the string representation of a university year""" """Get the string representation of a university year"""
years = ["1st Bachelor", "2nd Bachelor", "3rd Bachelor", "1st Master", "2nd Master"] years = ["1st Bachelor", "2nd Bachelor", "3rd Bachelor", "1st Master", "2nd Master"]