mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-06 03:08:55 -05:00
[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:
parent
7eed033c9e
commit
04e97f3516
101
docs/cog_customcom.rst
Normal file
101
docs/cog_customcom.rst
Normal 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
|
||||
@ -20,6 +20,7 @@ Welcome to Red - Discord Bot's documentation!
|
||||
:maxdepth: 2
|
||||
:caption: Cog Reference:
|
||||
|
||||
cog_customcom
|
||||
cog_downloader
|
||||
cog_permissions
|
||||
|
||||
|
||||
@ -2,6 +2,9 @@ import os
|
||||
import re
|
||||
import random
|
||||
from datetime import datetime
|
||||
from inspect import Parameter
|
||||
from collections import OrderedDict
|
||||
from typing import Mapping
|
||||
|
||||
import discord
|
||||
|
||||
@ -24,6 +27,10 @@ class AlreadyExists(CCError):
|
||||
pass
|
||||
|
||||
|
||||
class ArgParseError(CCError):
|
||||
pass
|
||||
|
||||
|
||||
class CommandObj:
|
||||
def __init__(self, **kwargs):
|
||||
config = kwargs.get("config")
|
||||
@ -51,6 +58,7 @@ class CommandObj:
|
||||
return m.channel == ctx.channel and m.author == ctx.message.author
|
||||
|
||||
responses = []
|
||||
args = None
|
||||
while True:
|
||||
await ctx.send(_("Add a random response:"))
|
||||
msg = await self.bot.wait_for("message", check=check)
|
||||
@ -58,6 +66,15 @@ class CommandObj:
|
||||
if msg.content.lower() == "exit()":
|
||||
break
|
||||
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)
|
||||
return responses
|
||||
|
||||
@ -69,7 +86,7 @@ class CommandObj:
|
||||
async def get(self, message: discord.Message, command: str) -> str:
|
||||
ccinfo = await self.db(message.guild).commands.get_raw(command, default=None)
|
||||
if not ccinfo:
|
||||
raise NotFound
|
||||
raise NotFound()
|
||||
else:
|
||||
return ccinfo["response"]
|
||||
|
||||
@ -78,6 +95,8 @@ class CommandObj:
|
||||
# Check if this command is already registered as a customcommand
|
||||
if await self.db(ctx.guild).commands.get_raw(command, default=None):
|
||||
raise AlreadyExists()
|
||||
# test to raise
|
||||
ctx.cog.prepare_args(response if isinstance(response, str) else response[0])
|
||||
author = ctx.message.author
|
||||
ccinfo = {
|
||||
"author": {"id": author.id, "name": author.name},
|
||||
@ -110,6 +129,9 @@ class CommandObj:
|
||||
await ctx.send(_("What response do you want?"))
|
||||
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["edited_at"] = self.get_now()
|
||||
|
||||
@ -151,19 +173,10 @@ class CustomCommands:
|
||||
@checks.mod_or_permissions(administrator=True)
|
||||
async def cc_add(self, ctx: commands.Context):
|
||||
"""
|
||||
Adds a new custom command
|
||||
|
||||
CCs can be enhanced with arguments:
|
||||
|
||||
Argument What it will be substituted with
|
||||
|
||||
{message} message
|
||||
|
||||
{author} message.author
|
||||
|
||||
{channel} message.channel
|
||||
|
||||
{guild} message.guild
|
||||
|
||||
{server} message.guild
|
||||
https://red-discordbot.readthedocs.io/en/v3-develop/cog_customcom.html
|
||||
"""
|
||||
pass
|
||||
|
||||
@ -175,7 +188,6 @@ class CustomCommands:
|
||||
|
||||
Note: This is interactive
|
||||
"""
|
||||
channel = ctx.channel
|
||||
responses = []
|
||||
|
||||
responses = await self.commandobj.get_responses(ctx=ctx)
|
||||
@ -199,7 +211,6 @@ class CustomCommands:
|
||||
Example:
|
||||
[p]customcom add simple yourcommand Text you want
|
||||
"""
|
||||
guild = ctx.guild
|
||||
command = command.lower()
|
||||
if command in self.bot.all_commands:
|
||||
await ctx.send(_("That command is already a standard command."))
|
||||
@ -213,6 +224,8 @@ class CustomCommands:
|
||||
"{}customcom edit".format(ctx.prefix)
|
||||
)
|
||||
)
|
||||
except ArgParseError as e:
|
||||
await ctx.send(e.args[0])
|
||||
|
||||
@customcom.command(name="edit")
|
||||
@checks.mod_or_permissions(administrator=True)
|
||||
@ -222,7 +235,6 @@ class CustomCommands:
|
||||
Example:
|
||||
[p]customcom edit yourcommand Text you want
|
||||
"""
|
||||
guild = ctx.message.guild
|
||||
command = command.lower()
|
||||
|
||||
try:
|
||||
@ -234,6 +246,8 @@ class CustomCommands:
|
||||
"{}customcom add".format(ctx.prefix)
|
||||
)
|
||||
)
|
||||
except ArgParseError as e:
|
||||
await ctx.send(e.args[0])
|
||||
|
||||
@customcom.command(name="delete")
|
||||
@checks.mod_or_permissions(administrator=True)
|
||||
@ -241,7 +255,6 @@ class CustomCommands:
|
||||
"""Deletes a custom command
|
||||
Example:
|
||||
[p]customcom delete yourcommand"""
|
||||
guild = ctx.message.guild
|
||||
command = command.lower()
|
||||
try:
|
||||
await self.commandobj.delete(ctx=ctx, command=command)
|
||||
@ -286,49 +299,139 @@ class CustomCommands:
|
||||
|
||||
async def on_message(self, message):
|
||||
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
|
||||
# something similar once it's added
|
||||
|
||||
user_allowed = True
|
||||
|
||||
for prefix in prefixes:
|
||||
if message.content.startswith(prefix):
|
||||
break
|
||||
else:
|
||||
if len(message.content) < 2 or is_private or not user_allowed or message.author.bot:
|
||||
return
|
||||
|
||||
ctx = await self.bot.get_context(message)
|
||||
|
||||
if ctx.prefix is None or ctx.valid:
|
||||
return
|
||||
|
||||
if user_allowed:
|
||||
cmd = message.content[len(prefix) :]
|
||||
try:
|
||||
c = await self.commandobj.get(message=message, command=cmd)
|
||||
if isinstance(c, list):
|
||||
command = random.choice(c)
|
||||
elif isinstance(c, str):
|
||||
command = c
|
||||
raw_response = await self.commandobj.get(message=message, command=ctx.invoked_with)
|
||||
if isinstance(raw_response, list):
|
||||
raw_response = random.choice(raw_response)
|
||||
elif isinstance(raw_response, str):
|
||||
pass
|
||||
else:
|
||||
raise NotFound()
|
||||
except NotFound:
|
||||
return
|
||||
response = self.format_cc(command, message)
|
||||
await message.channel.send(response)
|
||||
await self.call_cc_command(ctx, raw_response, message)
|
||||
|
||||
def format_cc(self, command, message) -> str:
|
||||
results = re.findall("\{([^}]+)\}", command)
|
||||
async def call_cc_command(self, ctx, raw_response, message) -> None:
|
||||
# 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:
|
||||
param = self.transform_parameter(result, message)
|
||||
command = command.replace("{" + result + "}", param)
|
||||
return command
|
||||
param = self.transform_parameter(result, ctx.message)
|
||||
raw_response = raw_response.replace("{" + result + "}", param)
|
||||
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:
|
||||
"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user