Skip to content
This repository has been archived by the owner on May 1, 2022. It is now read-only.

feat: basic help message #5

Closed
wants to merge 16 commits into from
Closed
197 changes: 197 additions & 0 deletions molter/help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import functools
import logging

import dis_snek
LordOfPolls marked this conversation as resolved.
Show resolved Hide resolved
from dis_snek import Embed
from dis_snek.ext.paginators import Paginator

import molter

__all__ = ("HelpCommand",)

log = logging.getLogger(dis_snek.const.logger_name)


class HelpCommand:
LordOfPolls marked this conversation as resolved.
Show resolved Hide resolved
show_hidden: bool
"""Should hidden commands be shown?"""
show_disabled: bool
"""Should disabled commands be shown?"""
run_checks: bool
"""Should commands be checked if they can be run by the help command user?"""
show_self: bool
"""Should this command be shown in the help message?"""
show_params: bool
LordOfPolls marked this conversation as resolved.
Show resolved Hide resolved
"""Should parameters for commands be shown?"""
show_aliases: bool
"""Should aliases for commands be shown?"""
show_prefix: bool
"""Should the prefix be shown?"""

embed_title: str
"""The title to use in the embed. {username} will be replaced by the client's username."""
not_found_message: str
"""The message to send when a command is not found. {cmd_name} will be replaced by the requested command."""

_client: dis_snek.Snake

def __init__(
self,
client: dis_snek.Snake,
*,
show_hidden: bool = False,
run_checks: bool = False,
show_self: bool = False,
show_params: bool = False,
show_aliases: bool = False,
show_prefix: bool = False,
embed_title: str | None = None,
not_found_message: str | None = None,
) -> None:
self._client = client
self.show_hidden = show_hidden
self.run_checks = run_checks
self.show_self = show_self
self.show_params = show_params
self.show_aliases = show_aliases
self.show_prefix = show_prefix
self.embed_title = embed_title or "{username} Help Command"
self.not_found_message = not_found_message or "Sorry! No command called `{cmd_name}` was found."
Copy link
Contributor

@AstreaTSS AstreaTSS Apr 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nitpick: the phrasing for the not found default string is slightly unprofessional with the exclamation mark, but... yeah, that's a nitpick.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, 2 objections to that.
One, exclamation marks are not unprofessional. Theyre frequently used in throughout professional settings in order to reduce end-user stress when something doesnt work. I believe you mean its not emotively neutral, which also leads into the second point.

Two, Snek as a whole is not professional, so the point is quite void.

self.cmd = self._callback

def register(self) -> None:
"""Register the help command in dis-snek."""
if not isinstance(self.cmd.callback, functools.partial):
# prevent wrap-nesting
self.cmd.callback = functools.partial(self.cmd.callback, self)

# replace existing help command if found
if "help" in self._client.commands:
log.warning("Replacing existing help command.")
del self._client.commands["help"]

self._client.add_message_command(self.cmd) # type: ignore

async def send_help(self, ctx: dis_snek.MessageContext, cmd_name: str | None) -> None:
"""
Send a help message to the given context.

args:
ctx: The context to use
cmd_name: An optional command name to send help for.
"""
await self._callback.callback(ctx, cmd_name) # type: ignore

@molter.msg_command(name="help")
async def _callback(self, ctx: dis_snek.MessageContext, cmd_name: str = None) -> None:
LordOfPolls marked this conversation as resolved.
Show resolved Hide resolved
if cmd_name:
return await self._help_specific(ctx, cmd_name)
await self._help_list(ctx)

async def _help_list(self, ctx: dis_snek.MessageContext) -> None:
cmds = await self._gather(ctx)

output = []
for cmd in cmds.values():
_temp = self._generate_command_string(cmd, ctx)
_temp += f"\n{cmd.brief}"

output.append(self._sanitise_mentions(_temp))
if len("\n".join(output)) > 500:
paginator = Paginator.create_from_list(self._client, output, page_size=500)
paginator.default_title = self.embed_title.format(username=self._client.user.username)
await paginator.send(ctx)
else:
embed = Embed(
title=self.embed_title.format(username=self._client.user.username),
description="\n".join(output),
color=dis_snek.BrandColors.BLURPLE,
LordOfPolls marked this conversation as resolved.
Show resolved Hide resolved
)
await ctx.reply(embeds=embed)

async def _help_specific(self, ctx: dis_snek.MessageContext, cmd_name: str) -> None:
cmds = await self._gather(ctx)

if cmd := cmds.get(cmd_name.lower()):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a naive approach and I think it won't work well with subcommands.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll be honest, i completely forgot message subcommands were a thing

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image
image

seems to accidentally work

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most likely works as help._gather generates a dict of {cmd_resolved_name, cmd} and the help_specific method is looking up by a resolved name (which the user would know commands by), so technically this should be the ideal route

_temp = self._generate_command_string(cmd, ctx)
_temp += f"\n{cmd.help}"
await ctx.reply(self._sanitise_mentions(_temp))
else:
await ctx.reply(self.not_found_message.format(cmd_name=cmd_name))

async def _gather(self, ctx: dis_snek.MessageContext | None = None) -> dict[str, molter.MolterCommand]:
"""
Gather commands based on the rules set out in the class attributes.

args:
ctx: The context to use to establish usability.

returns:
dict[str, MolterCommand]: A list of commands fit the class attribute configuration.
"""
out: dict[str, molter.MolterCommand] = {}

for cmd in self._client.commands.values():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shouldn't be assumed that every command here is a Molter command... or at least it may be a good idea just to add a filter for normal message commands just in case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding was MolterCommands were replacing snek's message commands, is this not the case?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a filter

cmd: molter.MolterCommand

if not cmd.enabled and not self.show_disabled:
continue

if cmd == self.cmd and not self.show_self:
continue

elif cmd.hidden and not self.show_hidden:
LordOfPolls marked this conversation as resolved.
Show resolved Hide resolved
continue

if ctx and cmd.checks and not self.run_checks:
# cmd._can_run would check the cooldowns, we don't want that so manual calling is required
for _c in cmd.checks:
if not await _c(ctx):
continue

if cmd.scale and cmd.scale.scale_checks:
for _c in cmd.scale.scale_checks:
if not await _c(ctx):
continue

out[cmd.qualified_name] = cmd

return out

def _sanitise_mentions(self, text: str) -> str:
"""
Replace mentions with a format that won't ping or look weird in code blocks.

args:
The text to sanitise.
"""
mappings = {
"@everyone": "@\u200beveryone",
"@here": "@\u200bhere",
f"<@{self._client.user.id}>": f"@{self._client.user.username}",
f"<@!{self._client.user.id}>": f"@{self._client.user.username}",
}
for source, target in mappings.items():
text = text.replace(source, target)

return text

def _generate_command_string(self, cmd: molter.MolterCommand, ctx: dis_snek.MessageContext) -> str:
"""
Generate a string based on a command, class attributes, and the context.

args:
cmd: The command in question.
ctx: The context for this command.
"""
_temp = f"`{ctx.prefix if self.show_prefix else ''}{cmd.qualified_name}`"

if cmd.aliases and self.show_aliases:
_temp += "|" + "|".join([f"`{a}`" for a in cmd.aliases])

if cmd.params and self.show_params:
for param in cmd.params:
LordOfPolls marked this conversation as resolved.
Show resolved Hide resolved
wrapper = ("[", "]") if param.optional else ("<", ">")
_temp += f" `{wrapper[0]}{param.name}{wrapper[1]}`"

return _temp