mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-06 11:18:54 -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
|
:maxdepth: 2
|
||||||
:caption: Cog Reference:
|
:caption: Cog Reference:
|
||||||
|
|
||||||
|
cog_customcom
|
||||||
cog_downloader
|
cog_downloader
|
||||||
cog_permissions
|
cog_permissions
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
|
||||||
if message.content.startswith(prefix):
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if user_allowed:
|
ctx = await self.bot.get_context(message)
|
||||||
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
|
|
||||||
else:
|
|
||||||
raise NotFound()
|
|
||||||
except NotFound:
|
|
||||||
return
|
|
||||||
response = self.format_cc(command, message)
|
|
||||||
await message.channel.send(response)
|
|
||||||
|
|
||||||
def format_cc(self, command, message) -> str:
|
if ctx.prefix is None or ctx.valid:
|
||||||
results = re.findall("\{([^}]+)\}", command)
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
await self.call_cc_command(ctx, raw_response, message)
|
||||||
|
|
||||||
|
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:
|
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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user