[CustomCom] Custom Command Parameters (#2051)

* [V3 CustomCom] Custom Command Parameters

Allows specifying more parameters for CC's via {0}, {1}, etc. that will be filled by the user invoking the CC. Python-style type hinting and attribute access is also allowed for Discord and builtin types.

> [p]cc add simple greet Hi, {0.mention:Member}!
> ...
> [p]greet zephyrkul
> Hi, @zephyrkul!

The bot will reply with the standard help messages if the cc is incorrectly executed.

> [p]greet me
> Member "me" not found

* black formatting

* check command failure

Only call the custom command if the faked command succeeded.

* misc fixes

1) don't str.strip all the time, it's not family-friendly and doesn't match transform_parameter
2) transform_arg now actually returns strings in every case
3) improve prepare_args parsing security
4) help parameters will show what type they expect
5) make linter less angery

* customcom documentation

I hate rst

* don't require repeated type hinting

If a parameter was type hinted previously, don't require it again.
Ex: `{0.display_name:Member}#{0.discriminator}` is now possible.

* add cog_customcom.rts to index

I despise rst

* don't enforce order

Allow type hinting and attribute access to be in either order.
Ex. `{0:Member.mention}` is now valid.

* clean up on_message

We're building context anyway, may as well use it.

* [doc] correct cog name

Cog class is named CustomCommands, not CustomCom

* minor on_message optimization

only build context if it's needed

* update cc_add docstring

Old one wasn't user-friendly. Replaced with a link to the new docs.
Link will not function until PR is merged and docs refreshed.

* [doc] change repeat to say

repeat is an audio command, use say in the example instead

* compare ctx.prefix to None

allows for null prefixes, which is a bad idea but who am I to judge

* address review

* raise error on conflicting colon notation

bugfix I was working on but failed to actually commit
This commit is contained in:
zephyrkul 2018-09-06 08:14:02 -06:00 committed by Toby Harradine
parent 7eed033c9e
commit 04e97f3516
3 changed files with 256 additions and 51 deletions

101
docs/cog_customcom.rst Normal file
View File

@ -0,0 +1,101 @@
.. CustomCommands Cog Reference
============================
CustomCommands Cog Reference
============================
------------
How it works
------------
CustomCommands allows you to create simple commands for your bot without requiring you to code your own cog for Red.
If the command you attempt to create shares a name with an already loaded command, you cannot overwrite it with this cog.
------------------
Context Parameters
------------------
You can enhance your custom command's response by leaving spaces for the bot to substitute.
+-----------+----------------------------------------+
| Argument | Substitute |
+===========+========================================+
| {message} | The message the bot is responding to. |
+-----------+----------------------------------------+
| {author} | The user who called the command. |
+-----------+----------------------------------------+
| {channel} | The channel the command was called in. |
+-----------+----------------------------------------+
| {server} | The server the command was called in. |
+-----------+----------------------------------------+
| {guild} | Same as with {server}. |
+-----------+----------------------------------------+
You can further refine the response with dot notation. For example, {author.mention} will mention the user who called the command.
------------------
Command Parameters
------------------
You can further enhance your custom command's response by leaving spaces for the user to substitute.
To do this, simply put {#} in the response, replacing # with any number starting with 0. Each number will be replaced with what the user gave the command, in order.
You can refine the response with colon notation. For example, {0:Member} will accept members of the server, and {0:int} will accept a number. If no colon notation is provided, the argument will be returned unchanged.
+-----------------+--------------------------------+
| Argument | Substitute |
+=================+================================+
| {#:Member} | A member of your server. |
+-----------------+--------------------------------+
| {#:TextChannel} | A text channel in your server. |
+-----------------+--------------------------------+
| {#:Role} | A role in your server. |
+-----------------+--------------------------------+
| {#:int} | A whole number. |
+-----------------+--------------------------------+
| {#:float} | A decimal number. |
+-----------------+--------------------------------+
| {#:bool} | True or False. |
+-----------------+--------------------------------+
You can specify more than the above with colon notation, but those are the most common.
As with context parameters, you can use dot notation to further refine the response. For example, {0.mention:Member} will mention the Member specified.
----------------
Example commands
----------------
Showing your own avatar
.. code-block:: none
[p]customcom add simple avatar {author.avatar_url}
[p]avatar
https://cdn.discordapp.com/avatars/133801473317404673/be4c4a4fe47cb3e74c31a0504e7a295e.webp?size=1024
Repeating the user
.. code-block:: none
[p]customcom add simple say {0}
[p]say Pete and Repeat
Pete and Repeat
Greeting the specified member
.. code-block:: none
[p]customcom add simple greet Hello, {0.mention:Member}!
[p]greet Twentysix
Hello, @Twentysix!
Comparing two text channel's categories
.. code-block:: none
[p]customcom add simple comparecategory {0.category:TextChannel} | {1.category:TextChannel}
[p]comparecategory #support #general
Red | Community

View File

@ -20,6 +20,7 @@ Welcome to Red - Discord Bot's documentation!
:maxdepth: 2 :maxdepth: 2
:caption: Cog Reference: :caption: Cog Reference:
cog_customcom
cog_downloader cog_downloader
cog_permissions cog_permissions

View File

@ -2,6 +2,9 @@ import os
import re import re
import random import random
from datetime import datetime from datetime import datetime
from inspect import Parameter
from collections import OrderedDict
from typing import Mapping
import discord import discord
@ -24,6 +27,10 @@ class AlreadyExists(CCError):
pass pass
class ArgParseError(CCError):
pass
class CommandObj: class CommandObj:
def __init__(self, **kwargs): def __init__(self, **kwargs):
config = kwargs.get("config") config = kwargs.get("config")
@ -51,6 +58,7 @@ class CommandObj:
return m.channel == ctx.channel and m.author == ctx.message.author return m.channel == ctx.channel and m.author == ctx.message.author
responses = [] responses = []
args = None
while True: while True:
await ctx.send(_("Add a random response:")) await ctx.send(_("Add a random response:"))
msg = await self.bot.wait_for("message", check=check) msg = await self.bot.wait_for("message", check=check)
@ -58,6 +66,15 @@ class CommandObj:
if msg.content.lower() == "exit()": if msg.content.lower() == "exit()":
break break
else: else:
try:
this_args = ctx.cog.prepare_args(msg.content)
except ArgParseError as e:
await ctx.send(e.args[0])
continue
if args and args != this_args:
await ctx.send(_("Random responses must take the same arguments!"))
continue
args = args or this_args
responses.append(msg.content) responses.append(msg.content)
return responses return responses
@ -69,7 +86,7 @@ class CommandObj:
async def get(self, message: discord.Message, command: str) -> str: async def get(self, message: discord.Message, command: str) -> str:
ccinfo = await self.db(message.guild).commands.get_raw(command, default=None) ccinfo = await self.db(message.guild).commands.get_raw(command, default=None)
if not ccinfo: if not ccinfo:
raise NotFound raise NotFound()
else: else:
return ccinfo["response"] return ccinfo["response"]
@ -78,6 +95,8 @@ class CommandObj:
# Check if this command is already registered as a customcommand # Check if this command is already registered as a customcommand
if await self.db(ctx.guild).commands.get_raw(command, default=None): if await self.db(ctx.guild).commands.get_raw(command, default=None):
raise AlreadyExists() raise AlreadyExists()
# test to raise
ctx.cog.prepare_args(response if isinstance(response, str) else response[0])
author = ctx.message.author author = ctx.message.author
ccinfo = { ccinfo = {
"author": {"id": author.id, "name": author.name}, "author": {"id": author.id, "name": author.name},
@ -110,6 +129,9 @@ class CommandObj:
await ctx.send(_("What response do you want?")) await ctx.send(_("What response do you want?"))
response = (await self.bot.wait_for("message", check=check)).content response = (await self.bot.wait_for("message", check=check)).content
# test to raise
ctx.cog.prepare_args(response if isinstance(response, str) else response[0])
ccinfo["response"] = response ccinfo["response"] = response
ccinfo["edited_at"] = self.get_now() ccinfo["edited_at"] = self.get_now()
@ -151,19 +173,10 @@ class CustomCommands:
@checks.mod_or_permissions(administrator=True) @checks.mod_or_permissions(administrator=True)
async def cc_add(self, ctx: commands.Context): async def cc_add(self, ctx: commands.Context):
""" """
Adds a new custom command
CCs can be enhanced with arguments: CCs can be enhanced with arguments:
https://red-discordbot.readthedocs.io/en/v3-develop/cog_customcom.html
Argument What it will be substituted with
{message} message
{author} message.author
{channel} message.channel
{guild} message.guild
{server} message.guild
""" """
pass pass
@ -175,7 +188,6 @@ class CustomCommands:
Note: This is interactive Note: This is interactive
""" """
channel = ctx.channel
responses = [] responses = []
responses = await self.commandobj.get_responses(ctx=ctx) responses = await self.commandobj.get_responses(ctx=ctx)
@ -199,7 +211,6 @@ class CustomCommands:
Example: Example:
[p]customcom add simple yourcommand Text you want [p]customcom add simple yourcommand Text you want
""" """
guild = ctx.guild
command = command.lower() command = command.lower()
if command in self.bot.all_commands: if command in self.bot.all_commands:
await ctx.send(_("That command is already a standard command.")) await ctx.send(_("That command is already a standard command."))
@ -213,6 +224,8 @@ class CustomCommands:
"{}customcom edit".format(ctx.prefix) "{}customcom edit".format(ctx.prefix)
) )
) )
except ArgParseError as e:
await ctx.send(e.args[0])
@customcom.command(name="edit") @customcom.command(name="edit")
@checks.mod_or_permissions(administrator=True) @checks.mod_or_permissions(administrator=True)
@ -222,7 +235,6 @@ class CustomCommands:
Example: Example:
[p]customcom edit yourcommand Text you want [p]customcom edit yourcommand Text you want
""" """
guild = ctx.message.guild
command = command.lower() command = command.lower()
try: try:
@ -234,6 +246,8 @@ class CustomCommands:
"{}customcom add".format(ctx.prefix) "{}customcom add".format(ctx.prefix)
) )
) )
except ArgParseError as e:
await ctx.send(e.args[0])
@customcom.command(name="delete") @customcom.command(name="delete")
@checks.mod_or_permissions(administrator=True) @checks.mod_or_permissions(administrator=True)
@ -241,7 +255,6 @@ class CustomCommands:
"""Deletes a custom command """Deletes a custom command
Example: Example:
[p]customcom delete yourcommand""" [p]customcom delete yourcommand"""
guild = ctx.message.guild
command = command.lower() command = command.lower()
try: try:
await self.commandobj.delete(ctx=ctx, command=command) await self.commandobj.delete(ctx=ctx, command=command)
@ -286,49 +299,139 @@ class CustomCommands:
async def on_message(self, message): async def on_message(self, message):
is_private = isinstance(message.channel, discord.abc.PrivateChannel) is_private = isinstance(message.channel, discord.abc.PrivateChannel)
if len(message.content) < 2 or is_private:
return
guild = message.guild
prefixes = await self.bot.db.guild(guild).get_raw("prefix", default=[])
if len(prefixes) < 1:
def_prefixes = await self.bot.get_prefix(message)
for prefix in def_prefixes:
prefixes.append(prefix)
# user_allowed check, will be replaced with self.bot.user_allowed or # user_allowed check, will be replaced with self.bot.user_allowed or
# something similar once it's added # something similar once it's added
user_allowed = True user_allowed = True
if len(message.content) < 2 or is_private or not user_allowed or message.author.bot:
for prefix in prefixes: return
if message.content.startswith(prefix):
break ctx = await self.bot.get_context(message)
else:
if ctx.prefix is None or ctx.valid:
return return
if user_allowed:
cmd = message.content[len(prefix) :]
try: try:
c = await self.commandobj.get(message=message, command=cmd) raw_response = await self.commandobj.get(message=message, command=ctx.invoked_with)
if isinstance(c, list): if isinstance(raw_response, list):
command = random.choice(c) raw_response = random.choice(raw_response)
elif isinstance(c, str): elif isinstance(raw_response, str):
command = c pass
else: else:
raise NotFound() raise NotFound()
except NotFound: except NotFound:
return return
response = self.format_cc(command, message) await self.call_cc_command(ctx, raw_response, message)
await message.channel.send(response)
def format_cc(self, command, message) -> str: async def call_cc_command(self, ctx, raw_response, message) -> None:
results = re.findall("\{([^}]+)\}", command) # wrap the command here so it won't register with the bot
fake_cc = commands.Command(ctx.invoked_with, self.cc_callback)
fake_cc.params = self.prepare_args(raw_response)
ctx.command = fake_cc
await self.bot.invoke(ctx)
if not ctx.command_failed:
await self.cc_command(*ctx.args, **ctx.kwargs, raw_response=raw_response)
async def cc_callback(self, *args, **kwargs) -> None:
"""
Custom command.
Created via the CustomCom cog. See `[p]customcom` for more details.
"""
# fake command to take advantage of discord.py's parsing and events
pass
async def cc_command(self, ctx, *cc_args, raw_response, **cc_kwargs) -> None:
cc_args = (*cc_args, *cc_kwargs.values())
results = re.findall(r"\{([^}]+)\}", raw_response)
for result in results: for result in results:
param = self.transform_parameter(result, message) param = self.transform_parameter(result, ctx.message)
command = command.replace("{" + result + "}", param) raw_response = raw_response.replace("{" + result + "}", param)
return command results = re.findall(r"\{((\d+)[^\.}]*(\.[^:}]+)?[^}]*)\}", raw_response)
low = min(int(result[1]) for result in results)
for result in results:
index = int(result[1]) - low
arg = self.transform_arg(result[0], result[2], cc_args[index])
raw_response = raw_response.replace("{" + result[0] + "}", arg)
await ctx.send(raw_response)
def prepare_args(self, raw_response) -> Mapping[str, Parameter]:
args = re.findall(r"\{(\d+)[^:}]*(:[^\.}]*)?[^}]*\}", raw_response)
default = [["ctx", Parameter("ctx", Parameter.POSITIONAL_OR_KEYWORD)]]
if not args:
return OrderedDict(default)
allowed_builtins = {
"bool": bool,
"complex": complex,
"float": float,
"frozenset": frozenset,
"int": int,
"list": list,
"set": set,
"str": str,
"tuple": tuple,
}
indices = [int(a[0]) for a in args]
low = min(indices)
indices = [a - low for a in indices]
high = max(indices)
if high > 9:
raise ArgParseError(_("Too many arguments!"))
gaps = set(indices).symmetric_difference(range(high + 1))
if gaps:
raise ArgParseError(
_("Arguments must be sequential. Missing arguments: {}.").format(
", ".join(str(i + low) for i in gaps)
)
)
fin = [Parameter("_" + str(i), Parameter.POSITIONAL_OR_KEYWORD) for i in range(high + 1)]
for arg in args:
index = int(arg[0]) - low
anno = arg[1][1:] # strip initial colon
if anno.lower().endswith("converter"):
anno = anno[:-9]
if not anno or anno.startswith("_"): # public types only
name = "{}_{}".format("text", index if index < high else "final")
fin[index] = fin[index].replace(name=name)
continue
# allow type hinting only for discord.py and builtin types
try:
anno = getattr(discord, anno)
# force an AttributeError if there's no discord.py converter
getattr(commands.converter, anno.__name__ + "Converter")
except AttributeError:
anno = allowed_builtins.get(anno.lower(), Parameter.empty)
if (
anno is not Parameter.empty
and fin[index].annotation is not Parameter.empty
and anno != fin[index].annotation
):
raise ArgParseError(
_('Conflicting colon notation for argument {}: "{}" and "{}".').format(
index + low, fin[index].annotation.__name__, anno.__name__
)
)
name = "{}_{}".format(
"text" if anno is Parameter.empty else anno.__name__.lower(),
index if index < high else "final",
)
fin[index] = fin[index].replace(name=name, annotation=anno)
# consume rest
fin[-1] = fin[-1].replace(kind=Parameter.KEYWORD_ONLY)
# insert ctx parameter for discord.py parsing
fin = default + [(p.name, p) for p in fin]
return OrderedDict(fin)
def transform_arg(self, result, attr, obj) -> str:
attr = attr[1:] # strip initial dot
if not attr:
return str(obj)
raw_result = "{" + result + "}"
# forbid private members and nested attr lookups
if attr.startswith("_") or "." in attr:
return raw_result
return str(getattr(obj, attr, raw_result))
def transform_parameter(self, result, message) -> str: def transform_parameter(self, result, message) -> str:
""" """