Permissions redesign (#2149)

API changes:
- Cogs must now inherit from `commands.Cog` (see #2151 for discussion and more details)
- All functions which are not decorators in the `redbot.core.checks` module are now deprecated in favour of their counterparts in `redbot.core.utils.mod`. This is to make this module more consistent and end the confusing naming convention.
- `redbot.core.checks.check_overrides` function is now gone, overrideable checks can now be created with the `@commands.permissions_check` decorator
- Command, Group, Cog and Context have some new attributes and methods, but they are for internal use so shouldn't concern cog creators (unless they're making a permissions cog!).
- `__permissions_check_before` and `__permissions_check_after` have been replaced:  A cog method named `__permissions_hook` will be evaluated as permissions hooks in the same way `__permissions_check_before` previously was. Permissions hooks can also be added/removed/verified through the new `*_permissions_hook()` methods on the bot object, and they will be verified even when permissions is unloaded.
- New utility method `redbot.core.utils.chat_formatting.humanize_list`
- New dependency [`schema`](https://github.com/keleshev/schema)

User-facing changes:
- When a `@bot_has_permissions` check fails, the bot will respond saying what permissions were actually missing.
- All YAML-related `[p]permissions` subcommands now reside under the `[p]permissions acl` sub-group (tbh I still think the whole cog has too many top-level commands)
- The YAML schema for these commands has been changed
- A rule cannot be set as allow and deny at the same time (previously this would just default to allow)

Documentation:
- New documentation for `redbot.core.commands.requires` and `redbot.core.checks` modules
- Renewed documentation for the permissions cog
- `sphinx.ext.doctest` is now enabled

Note: standard discord.py checks will still behave exactly the same way, in fact they are checked before `Requires` is looked at, so they are not overrideable. 

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
This commit is contained in:
Toby Harradine 2018-10-01 13:19:25 +10:00 committed by GitHub
parent f07b78bd0d
commit 0870403299
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 2026 additions and 1044 deletions

14
Pipfile.lock generated
View File

@ -236,6 +236,13 @@
], ],
"version": "==0.1.2" "version": "==0.1.2"
}, },
"schema": {
"hashes": [
"sha256:d994b0dc4966000037b26898df638e3e2a694cc73636cb2050e652614a350687",
"sha256:fa1a53fe5f3b6929725a4e81688c250f46838e25d8c1885a10a590c8c01a7b74"
],
"version": "==0.6.8"
},
"websockets": { "websockets": {
"hashes": [ "hashes": [
"sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136", "sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136",
@ -597,6 +604,13 @@
"markers": "python_version != '3.0.*' and python_version != '3.3.*' and python_version < '4' and python_version != '3.1.*' and python_version != '3.2.*' and python_version >= '2.6'", "markers": "python_version != '3.0.*' and python_version != '3.3.*' and python_version < '4' and python_version != '3.1.*' and python_version != '3.2.*' and python_version >= '2.6'",
"version": "==2.19.1" "version": "==2.19.1"
}, },
"schema": {
"hashes": [
"sha256:d994b0dc4966000037b26898df638e3e2a694cc73636cb2050e652614a350687",
"sha256:fa1a53fe5f3b6929725a4e81688c250f46838e25d8c1885a10a590c8c01a7b74"
],
"version": "==0.6.8"
},
"six": { "six": {
"hashes": [ "hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",

View File

@ -8,100 +8,90 @@ Permissions Cog Reference
How it works How it works
------------ ------------
When loaded, the permissions cog will allow you When loaded, the permissions cog will allow you to define extra custom rules for who can use a
to define extra custom rules for who can use a command command.
If no applicable rules are found, the command will behave as if If no applicable rules are found, the command will behave normally.
the cog was not loaded.
Rules can also be added to cogs, which will affect all commands from that cog. The cog name can be
found from the help menu.
------------- -------------
Rule priority Rule priority
------------- -------------
Rules set will be checked in the following order Rules set for subcommands will take precedence over rules set for the parent commands, which
lastly take precedence over rules set for the cog. So for example, if a user is denied the Core
cog, but allowed the ``[p]set token`` command, the user will not be able to use any command in the
Core cog except for ``[p]set token``.
In terms of scope, global rules will be checked first, then server rules.
1. Owner level command specific settings For each of those, the first rule pertaining to one of the following models will be used:
2. Owner level cog specific settings
3. Server level command specific settings
4. Server level cog specific settings
For each of those, settings have varying priorities (listed below, highest to lowest priority) 1. User
2. Voice channel
3. Text channel
4. Channel category
5. Roles, highest to lowest
6. Server (can only be in global rules)
7. Default rules
1. User whitelist In private messages, only global rules about a user will be checked.
2. User blacklist
3. Voice Channel whitelist
4. Voice Channel blacklist
5. Text Channel whitelist
6. Text Channel blacklist
7. Role settings (see below)
8. Server whitelist
9. Server blacklist
10. Default settings
For the role whitelist and blacklist settings,
roles will be checked individually in order from highest to lowest role the user has
Each role will be checked for whitelist, then blacklist. The first role with a setting
found will be the one used.
------------------------- -------------------------
Setting Rules from a file Setting Rules From a File
------------------------- -------------------------
The permissions cog can set rules from a yaml file: The permissions cog can also set, display or update rules with a YAML file with the
All entries are based on ID. ``[p]permissions yaml`` command. Models must be represented by ID. Rules must be ``true`` for
An example of the expected format is shown below. allow, or ``false`` for deny. Here is an example:
.. code-block:: yaml .. code-block:: yaml
cogs: COG:
Admin: Admin:
allow: 78631113035100160: true
- 78631113035100160 96733288462286848: false
deny:
- 96733288462286848
Audio: Audio:
allow: 133049272517001216: true
- 133049272517001216 default: false
default: deny COMMAND:
commands:
cleanup bot: cleanup bot:
allow: 78631113035100160: true
- 78631113035100160 default: false
default: deny
ping: ping:
deny: 96733288462286848: false
- 96733288462286848 default: true
default: allow
---------------------- ----------------------
Example configurations Example configurations
---------------------- ----------------------
Locking Audio cog to approved server(s) as a bot owner Locking the ``[p]play`` command to approved server(s) as a bot owner:
.. code-block:: none .. code-block:: none
[p]permissions setglobaldefault Audio deny [p]permissions setglobaldefault play deny
[p]permissions addglobalrule allow Audio [server ID or name] [p]permissions addglobalrule allow play [server ID or name]
Locking Audio to specific voice channel(s) as a serverowner or admin: Locking the ``[p]play`` command to specific voice channel(s) as a serverowner or admin:
.. code-block:: none .. code-block:: none
[p]permissions setguilddefault deny play [p]permissions setserverdefault deny play
[p]permissions setguilddefault deny "playlist start" [p]permissions setserverdefault deny "playlist start"
[p]permissions addguildrule allow play [voice channel ID or name] [p]permissions addserverrule allow play [voice channel ID or name]
[p]permissions addguildrule allow "playlist start" [voice channel ID or name] [p]permissions addserverrule allow "playlist start" [voice channel ID or name]
Allowing extra roles to use cleanup Allowing extra roles to use ``[p]cleanup``:
.. code-block:: none .. code-block:: none
[p]permissions addguildrule allow Cleanup [role ID] [p]permissions addserverrule allow cleanup [role ID]
Preventing cleanup from being used in channels where message history is important: Preventing ``[p]cleanup`` from being used in channels where message history is important:
.. code-block:: none .. code-block:: none
[p]permissions addguildrule deny Cleanup [channel ID or mention] [p]permissions addserverrule deny cleanup [channel ID or mention]

View File

@ -39,6 +39,7 @@ extensions = [
"sphinx.ext.intersphinx", "sphinx.ext.intersphinx",
"sphinx.ext.viewcode", "sphinx.ext.viewcode",
"sphinx.ext.napoleon", "sphinx.ext.napoleon",
"sphinx.ext.doctest",
"sphinxcontrib.asyncio", "sphinxcontrib.asyncio",
] ]
@ -197,9 +198,16 @@ texinfo_documents = [
linkcheck_ignore = [r"https://java.com*"] linkcheck_ignore = [r"https://java.com*"]
# Example configuration for intersphinx: refer to the Python standard library. # -- Options for extensions -----------------------------------------------
# Intersphinx
intersphinx_mapping = { intersphinx_mapping = {
"python": ("https://docs.python.org/3.6", None), "python": ("https://docs.python.org/3.6", None),
"dpy": ("https://discordpy.readthedocs.io/en/rewrite/", None), "dpy": ("https://discordpy.readthedocs.io/en/rewrite/", None),
"motor": ("https://motor.readthedocs.io/en/stable/", None), "motor": ("https://motor.readthedocs.io/en/stable/", None),
} }
# Doctest
# If this string is non-empty, all blocks with ``>>>`` in them will be
# tested, not just the ones explicitly marked with ``.. doctest::``
doctest_test_doctest_blocks = ""

11
docs/framework_checks.rst Normal file
View File

@ -0,0 +1,11 @@
.. _checks:
========================
Command Check Decorators
========================
The following are all decorators for commands, which add restrictions to where and when they can be
run.
.. automodule:: redbot.core.checks
:members:

View File

@ -21,3 +21,6 @@ extend functionlities used throughout the bot, as outlined below.
.. autoclass:: redbot.core.commands.Context .. autoclass:: redbot.core.commands.Context
:members: :members:
.. automodule:: redbot.core.commands.requires
:members: PrivilegeLevel, PermState, Requires

View File

@ -33,14 +33,15 @@ Welcome to Red - Discord Bot's documentation!
guide_data_conversion guide_data_conversion
framework_bank framework_bank
framework_bot framework_bot
framework_checks
framework_cogmanager framework_cogmanager
framework_commands
framework_config framework_config
framework_datamanager framework_datamanager
framework_downloader framework_downloader
framework_events framework_events
framework_i18n framework_i18n
framework_modlog framework_modlog
framework_commands
framework_rpc framework_rpc
framework_utils framework_utils

View File

@ -30,3 +30,5 @@ colorama.init()
# Filter fuzzywuzzy slow sequence matcher warning # Filter fuzzywuzzy slow sequence matcher warning
warnings.filterwarnings("ignore", module=r"fuzzywuzzy.*") warnings.filterwarnings("ignore", module=r"fuzzywuzzy.*")
# Prevent discord PyNaCl missing warning
discord.voice_client.VoiceClient.warn_nacl = False

View File

@ -38,8 +38,9 @@ RUNNING_ANNOUNCEMENT = (
) )
class Admin: class Admin(commands.Cog):
def __init__(self, config=Config): def __init__(self, config=Config):
super().__init__()
self.conf = config.get_conf(self, 8237492837454039, force_registration=True) self.conf = config.get_conf(self, 8237492837454039, force_registration=True)
self.conf.register_global(serverlocked=False) self.conf.register_global(serverlocked=False)

View File

@ -14,7 +14,7 @@ _ = Translator("Alias", __file__)
@cog_i18n(_) @cog_i18n(_)
class Alias: class Alias(commands.Cog):
""" """
Alias Alias
@ -31,6 +31,7 @@ class Alias:
default_guild_settings = {"enabled": False, "entries": []} # Going to be a list of dicts default_guild_settings = {"enabled": False, "entries": []} # Going to be a list of dicts
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
self._aliases = Config.get_conf(self, 8927348724) self._aliases = Config.get_conf(self, 8927348724)

View File

@ -27,8 +27,9 @@ __author__ = ["aikaterna", "billy/bollo/ati"]
@cog_i18n(_) @cog_i18n(_)
class Audio: class Audio(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
super().__init__()
self.bot = bot self.bot = bot
self.config = Config.get_conf(self, 2711759130, force_registration=True) self.config = Config.get_conf(self, 2711759130, force_registration=True)

View File

@ -54,10 +54,11 @@ def check_global_setting_admin():
@cog_i18n(_) @cog_i18n(_)
class Bank: class Bank(commands.Cog):
"""Bank""" """Bank"""
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
# SECTION commands # SECTION commands

View File

@ -14,10 +14,11 @@ _ = Translator("Cleanup", __file__)
@cog_i18n(_) @cog_i18n(_)
class Cleanup: class Cleanup(commands.Cog):
"""Commands for cleaning messages""" """Commands for cleaning messages"""
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
@staticmethod @staticmethod

View File

@ -172,12 +172,13 @@ class CommandObj:
@cog_i18n(_) @cog_i18n(_)
class CustomCommands: class CustomCommands(commands.Cog):
"""Custom commands """Custom commands
Creates commands used to display text""" Creates commands used to display text"""
def __init__(self, bot): def __init__(self, bot):
super().__init__()
self.bot = bot self.bot = bot
self.key = 414589031223512 self.key = 414589031223512
self.config = Config.get_conf(self, self.key) self.config = Config.get_conf(self, self.key)

View File

@ -11,12 +11,13 @@ _ = Translator("DataConverter", __file__)
@cog_i18n(_) @cog_i18n(_)
class DataConverter: class DataConverter(commands.Cog):
""" """
Cog for importing Red v2 Data Cog for importing Red v2 Data
""" """
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
@checks.is_owner() @checks.is_owner()

View File

@ -23,8 +23,9 @@ _ = Translator("Downloader", __file__)
@cog_i18n(_) @cog_i18n(_)
class Downloader: class Downloader(commands.Cog):
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
self.conf = Config.get_conf(self, identifier=998240343, force_registration=True) self.conf = Config.get_conf(self, identifier=998240343, force_registration=True)

View File

@ -105,7 +105,7 @@ class SetParser:
@cog_i18n(_) @cog_i18n(_)
class Economy: class Economy(commands.Cog):
"""Economy """Economy
Get rich and have fun with imaginary currency!""" Get rich and have fun with imaginary currency!"""
@ -128,6 +128,7 @@ class Economy:
default_user_settings = default_member_settings default_user_settings = default_member_settings
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
self.file_path = "data/economy/settings.json" self.file_path = "data/economy/settings.json"
self.config = Config.get_conf(self, 1256844281) self.config = Config.get_conf(self, 1256844281)

View File

@ -10,10 +10,11 @@ _ = Translator("Filter", __file__)
@cog_i18n(_) @cog_i18n(_)
class Filter: class Filter(commands.Cog):
"""Filter-related commands""" """Filter-related commands"""
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
self.settings = Config.get_conf(self, 4766951341) self.settings = Config.get_conf(self, 4766951341)
default_guild_settings = { default_guild_settings = {

View File

@ -33,10 +33,11 @@ class RPSParser:
@cog_i18n(_) @cog_i18n(_)
class General: class General(commands.Cog):
"""General commands.""" """General commands."""
def __init__(self): def __init__(self):
super().__init__()
self.stopwatches = {} self.stopwatches = {}
self.ball = [ self.ball = [
_("As I see it, yes"), _("As I see it, yes"),

View File

@ -11,12 +11,13 @@ GIPHY_API_KEY = "dc6zaTOxFJmzC"
@cog_i18n(_) @cog_i18n(_)
class Image: class Image(commands.Cog):
"""Image related commands.""" """Image related commands."""
default_global = {"imgur_client_id": None} default_global = {"imgur_client_id": None}
def __init__(self, bot): def __init__(self, bot):
super().__init__()
self.bot = bot self.bot = bot
self.settings = Config.get_conf(self, identifier=2652104208, force_registration=True) self.settings = Config.get_conf(self, identifier=2652104208, force_registration=True)
self.settings.register_global(**self.default_global) self.settings.register_global(**self.default_global)

View File

@ -26,7 +26,7 @@ def mod_or_voice_permissions(**perms):
else: else:
return True return True
return commands.check(pred) return commands.permissions_check(pred)
def admin_or_voice_permissions(**perms): def admin_or_voice_permissions(**perms):
@ -48,7 +48,7 @@ def admin_or_voice_permissions(**perms):
else: else:
return True return True
return commands.check(pred) return commands.permissions_check(pred)
def bot_has_voice_permissions(**perms): def bot_has_voice_permissions(**perms):

View File

@ -18,7 +18,7 @@ _ = Translator("Mod", __file__)
@cog_i18n(_) @cog_i18n(_)
class Mod: class Mod(commands.Cog):
"""Moderation tools.""" """Moderation tools."""
default_guild_settings = { default_guild_settings = {
@ -38,6 +38,7 @@ class Mod:
default_user_settings = {"past_names": []} default_user_settings = {"past_names": []}
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
self.settings = Config.get_conf(self, 4961522000, force_registration=True) self.settings = Config.get_conf(self, 4961522000, force_registration=True)

View File

@ -9,10 +9,11 @@ _ = Translator("ModLog", __file__)
@cog_i18n(_) @cog_i18n(_)
class ModLog: class ModLog(commands.Cog):
"""Log for mod actions""" """Log for mod actions"""
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
@commands.group() @commands.group()

View File

@ -1,5 +1,13 @@
from .permissions import Permissions from .permissions import Permissions
def setup(bot): async def setup(bot):
bot.add_cog(Permissions(bot)) cog = Permissions(bot)
await cog.initialize()
# It's important that these listeners are added prior to load, so
# the permissions commands themselves have rules added.
# Automatic listeners being added in add_cog happen in arbitrary
# order, so we want to circumvent that.
bot.add_listener(cog.cog_added, "on_cog_add")
bot.add_listener(cog.command_added, "on_command_add")
bot.add_cog(cog)

View File

@ -1,15 +1,21 @@
from typing import NamedTuple, Union, Optional
from redbot.core import commands from redbot.core import commands
from typing import Tuple
class CogOrCommand(commands.Converter): class CogOrCommand(NamedTuple):
async def convert(self, ctx: commands.Context, arg: str) -> Tuple[str]: type: str
ret = ctx.bot.get_cog(arg) name: str
if ret: obj: Union[commands.Command, commands.Cog]
return "cogs", ret.__class__.__name__
ret = ctx.bot.get_command(arg) # noinspection PyArgumentList
if ret: @classmethod
return "commands", ret.qualified_name async def convert(cls, ctx: commands.Context, arg: str) -> "CogOrCommand":
cog = ctx.bot.get_cog(arg)
if cog:
return cls(type="COG", name=cog.__class__.__name__, obj=cog)
cmd = ctx.bot.get_command(arg)
if cmd:
return cls(type="COMMAND", name=cmd.qualified_name, obj=cmd)
raise commands.BadArgument( raise commands.BadArgument(
'Cog or command "{arg}" not found. Please note that this is case sensitive.' 'Cog or command "{arg}" not found. Please note that this is case sensitive.'
@ -17,28 +23,34 @@ class CogOrCommand(commands.Converter):
) )
class RuleType(commands.Converter): class RuleType:
async def convert(self, ctx: commands.Context, arg: str) -> str:
# noinspection PyUnusedLocal
@classmethod
async def convert(cls, ctx: commands.Context, arg: str) -> bool:
if arg.lower() in ("allow", "whitelist", "allowed"): if arg.lower() in ("allow", "whitelist", "allowed"):
return "allow" return True
if arg.lower() in ("deny", "blacklist", "denied"): if arg.lower() in ("deny", "blacklist", "denied"):
return "deny" return False
raise commands.BadArgument( raise commands.BadArgument(
'"{arg}" is not a valid rule. Valid rules are "allow" or "deny"'.format(arg=arg) '"{arg}" is not a valid rule. Valid rules are "allow" or "deny"'.format(arg=arg)
) )
class ClearableRuleType(commands.Converter): class ClearableRuleType:
async def convert(self, ctx: commands.Context, arg: str) -> str:
# noinspection PyUnusedLocal
@classmethod
async def convert(cls, ctx: commands.Context, arg: str) -> Optional[bool]:
if arg.lower() in ("allow", "whitelist", "allowed"): if arg.lower() in ("allow", "whitelist", "allowed"):
return "allow" return True
if arg.lower() in ("deny", "blacklist", "denied"): if arg.lower() in ("deny", "blacklist", "denied"):
return "deny" return False
if arg.lower() in ("clear", "reset"): if arg.lower() in ("clear", "reset"):
return "clear" return None
raise commands.BadArgument( raise commands.BadArgument(
'"{arg}" is not a valid rule. Valid rules are "allow" or "deny", or "clear" to remove the rule' '"{arg}" is not a valid rule. Valid rules are "allow" or "deny", or "clear" to '
"".format(arg=arg) "remove the rule".format(arg=arg)
) )

View File

@ -1,102 +0,0 @@
from redbot.core import commands
from redbot.core.config import Config
from .resolvers import entries_from_ctx, resolve_lists
# This has optimizations in it that may not hold True if other parts of the permission
# model are changed from the state they are in currently.
# (commit hash ~ 3bcf375204c22271ad3ed1fc059b598b751aa03f)
#
# This is primarily to help with the performance of the help formatter
# This is less efficient if only checking one command,
# but is much faster for checking all of them.
async def mass_resolve(*, ctx: commands.Context, config: Config):
"""
Get's all the permission cog interactions for all loaded commands
in the given context.
"""
owner_settings = await config.owner_models()
guild_owner_settings = await config.guild(ctx.guild).owner_models() if ctx.guild else None
ret = {"allowed": [], "denied": [], "default": []}
for cogname, cog in ctx.bot.cogs.items():
cog_setting = resolve_cog_or_command(
objname=cogname, models=owner_settings, ctx=ctx, typ="cogs"
)
if cog_setting is None and guild_owner_settings:
cog_setting = resolve_cog_or_command(
objname=cogname, models=guild_owner_settings, ctx=ctx, typ="cogs"
)
for command in [c for c in ctx.bot.all_commands.values() if c.instance is cog]:
resolution = recursively_resolve(
com_or_group=command,
o_models=owner_settings,
g_models=guild_owner_settings,
ctx=ctx,
)
for com, resolved in resolution:
if resolved is None:
resolved = cog_setting
if resolved is True:
ret["allowed"].append(com)
elif resolved is False:
ret["denied"].append(com)
else:
ret["default"].append(com)
ret = {k: set(v) for k, v in ret.items()}
return ret
def recursively_resolve(*, com_or_group, o_models, g_models, ctx, override=False):
ret = []
if override:
current = False
else:
current = resolve_cog_or_command(
typ="commands", objname=com_or_group.qualified_name, ctx=ctx, models=o_models
)
if current is None and g_models:
current = resolve_cog_or_command(
typ="commands", objname=com_or_group.qualified_name, ctx=ctx, models=o_models
)
ret.append((com_or_group, current))
if isinstance(com_or_group, commands.Group):
for com in com_or_group.commands:
ret.extend(
recursively_resolve(
com_or_group=com,
o_models=o_models,
g_models=g_models,
ctx=ctx,
override=(current is False),
)
)
return ret
def resolve_cog_or_command(*, typ, ctx, objname, models: dict) -> bool:
"""
Resolves models in order.
"""
resolved = None
if objname in models.get(typ, {}):
blacklist = models[typ][objname].get("deny", [])
whitelist = models[typ][objname].get("allow", [])
resolved = resolve_lists(ctx=ctx, whitelist=whitelist, blacklist=blacklist)
if resolved is not None:
return resolved
resolved = models[typ][objname].get("default", None)
if resolved is not None:
return resolved
return None

File diff suppressed because it is too large Load Diff

View File

@ -1,81 +0,0 @@
import types
import contextlib
import asyncio
import logging
from redbot.core import commands
log = logging.getLogger("redbot.cogs.permissions.resolvers")
def entries_from_ctx(ctx: commands.Context) -> tuple:
voice_channel = None
with contextlib.suppress(Exception):
voice_channel = ctx.author.voice.voice_channel
entries = [x.id for x in (ctx.author, voice_channel, ctx.channel) if x]
roles = sorted(ctx.author.roles, reverse=True) if ctx.guild else []
entries.extend([x.id for x in roles])
# entries now contains the following (in order) (if applicable)
# author.id
# author.voice.voice_channel.id
# channel.id
# role.id for each role (highest to lowest)
# (implicitly) guild.id because
# the @everyone role shares an id with the guild
return tuple(entries)
async def val_if_check_is_valid(*, ctx: commands.Context, check: object, level: str) -> bool:
"""
Returns the value from a check if it is valid
"""
val = None
# let's not spam the console with improperly made 3rd party checks
try:
if asyncio.iscoroutinefunction(check):
val = await check(ctx, level=level)
else:
val = check(ctx, level=level)
except Exception as e:
# but still provide a way to view it (run with debug flag)
log.debug(str(e))
return val
def resolve_models(*, ctx: commands.Context, models: dict) -> bool:
"""
Resolves models in order.
"""
cmd_name = ctx.command.qualified_name
cog_name = ctx.cog.__class__.__name__
resolved = None
to_iter = (("commands", cmd_name), ("cogs", cog_name))
for model_name, ctx_attr in to_iter:
if ctx_attr in models.get(model_name, {}):
blacklist = models[model_name][ctx_attr].get("deny", [])
whitelist = models[model_name][ctx_attr].get("allow", [])
resolved = resolve_lists(ctx=ctx, whitelist=whitelist, blacklist=blacklist)
if resolved is not None:
return resolved
resolved = models[model_name][ctx_attr].get("default", None)
if resolved is not None:
return resolved
return None
def resolve_lists(*, ctx: commands.Context, whitelist: list, blacklist: list) -> bool:
"""
resolves specific lists
"""
for entry in entries_from_ctx(ctx):
if entry in whitelist:
return True
if entry in blacklist:
return False
return None

View File

@ -1,19 +0,0 @@
cogs:
Admin:
allow:
- 78631113035100160
deny:
- 96733288462286848
Audio:
allow:
- 133049272517001216
default: deny
commands:
cleanup bot:
allow:
- 78631113035100160
default: deny
ping:
deny:
- 96733288462286848
default: allow

View File

@ -1,67 +0,0 @@
import io
import yaml
import pathlib
import discord
def yaml_template() -> dict:
template_fp = pathlib.Path(__file__).parent / "template.yaml"
with template_fp.open() as f:
return yaml.safe_load(f)
async def yamlset_acl(ctx, *, config, update):
_fp = io.BytesIO()
await ctx.message.attachments[0].save(_fp)
try:
data = yaml.safe_load(_fp)
except yaml.YAMLError:
_fp.close()
del _fp
raise
old_data = await config()
for outer, inner in data.items():
for ok, iv in inner.items():
for k, v in iv.items():
if k == "default":
data[outer][ok][k] = {"allow": True, "deny": False}.get(v.lower(), None)
if not update:
continue
try:
if isinstance(old_data[outer][ok][k], list):
data[outer][ok][k].extend(old_data[outer][ok][k])
except KeyError:
pass
await config.set(data)
async def yamlget_acl(ctx, *, config):
data = await config()
removals = []
for outer, inner in data.items():
for ok, iv in inner.items():
for k, v in iv.items():
if k != "default":
continue
if v is True:
data[outer][ok][k] = "allow"
elif v is False:
data[outer][ok][k] = "deny"
else:
removals.append((outer, ok, k))
for tup in removals:
o, i, k = tup
data[o][i].pop(k, None)
_fp = io.BytesIO(yaml.dump(data, default_flow_style=False).encode())
_fp.seek(0)
await ctx.author.send(file=discord.File(_fp, filename="acl.yaml"))
_fp.close()

View File

@ -20,7 +20,7 @@ log = logging.getLogger("red.reports")
@cog_i18n(_) @cog_i18n(_)
class Reports: class Reports(commands.Cog):
default_guild_settings = {"output_channel": None, "active": False, "next_ticket": 1} default_guild_settings = {"output_channel": None, "active": False, "next_ticket": 1}
@ -40,6 +40,7 @@ class Reports:
] ]
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.bot = bot self.bot = bot
self.config = Config.get_conf(self, 78631113035100160, force_registration=True) self.config = Config.get_conf(self, 78631113035100160, force_registration=True)
self.config.register_guild(**self.default_guild_settings) self.config.register_guild(**self.default_guild_settings)

View File

@ -35,7 +35,7 @@ _ = Translator("Streams", __file__)
@cog_i18n(_) @cog_i18n(_)
class Streams: class Streams(commands.Cog):
global_defaults = {"tokens": {}, "streams": [], "communities": []} global_defaults = {"tokens": {}, "streams": [], "communities": []}
@ -44,6 +44,7 @@ class Streams:
role_defaults = {"mention": False} role_defaults = {"mention": False}
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.db = Config.get_conf(self, 26262626) self.db = Config.get_conf(self, 26262626)
self.db.register_global(**self.global_defaults) self.db.register_global(**self.global_defaults)

View File

@ -23,10 +23,11 @@ class InvalidListError(Exception):
pass pass
class Trivia: class Trivia(commands.Cog):
"""Play trivia with friends!""" """Play trivia with friends!"""
def __init__(self): def __init__(self):
super().__init__()
self.trivia_sessions = [] self.trivia_sessions = []
self.conf = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True) self.conf = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True)

View File

@ -20,7 +20,7 @@ _ = Translator("Warnings", __file__)
@cog_i18n(_) @cog_i18n(_)
class Warnings: class Warnings(commands.Cog):
"""A warning system for Red""" """A warning system for Red"""
default_guild = {"actions": [], "reasons": {}, "allow_custom_reasons": False} default_guild = {"actions": [], "reasons": {}, "allow_custom_reasons": False}
@ -28,6 +28,7 @@ class Warnings:
default_member = {"total_points": 0, "status": "", "warnings": {}} default_member = {"total_points": 0, "status": "", "warnings": {}}
def __init__(self, bot: Red): def __init__(self, bot: Red):
super().__init__()
self.config = Config.get_conf(self, identifier=5757575755) self.config = Config.get_conf(self, identifier=5757575755)
self.config.register_guild(**self.default_guild) self.config.register_guild(**self.default_guild)
self.config.register_member(**self.default_member) self.config.register_member(**self.default_member)

View File

@ -5,17 +5,12 @@ from collections import Counter
from enum import Enum from enum import Enum
from importlib.machinery import ModuleSpec from importlib.machinery import ModuleSpec
from pathlib import Path from pathlib import Path
from typing import Union from typing import Optional, Union, List
import discord import discord
import sys import sys
from discord.ext.commands import when_mentioned_or from discord.ext.commands import when_mentioned_or
# This supresses the PyNaCl warning that isn't relevant here
from discord.voice_client import VoiceClient
VoiceClient.warn_nacl = False
from .cog_manager import CogManager from .cog_manager import CogManager
from . import Config, i18n, commands from . import Config, i18n, commands
from .rpc import RPCMixin from .rpc import RPCMixin
@ -124,6 +119,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
self.add_command(help_) self.add_command(help_)
self._sentry_mgr = None self._sentry_mgr = None
self._permissions_hooks: List[commands.CheckPredicate] = []
def enable_sentry(self): def enable_sentry(self):
"""Enable Sentry logging for Red.""" """Enable Sentry logging for Red."""
@ -200,7 +196,8 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
async def get_context(self, message, *, cls=commands.Context): async def get_context(self, message, *, cls=commands.Context):
return await super().get_context(message, cls=cls) return await super().get_context(message, cls=cls)
def list_packages(self): @staticmethod
def list_packages():
"""Lists packages present in the cogs the folder""" """Lists packages present in the cogs the folder"""
return os.listdir("cogs") return os.listdir("cogs")
@ -234,7 +231,26 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
self.extensions[name] = lib self.extensions[name] = lib
def remove_cog(self, cogname): def remove_cog(self, cogname: str):
cog = self.get_cog(cogname)
if cog is None:
return
for when in ("before", "after"):
try:
hook = getattr(cog, f"_{cog.__class__.__name__}__red_permissions_{when}")
except AttributeError:
pass
else:
self.remove_permissions_hook(hook, when)
try:
hook = getattr(cog, f"_{cog.__class__.__name__}__red_permissions_before")
except AttributeError:
pass
else:
self.remove_permissions_hook(hook)
super().remove_cog(cogname) super().remove_cog(cogname)
for meth in self.rpc_handlers.pop(cogname.upper(), ()): for meth in self.rpc_handlers.pop(cogname.upper(), ()):
@ -365,9 +381,19 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
await destination.send(content=content, **kwargs) await destination.send(content=content, **kwargs)
def add_cog(self, cog): def add_cog(self, cog: commands.Cog):
if not isinstance(cog, commands.Cog):
raise RuntimeError(
f"The {cog.__class__.__name__} cog in the {cog.__module__} package does "
f"not inherit from the commands.Cog base class. The cog author must update "
f"the cog to adhere to this requirement."
)
if not hasattr(cog, "requires"):
commands.Cog.__init__(cog)
for attr in dir(cog): for attr in dir(cog):
_attr = getattr(cog, attr) _attr = getattr(cog, attr)
if attr == f"_{cog.__class__.__name__}__permissions_hook":
self.add_permissions_hook(_attr)
if isinstance(_attr, discord.ext.commands.Command) and not isinstance( if isinstance(_attr, discord.ext.commands.Command) and not isinstance(
_attr, commands.Command _attr, commands.Command
): ):
@ -380,6 +406,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
"http://red-discordbot.readthedocs.io/en/v3-develop/framework_commands.html" "http://red-discordbot.readthedocs.io/en/v3-develop/framework_commands.html"
) )
super().add_cog(cog) super().add_cog(cog)
self.dispatch("cog_add", cog)
def add_command(self, command: commands.Command): def add_command(self, command: commands.Command):
if not isinstance(command, commands.Command): if not isinstance(command, commands.Command):
@ -388,6 +415,76 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
super().add_command(command) super().add_command(command)
self.dispatch("command_add", command) self.dispatch("command_add", command)
def clear_permission_rules(self, guild_id: Optional[int]) -> None:
"""Clear all permission overrides in a scope.
Parameters
----------
guild_id : Optional[int]
The guild ID to wipe permission overrides for. If
``None``, this will clear all global rules and leave all
guild rules untouched.
"""
for cog in self.cogs.values():
cog.requires.clear_all_rules(guild_id)
for command in self.walk_commands():
command.requires.clear_all_rules(guild_id)
def add_permissions_hook(self, hook: commands.CheckPredicate) -> None:
"""Add a permissions hook.
Permissions hooks are check predicates which are called before
calling `Requires.verify`, and they can optionally return an
override: ``True`` to allow, ``False`` to deny, and ``None`` to
default to normal behaviour.
Parameters
----------
hook
A command check predicate which returns ``True``, ``False``
or ``None``.
"""
self._permissions_hooks.append(hook)
def remove_permissions_hook(self, hook: commands.CheckPredicate) -> None:
"""Remove a permissions hook.
Parameters are the same as those in `add_permissions_hook`.
Raises
------
ValueError
If the permissions hook has not been added.
"""
self._permissions_hooks.remove(hook)
async def verify_permissions_hooks(self, ctx: commands.Context) -> Optional[bool]:
"""Run permissions hooks.
Parameters
----------
ctx : commands.Context
The context for the command being invoked.
Returns
-------
Optional[bool]
``False`` if any hooks returned ``False``, ``True`` if any
hooks return ``True`` and none returned ``False``, ``None``
otherwise.
"""
hook_results = []
for hook in self._permissions_hooks:
result = await discord.utils.maybe_coroutine(hook, ctx)
if result is not None:
hook_results.append(result)
if hook_results:
return all(hook_results)
class Red(RedBase, discord.AutoShardedClient): class Red(RedBase, discord.AutoShardedClient):
""" """

View File

@ -1,126 +1,77 @@
import warnings
from typing import Awaitable, TYPE_CHECKING, Dict
import discord import discord
from redbot.core import commands
from .commands import (
async def check_overrides(ctx, *, level): bot_has_permissions,
if await ctx.bot.is_owner(ctx.author): has_permissions,
return True is_owner,
perm_cog = ctx.bot.get_cog("Permissions") guildowner,
if not perm_cog or ctx.cog == perm_cog: guildowner_or_permissions,
return None admin,
# don't break if someone loaded a cog named admin_or_permissions,
# permissions that doesn't implement this mod,
func = getattr(perm_cog, "check_overrides", None) mod_or_permissions,
val = None if func is None else await func(ctx, level) check as _check_decorator,
return val )
from .utils.mod import (
is_mod_or_superior as _is_mod_or_superior,
def is_owner(**kwargs): is_admin_or_superior as _is_admin_or_superior,
async def check(ctx): check_permissions as _check_permissions,
return await ctx.bot.is_owner(ctx.author, **kwargs)
return commands.check(check)
async def check_permissions(ctx, perms):
if await ctx.bot.is_owner(ctx.author):
return True
elif not perms:
return False
resolved = ctx.channel.permissions_for(ctx.author)
return resolved.administrator or all(
getattr(resolved, name, None) == value for name, value in perms.items()
) )
if TYPE_CHECKING:
from .bot import Red
from .commands import Context
async def is_mod_or_superior(ctx): __all__ = [
if ctx.guild is None: "bot_has_permissions",
return await ctx.bot.is_owner(ctx.author) "has_permissions",
else: "is_owner",
author = ctx.author "guildowner",
settings = ctx.bot.db.guild(ctx.guild) "guildowner_or_permissions",
mod_role_id = await settings.mod_role() "admin",
admin_role_id = await settings.admin_role() "admin_or_permissions",
"mod",
mod_role = discord.utils.get(ctx.guild.roles, id=mod_role_id) "mod_or_permissions",
admin_role = discord.utils.get(ctx.guild.roles, id=admin_role_id) "is_mod_or_superior",
"is_admin_or_superior",
return ( "bot_in_a_guild",
await ctx.bot.is_owner(ctx.author) "check_permissions",
or mod_role in author.roles ]
or admin_role in author.roles
or author == ctx.guild.owner
)
async def is_admin_or_superior(ctx): def bot_in_a_guild():
if ctx.guild is None: """Deny the command if the bot is not in a guild."""
return await ctx.bot.is_owner(ctx.author)
else:
author = ctx.author
settings = ctx.bot.db.guild(ctx.guild)
admin_role_id = await settings.admin_role()
admin_role = discord.utils.get(ctx.guild.roles, id=admin_role_id)
return (
await ctx.bot.is_owner(ctx.author)
or admin_role in author.roles
or author == ctx.guild.owner
)
def mod_or_permissions(**perms):
async def predicate(ctx):
override = await check_overrides(ctx, level="mod")
return (
override
if override is not None
else await check_permissions(ctx, perms) or await is_mod_or_superior(ctx)
)
return commands.check(predicate)
def admin_or_permissions(**perms):
async def predicate(ctx):
override = await check_overrides(ctx, level="admin")
return (
override
if override is not None
else await check_permissions(ctx, perms) or await is_admin_or_superior(ctx)
)
return commands.check(predicate)
def bot_in_a_guild(**kwargs):
async def predicate(ctx): async def predicate(ctx):
return len(ctx.bot.guilds) > 0 return len(ctx.bot.guilds) > 0
return commands.check(predicate) return _check_decorator(predicate)
def guildowner_or_permissions(**perms): def is_mod_or_superior(bot: "Red", member: discord.Member) -> Awaitable[bool]:
async def predicate(ctx): warnings.warn(
has_perms_or_is_owner = await check_permissions(ctx, perms) "`redbot.core.checks.is_mod_or_superior` is deprecated and will be removed in a future "
if ctx.guild is None: "release, please use `redbot.core.utils.mod.is_mod_or_superior` instead.",
return has_perms_or_is_owner category=DeprecationWarning,
is_guild_owner = ctx.author == ctx.guild.owner )
return _is_mod_or_superior(bot, member)
override = await check_overrides(ctx, level="guildowner")
return override if override is not None else is_guild_owner or has_perms_or_is_owner
return commands.check(predicate)
def guildowner(): def is_admin_or_superior(bot: "Red", member: discord.Member) -> Awaitable[bool]:
return guildowner_or_permissions() warnings.warn(
"`redbot.core.checks.is_admin_or_superior` is deprecated and will be removed in a future "
"release, please use `redbot.core.utils.mod.is_admin_or_superior` instead.",
category=DeprecationWarning,
)
return _is_admin_or_superior(bot, member)
def admin(): def check_permissions(ctx: "Context", perms: Dict[str, bool]) -> Awaitable[bool]:
return admin_or_permissions() warnings.warn(
"`redbot.core.checks.check_permissions` is deprecated and will be removed in a future "
"release, please use `redbot.core.utils.mod.check_permissions`."
def mod(): )
return mod_or_permissions() return _check_permissions(ctx, perms)

View File

@ -311,7 +311,7 @@ _ = Translator("CogManagerUI", __file__)
@cog_i18n(_) @cog_i18n(_)
class CogManagerUI: class CogManagerUI(commands.Cog):
"""Commands to interface with Red's cog manager.""" """Commands to interface with Red's cog manager."""
@staticmethod @staticmethod

View File

@ -2,4 +2,6 @@
from discord.ext.commands import * from discord.ext.commands import *
from .commands import * from .commands import *
from .context import * from .context import *
from .converter import *
from .errors import * from .errors import *
from .requires import *

View File

@ -5,33 +5,118 @@ replace those from the `discord.ext.commands` module.
""" """
import inspect import inspect
import weakref import weakref
from typing import Awaitable, Callable, TYPE_CHECKING from typing import Awaitable, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
import discord import discord
from discord.ext import commands from discord.ext import commands
from . import converter as converters
from .errors import ConversionFailure from .errors import ConversionFailure
from .requires import PermState, PrivilegeLevel, Requires
from ..i18n import Translator from ..i18n import Translator
if TYPE_CHECKING: if TYPE_CHECKING:
from .context import Context from .context import Context
__all__ = ["Command", "GroupMixin", "Group", "command", "group"] __all__ = [
"Cog",
"CogCommandMixin",
"CogGroupMixin",
"Command",
"Group",
"GroupMixin",
"command",
"group",
]
_ = Translator("commands.commands", __file__) _ = Translator("commands.commands", __file__)
class Command(commands.Command): class CogCommandMixin:
"""A mixin for cogs and commands."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if isinstance(self, Command):
decorated = self.callback
else:
decorated = self
self.requires: Requires = Requires(
privilege_level=getattr(
decorated, "__requires_privilege_level__", PrivilegeLevel.NONE
),
user_perms=getattr(decorated, "__requires_user_perms__", {}),
bot_perms=getattr(decorated, "__requires_bot_perms__", {}),
checks=getattr(decorated, "__requires_checks__", []),
)
def allow_for(self, model_id: int, guild_id: int) -> None:
"""Actively allow this command for the given model."""
self.requires.set_rule(model_id, PermState.ACTIVE_ALLOW, guild_id=guild_id)
def deny_to(self, model_id: int, guild_id: int) -> None:
"""Actively deny this command to the given model."""
cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
if cur_rule is PermState.PASSIVE_ALLOW:
self.requires.set_rule(model_id, PermState.CAUTIOUS_ALLOW, guild_id=guild_id)
else:
self.requires.set_rule(model_id, PermState.ACTIVE_DENY, guild_id=guild_id)
def clear_rule_for(self, model_id: int, guild_id: int) -> Tuple[PermState, PermState]:
"""Clear the rule which is currently set for this model."""
cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
if cur_rule is PermState.ACTIVE_ALLOW:
new_rule = PermState.NORMAL
elif cur_rule is PermState.ACTIVE_DENY:
new_rule = PermState.NORMAL
elif cur_rule is PermState.CAUTIOUS_ALLOW:
new_rule = PermState.PASSIVE_ALLOW
else:
return cur_rule, cur_rule
self.requires.set_rule(model_id, new_rule, guild_id=guild_id)
return cur_rule, new_rule
def set_default_rule(self, rule: Optional[bool], guild_id: int) -> None:
"""Set the default rule for this cog or command.
Parameters
----------
rule : Optional[bool]
The rule to set as default. If ``True`` for allow,
``False`` for deny and ``None`` for normal.
guild_id : Optional[int]
Specify to set the default rule for a specific guild.
When ``None``, this will set the global default rule.
"""
if guild_id:
self.requires.set_default_guild_rule(guild_id, PermState.from_bool(rule))
else:
self.requires.default_global_rule = PermState.from_bool(rule)
class Command(CogCommandMixin, commands.Command):
"""Command class for Red. """Command class for Red.
This should not be created directly, and instead via the decorator. This should not be created directly, and instead via the decorator.
This class inherits from `discord.ext.commands.Command`. This class inherits from `discord.ext.commands.Command`. The
attributes listed below are simply additions to the ones listed
with that class.
Attributes
----------
checks : List[`coroutine function`]
A list of check predicates which cannot be overridden, unlike
`Requires.checks`.
translator : Translator
A translator for this command's help docstring.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self._help_override = kwargs.pop("help_override", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._help_override = kwargs.pop("help_override", None)
self.translator = kwargs.pop("i18n", None) self.translator = kwargs.pop("i18n", None)
@property @property
@ -59,11 +144,10 @@ class Command(commands.Command):
pass pass
@property @property
def parents(self): def parents(self) -> List["Group"]:
""" """List[Group] : Returns all parent commands of this command.
Returns all parent commands of this command.
This is a list, sorted by the length of :attr:`.qualified_name` from highest to lowest. This is sorted by the length of :attr:`.qualified_name` from highest to lowest.
If the command has no parents, this will be an empty list. If the command has no parents, this will be an empty list.
""" """
cmd = self.parent cmd = self.parent
@ -73,6 +157,33 @@ class Command(commands.Command):
cmd = cmd.parent cmd = cmd.parent
return sorted(entries, key=lambda x: len(x.qualified_name), reverse=True) return sorted(entries, key=lambda x: len(x.qualified_name), reverse=True)
async def can_run(self, ctx: "Context") -> bool:
"""Check if this command can be run in the given context.
This function first checks if the command can be run using
discord.py's method `discord.ext.commands.Command.can_run`,
then will return the result of `Requires.verify`.
"""
ret = await super().can_run(ctx)
if ret is False:
return False
# This is so contexts invoking other commands can be checked with
# this command as well
original_command = ctx.command
ctx.command = self
if self.parent is None and self.instance is not None:
# For top-level commands, we need to check the cog's requires too
ret = await self.instance.requires.verify(ctx)
if ret is False:
return False
try:
return await self.requires.verify(ctx)
finally:
ctx.command = original_command
async def do_conversion( async def do_conversion(
self, ctx: "Context", converter, argument: str, param: inspect.Parameter self, ctx: "Context", converter, argument: str, param: inspect.Parameter
): ):
@ -179,8 +290,32 @@ class Command(commands.Command):
else: else:
return True return True
def allow_for(self, model_id: int, guild_id: int) -> None:
super().allow_for(model_id, guild_id=guild_id)
parents = self.parents
if self.instance is not None:
parents.append(self.instance)
for parent in parents:
cur_rule = parent.requires.get_rule(model_id, guild_id=guild_id)
if cur_rule is PermState.NORMAL:
parent.requires.set_rule(model_id, PermState.PASSIVE_ALLOW, guild_id=guild_id)
elif cur_rule is PermState.ACTIVE_DENY:
parent.requires.set_rule(model_id, PermState.CAUTIOUS_ALLOW, guild_id=guild_id)
class GroupMixin(commands.GroupMixin): def clear_rule_for(self, model_id: int, guild_id: int) -> Tuple[PermState, PermState]:
old_rule, new_rule = super().clear_rule_for(model_id, guild_id=guild_id)
if old_rule is PermState.ACTIVE_ALLOW:
parents = self.parents
if self.instance is not None:
parents.append(self.instance)
for parent in parents:
should_continue = parent.reevaluate_rules_for(model_id, guild_id=guild_id)[1]
if not should_continue:
break
return old_rule, new_rule
class GroupMixin(discord.ext.commands.GroupMixin):
"""Mixin for `Group` and `Red` classes. """Mixin for `Group` and `Red` classes.
This class inherits from :class:`discord.ext.commands.GroupMixin`. This class inherits from :class:`discord.ext.commands.GroupMixin`.
@ -211,7 +346,34 @@ class GroupMixin(commands.GroupMixin):
return decorator return decorator
class Group(GroupMixin, Command, commands.Group): class CogGroupMixin:
requires: Requires
all_commands: Dict[str, Command]
def reevaluate_rules_for(
self, model_id: int, guild_id: Optional[int]
) -> Tuple[PermState, bool]:
cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
if cur_rule in (PermState.NORMAL, PermState.ACTIVE_ALLOW, PermState.ACTIVE_DENY):
# These three states are unaffected by subcommand rules
return cur_rule, False
else:
# Remaining states can be changed if there exists no actively-allowed
# subcommand (this includes subcommands multiple levels below)
if any(
cmd.requires.get_rule(model_id, guild_id=guild_id) in PermState.ALLOWED_STATES
for cmd in self.all_commands.values()
):
return cur_rule, False
elif cur_rule is PermState.PASSIVE_ALLOW:
self.requires.set_rule(model_id, PermState.NORMAL, guild_id=guild_id)
return PermState.NORMAL, True
elif cur_rule is PermState.CAUTIOUS_ALLOW:
self.requires.set_rule(model_id, PermState.ACTIVE_DENY, guild_id=guild_id)
return PermState.ACTIVE_DENY, True
class Group(GroupMixin, Command, CogGroupMixin, commands.Group):
"""Group command class for Red. """Group command class for Red.
This class inherits from `Command`, with :class:`GroupMixin` and This class inherits from `Command`, with :class:`GroupMixin` and
@ -222,7 +384,7 @@ class Group(GroupMixin, Command, commands.Group):
self.autohelp = kwargs.pop("autohelp", True) self.autohelp = kwargs.pop("autohelp", True)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
async def invoke(self, ctx): async def invoke(self, ctx: "Context"):
view = ctx.view view = ctx.view
previous = view.index previous = view.index
view.skip_ws() view.skip_ws()
@ -247,7 +409,12 @@ class Group(GroupMixin, Command, commands.Group):
await super().invoke(ctx) await super().invoke(ctx)
# decorators class Cog(CogCommandMixin, CogGroupMixin):
"""Base class for a cog."""
@property
def all_commands(self) -> Dict[str, Command]:
return {cmd.name: cmd for cmd in self.__dict__.values() if isinstance(cmd, Command)}
def command(name=None, cls=Command, **attrs): def command(name=None, cls=Command, **attrs):

View File

@ -4,8 +4,9 @@ from typing import Iterable, List
import discord import discord
from discord.ext import commands from discord.ext import commands
from redbot.core.utils.chat_formatting import box from .requires import PermState
from redbot.core.utils import common_filters from ..utils.chat_formatting import box
from ..utils import common_filters
TICK = "\N{WHITE HEAVY CHECK MARK}" TICK = "\N{WHITE HEAVY CHECK MARK}"
@ -20,6 +21,10 @@ class Context(commands.Context):
This class inherits from `discord.ext.commands.Context`. This class inherits from `discord.ext.commands.Context`.
""" """
def __init__(self, **attrs):
super().__init__(**attrs)
self.permission_state: PermState = PermState.NORMAL
async def send(self, content=None, **kwargs): async def send(self, content=None, **kwargs):
"""Sends a message to the destination with the content given. """Sends a message to the destination with the content given.

View File

@ -0,0 +1,41 @@
import re
from typing import TYPE_CHECKING
import discord
from . import BadArgument
from ..i18n import Translator
if TYPE_CHECKING:
from .context import Context
__all__ = ["GuildConverter"]
_ = Translator("commands.converter", __file__)
ID_REGEX = re.compile(r"([0-9]{15,21})")
class GuildConverter(discord.Guild):
"""Converts to a `discord.Guild` object.
The lookup strategy is as follows (in order):
1. Lookup by ID.
2. Lookup by name.
"""
@classmethod
async def convert(cls, ctx: "Context", argument: str) -> discord.Guild:
match = ID_REGEX.fullmatch(argument)
if match is None:
ret = discord.utils.get(ctx.bot.guilds, name=argument)
else:
guild_id = int(match.group(1))
ret = ctx.bot.get_guild(guild_id)
if ret is None:
raise BadArgument(_('Server "{name}" not found.').format(name=argument))
return ret

View File

@ -1,8 +1,9 @@
"""Errors module for the commands package.""" """Errors module for the commands package."""
import inspect import inspect
import discord
from discord.ext import commands from discord.ext import commands
__all__ = ["ConversionFailure"] __all__ = ["ConversionFailure", "BotMissingPermissions"]
class ConversionFailure(commands.BadArgument): class ConversionFailure(commands.BadArgument):
@ -13,3 +14,11 @@ class ConversionFailure(commands.BadArgument):
self.argument = argument self.argument = argument
self.param = param self.param = param
super().__init__(*args) super().__init__(*args)
class BotMissingPermissions(commands.CheckFailure):
"""Raised if the bot is missing permissions required to run a command."""
def __init__(self, missing: discord.Permissions, *args):
self.missing: discord.Permissions = missing
super().__init__(*args)

View File

@ -0,0 +1,668 @@
"""
commands.requires
=================
This module manages the logic of resolving command permissions and
requirements. This includes rules which override those requirements,
as well as custom checks which can be overriden, and some special
checks like bot permissions checks.
"""
import asyncio
import enum
from typing import (
Union,
Optional,
List,
Callable,
Awaitable,
Dict,
Any,
TYPE_CHECKING,
TypeVar,
Tuple,
)
import discord
from .converter import GuildConverter
from .errors import BotMissingPermissions
if TYPE_CHECKING:
from .commands import Command
from .context import Context
_CommandOrCoro = TypeVar("_CommandOrCoro", Callable[..., Awaitable[Any]], Command)
__all__ = [
"CheckPredicate",
"DM_PERMS",
"GlobalPermissionModel",
"GuildPermissionModel",
"PermissionModel",
"PrivilegeLevel",
"PermState",
"Requires",
"permissions_check",
"bot_has_permissions",
"has_permissions",
"is_owner",
"guildowner",
"guildowner_or_permissions",
"admin",
"admin_or_permissions",
"mod",
"mod_or_permissions",
]
_T = TypeVar("_T")
GlobalPermissionModel = Union[
discord.User,
discord.VoiceChannel,
discord.TextChannel,
discord.CategoryChannel,
discord.Role,
GuildConverter, # Unfortunately this will have to do for now
]
GuildPermissionModel = Union[
discord.Member,
discord.VoiceChannel,
discord.TextChannel,
discord.CategoryChannel,
discord.Role,
GuildConverter,
]
PermissionModel = Union[GlobalPermissionModel, GuildPermissionModel]
CheckPredicate = Callable[["Context"], Union[Optional[bool], Awaitable[Optional[bool]]]]
# Here we are trying to model DM permissions as closely as possible. The only
# discrepancy I've found is that users can pin messages, but they cannot delete them.
# This means manage_messages is only half True, so it's left as False.
# This is also the same as the permissions returned when `permissions_for` is used in DM.
DM_PERMS = discord.Permissions.none()
DM_PERMS.update(
add_reactions=True,
attach_files=True,
embed_links=True,
external_emojis=True,
mention_everyone=True,
read_message_history=True,
read_messages=True,
send_messages=True,
)
class PrivilegeLevel(enum.IntEnum):
"""Enumeration for special privileges."""
NONE = enum.auto()
"""No special privilege level."""
MOD = enum.auto()
"""User has the mod role."""
ADMIN = enum.auto()
"""User has the admin role."""
GUILD_OWNER = enum.auto()
"""User is the guild level."""
BOT_OWNER = enum.auto()
"""User is a bot owner."""
@classmethod
async def from_ctx(cls, ctx: "Context") -> "PrivilegeLevel":
"""Get a command author's PrivilegeLevel based on context."""
if await ctx.bot.is_owner(ctx.author):
return cls.BOT_OWNER
elif ctx.guild is None:
return cls.NONE
elif ctx.author == ctx.guild.owner:
return cls.GUILD_OWNER
# The following is simply an optimised way to check if the user has the
# admin or mod role.
guild_settings = ctx.bot.db.guild(ctx.guild)
admin_role_id = await guild_settings.admin_role()
mod_role_id = await guild_settings.mod_role()
is_mod = False
for role in ctx.author.roles:
if role.id == admin_role_id:
return cls.ADMIN
elif role.id == mod_role_id:
is_mod = True
if is_mod:
return cls.MOD
return cls.NONE
def __repr__(self) -> str:
return f"<{self.__class__.__name__}.{self.name}>"
class PermState(enum.Enum):
"""Enumeration for permission states used by rules."""
ACTIVE_ALLOW = enum.auto()
"""This command has been actively allowed, default user checks
should be ignored.
"""
NORMAL = enum.auto()
"""No overrides have been set for this command, make determination
from default user checks.
"""
PASSIVE_ALLOW = enum.auto()
"""There exists a subcommand in the `ACTIVE_ALLOW` state, continue
down the subcommand tree until we either find it or realise we're
on the wrong branch.
"""
CAUTIOUS_ALLOW = enum.auto()
"""This command has been actively denied, but there exists a
subcommand in the `ACTIVE_ALLOW` state. This occurs when
`PASSIVE_ALLOW` and `ACTIVE_DENY` are combined.
"""
ACTIVE_DENY = enum.auto()
"""This command has been actively denied, terminate the command
chain.
"""
def transition_to(
self, next_state: "PermState"
) -> Tuple[Optional[bool], Union["PermState", Dict[bool, "PermState"]]]:
return self.TRANSITIONS[self][next_state]
@classmethod
def from_bool(cls, value: Optional[bool]) -> "PermState":
"""Get a PermState from a bool or ``NoneType``."""
if value is True:
return cls.ACTIVE_ALLOW
elif value is False:
return cls.ACTIVE_DENY
else:
return cls.NORMAL
def __repr__(self) -> str:
return f"<{self.__class__.__name__}.{self.name}>"
# Here we're defining how we transition between states.
# The dict is in the form:
# previous state -> this state -> Tuple[override, next state]
# "override" is a bool describing whether or not the command should be
# invoked. It can be None, in which case the default permission checks
# will be used instead.
# There is also one case where the "next state" is dependent on the
# result of the default permission checks - the transition from NORMAL
# to PASSIVE_ALLOW. In this case "next state" is a dict mapping the
# permission check results to the actual next state.
PermState.TRANSITIONS = {
PermState.ACTIVE_ALLOW: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (True, PermState.ACTIVE_ALLOW),
PermState.PASSIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.CAUTIOUS_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
PermState.NORMAL: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (None, PermState.NORMAL),
PermState.PASSIVE_ALLOW: (True, {True: PermState.NORMAL, False: PermState.PASSIVE_ALLOW}),
PermState.CAUTIOUS_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
PermState.PASSIVE_ALLOW: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (False, PermState.NORMAL),
PermState.PASSIVE_ALLOW: (True, PermState.PASSIVE_ALLOW),
PermState.CAUTIOUS_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
PermState.CAUTIOUS_ALLOW: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (False, PermState.ACTIVE_DENY),
PermState.PASSIVE_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.CAUTIOUS_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
PermState.ACTIVE_DENY: { # We can only start from ACTIVE_DENY if it is set on a cog.
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW), # Should never happen
PermState.NORMAL: (False, PermState.ACTIVE_DENY),
PermState.PASSIVE_ALLOW: (False, PermState.ACTIVE_DENY), # Should never happen
PermState.CAUTIOUS_ALLOW: (False, PermState.ACTIVE_DENY), # Should never happen
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
}
PermState.ALLOWED_STATES = (
PermState.ACTIVE_ALLOW,
PermState.PASSIVE_ALLOW,
PermState.CAUTIOUS_ALLOW,
)
class Requires:
"""This class describes the requirements for executing a specific command.
The permissions described include both bot permissions and user
permissions.
Attributes
----------
checks : List[Callable[[Context], Union[bool, Awaitable[bool]]]]
A list of checks which can be overridden by rules. Use
`Command.checks` if you would like them to never be overridden.
privilege_level : PrivilegeLevel
The required privilege level (bot owner, admin, etc.) for users
to execute the command. Can be ``None``, in which case the
`user_perms` will be used exclusively, otherwise, for levels
other than bot owner, the user can still run the command if
they have the required `user_perms`.
user_perms : Optional[discord.Permissions]
The required permissions for users to execute the command. Can
be ``None``, in which case the `privilege_level` will be used
exclusively, otherwise, it will pass whether the user has the
required `privilege_level` _or_ `user_perms`.
bot_perms : discord.Permissions
The required bot permissions for a command to be executed. This
is not overrideable by other conditions.
"""
def __init__(
self,
privilege_level: Optional[PrivilegeLevel],
user_perms: Union[Dict[str, bool], discord.Permissions, None],
bot_perms: Union[Dict[str, bool], discord.Permissions],
checks: List[CheckPredicate],
):
self.checks: List[CheckPredicate] = checks
self.privilege_level: Optional[PrivilegeLevel] = privilege_level
if isinstance(user_perms, dict):
self.user_perms: Optional[discord.Permissions] = discord.Permissions.none()
self.user_perms.update(**user_perms)
else:
self.user_perms = user_perms
if isinstance(bot_perms, dict):
self.bot_perms: discord.Permissions = discord.Permissions.none()
self.bot_perms.update(**bot_perms)
else:
self.bot_perms = bot_perms
self.default_global_rule: PermState = PermState.NORMAL
self._global_rules: _IntKeyDict[PermState] = _IntKeyDict()
self._default_guild_rules: _IntKeyDict[PermState] = _IntKeyDict()
self._guild_rules: _IntKeyDict[_IntKeyDict[PermState]] = _IntKeyDict()
@staticmethod
def get_decorator(
privilege_level: Optional[PrivilegeLevel], user_perms: Dict[str, bool]
) -> Callable[["_CommandOrCoro"], "_CommandOrCoro"]:
if not user_perms:
user_perms = None
def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
if asyncio.iscoroutinefunction(func):
func.__requires_privilege_level__ = privilege_level
func.__requires_user_perms__ = user_perms
else:
func.requires.privilege_level = privilege_level
if user_perms is None:
func.requires.user_perms = None
else:
func.requires.user_perms.update(**user_perms)
return func
return decorator
def get_rule(self, model: Union[int, PermissionModel], guild_id: int) -> PermState:
"""Get the rule for a particular model.
Parameters
----------
model : PermissionModel
The model to get the rule for.
guild_id : int
The ID of the guild for the rule's scope. Set to ``0``
for a global rule.
Returns
-------
PermState
The state for this rule. See the `PermState` class
for an explanation.
"""
if not isinstance(model, int):
model = model.id
if guild_id:
rules = self._guild_rules.get(guild_id, _IntKeyDict())
else:
rules = self._global_rules
return rules.get(model, PermState.NORMAL)
def set_rule(self, model_id: int, rule: PermState, guild_id: int) -> None:
"""Set the rule for a particular model.
Parameters
----------
model_id : PermissionModel
The model to add a rule for.
rule : PermState
Which state this rule should be set as. See the `PermState`
class for an explanation.
guild_id : int
The ID of the guild for the rule's scope. Set to ``0``
for a global rule.
"""
if guild_id:
rules = self._guild_rules.setdefault(guild_id, _IntKeyDict())
else:
rules = self._global_rules
if rule is PermState.NORMAL:
rules.pop(model_id, None)
else:
rules[model_id] = rule
def clear_all_rules(self, guild_id: int) -> None:
"""Clear all rules of a particular scope.
Parameters
----------
guild_id : int
The guild ID to clear rules for. If ``0``, this will
clear all global rules and leave all guild rules
untouched.
"""
if guild_id:
rules = self._guild_rules.setdefault(guild_id, _IntKeyDict())
else:
rules = self._global_rules
rules.clear()
def get_default_guild_rule(self, guild_id: int) -> PermState:
"""Get the default rule for a guild."""
return self._default_guild_rules.get(guild_id, PermState.NORMAL)
def set_default_guild_rule(self, guild_id: int, rule: PermState) -> None:
"""Set the default rule for a guild."""
self._default_guild_rules[guild_id] = rule
async def verify(self, ctx: "Context") -> bool:
"""Check if the given context passes the requirements.
This will check the bot permissions, overrides, user permissions
and privilege level.
Parameters
----------
ctx : "Context"
The invkokation context to check with.
Returns
-------
bool
``True`` if the context passes the requirements.
Raises
------
BotMissingPermissions
If the bot is missing required permissions to run the
command.
CommandError
Propogated from any permissions checks.
"""
await self._verify_bot(ctx)
# Owner-only commands are non-overrideable
if self.privilege_level is PrivilegeLevel.BOT_OWNER:
return await ctx.bot.is_owner(ctx.author)
hook_result = await ctx.bot.verify_permissions_hooks(ctx)
if hook_result is not None:
return hook_result
return await self._transition_state(ctx)
async def _verify_bot(self, ctx: "Context") -> None:
if ctx.guild is None:
bot_user = ctx.bot.user
else:
bot_user = ctx.guild.me
bot_perms = ctx.channel.permissions_for(bot_user)
if not (bot_perms.administrator or bot_perms >= self.bot_perms):
raise BotMissingPermissions(missing=self._missing_perms(self.bot_perms, bot_perms))
async def _transition_state(self, ctx: "Context") -> bool:
prev_state = ctx.permission_state
cur_state = self._get_rule_from_ctx(ctx)
should_invoke, next_state = prev_state.transition_to(cur_state)
if should_invoke is None:
# NORMAL invokation, we simply follow standard procedure
should_invoke = await self._verify_user(ctx)
elif isinstance(next_state, dict):
# NORMAL to PASSIVE_ALLOW; should we proceed as normal or transition?
next_state = next_state[await self._verify_user(ctx)]
ctx.permission_state = next_state
return should_invoke
async def _verify_user(self, ctx: "Context") -> bool:
checks_pass = await self._verify_checks(ctx)
if checks_pass is False:
return False
if self.user_perms is not None:
user_perms = ctx.channel.permissions_for(ctx.author)
if user_perms.administrator or user_perms >= self.user_perms:
return True
if self.privilege_level is not None:
privilege_level = await PrivilegeLevel.from_ctx(ctx)
if privilege_level >= self.privilege_level:
return True
return False
def _get_rule_from_ctx(self, ctx: "Context") -> PermState:
author = ctx.author
guild = ctx.guild
if ctx.guild is None:
# We only check the user for DM channels
rule = self._global_rules.get(author.id)
if rule is not None:
return rule
return self.default_global_rule
rules_chain = [self._global_rules]
guild_rules = self._guild_rules.get(ctx.guild.id)
if guild_rules:
rules_chain.append(guild_rules)
channels = []
if author.voice is not None:
channels.append(author.voice.channel)
channels.append(ctx.channel)
category = ctx.channel.category
if category is not None:
channels.append(category)
model_chain = [author, *channels, *author.roles, guild]
for rules in rules_chain:
for model in model_chain:
rule = rules.get(model.id)
if rule is not None:
return rule
del model_chain[-1] # We don't check for the guild in guild rules
default_rule = self.get_default_guild_rule(guild.id)
if default_rule is PermState.NORMAL:
default_rule = self.default_global_rule
return default_rule
async def _verify_checks(self, ctx: "Context") -> bool:
if not self.checks:
return True
return await discord.utils.async_all(check(ctx) for check in self.checks)
@staticmethod
def _get_perms_for(ctx: "Context", user: discord.abc.User) -> discord.Permissions:
if ctx.guild is None:
return DM_PERMS
else:
return ctx.channel.permissions_for(user)
@classmethod
def _get_bot_perms(cls, ctx: "Context") -> discord.Permissions:
return cls._get_perms_for(ctx, ctx.guild.me if ctx.guild else ctx.bot.user)
@staticmethod
def _missing_perms(
required: discord.Permissions, actual: discord.Permissions
) -> discord.Permissions:
# Explained in set theory terms:
# Assuming R is the set of required permissions, and A is
# the set of the user's permissions, the set of missing
# permissions will be equal to R \ A, i.e. the relative
# complement/difference of A with respect to R.
relative_complement = required.value & ~actual.value
return discord.Permissions(relative_complement)
@staticmethod
def _member_as_user(member: discord.abc.User) -> discord.User:
if isinstance(member, discord.Member):
# noinspection PyProtectedMember
return member._user
return member
def __repr__(self) -> str:
return (
f"<Requires privilege_level={self.privilege_level!r} user_perms={self.user_perms!r} "
f"bot_perms={self.bot_perms!r}>"
)
# check decorators
def permissions_check(predicate: CheckPredicate):
"""An overwriteable version of `discord.ext.commands.check`.
This has the same behaviour as `discord.ext.commands.check`,
however this check can be ignored if the command is allowed
through a permissions cog.
"""
def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
if hasattr(func, "requires"):
func.requires.checks.append(predicate)
else:
if not hasattr(func, "__requires_checks__"):
func.__requires_checks__ = []
# noinspection PyUnresolvedReferences
func.__requires_checks__.append(predicate)
return func
return decorator
def bot_has_permissions(**perms: bool):
"""Complain if the bot is missing permissions.
If the user tries to run the command, but the bot is missing the
permissions, it will send a message describing which permissions
are missing.
This check cannot be overridden by rules.
"""
def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
if asyncio.iscoroutinefunction(func):
func.__requires_bot_perms__ = perms
else:
func.requires.bot_perms.update(**perms)
return func
return decorator
def has_permissions(**perms: bool):
"""Restrict the command to users with these permissions.
This check can be overridden by rules.
"""
return Requires.get_decorator(None, perms)
def is_owner():
"""Restrict the command to bot owners.
This check cannot be overridden by rules.
"""
return Requires.get_decorator(PrivilegeLevel.BOT_OWNER, {})
def guildowner_or_permissions(**perms: bool):
"""Restrict the command to the guild owner or users with these permissions.
This check can be overridden by rules.
"""
return Requires.get_decorator(PrivilegeLevel.GUILD_OWNER, perms)
def guildowner():
"""Restrict the command to the guild owner.
This check can be overridden by rules.
"""
return guildowner_or_permissions()
def admin_or_permissions(**perms: bool):
"""Restrict the command to users with the admin role or these permissions.
This check can be overridden by rules.
"""
return Requires.get_decorator(PrivilegeLevel.ADMIN, perms)
def admin():
"""Restrict the command to users with the admin role.
This check can be overridden by rules.
"""
return admin_or_permissions()
def mod_or_permissions(**perms: bool):
"""Restrict the command to users with the mod role or these permissions.
This check can be overridden by rules.
"""
return Requires.get_decorator(PrivilegeLevel.MOD, perms)
def mod():
"""Restrict the command to users with the mod role.
This check can be overridden by rules.
"""
return mod_or_permissions()
class _IntKeyDict(Dict[int, _T]):
"""Dict subclass which throws KeyError when a non-int key is used."""
def __getitem__(self, key: Any) -> _T:
if not isinstance(key, int):
raise TypeError("Keys must be of type `int`")
return super().__getitem__(key)
def __setitem__(self, key: Any, value: _T) -> None:
if not isinstance(key, int):
raise TypeError("Keys must be of type `int`")
return super().__setitem__(key, value)

View File

@ -241,7 +241,7 @@ class CoreLogic:
@i18n.cog_i18n(_) @i18n.cog_i18n(_)
class Core(CoreLogic): class Core(commands.Cog, CoreLogic):
"""Commands related to core functions""" """Commands related to core functions"""
def __init__(self, bot): def __init__(self, bot):

View File

@ -25,10 +25,11 @@ _ = Translator("Dev", __file__)
START_CODE_BLOCK_RE = re.compile(r"^((```py)(?=\s)|(```))") START_CODE_BLOCK_RE = re.compile(r"^((```py)(?=\s)|(```))")
class Dev: class Dev(commands.Cog):
"""Various development focused utilities.""" """Various development focused utilities."""
def __init__(self): def __init__(self):
super().__init__()
self._last_result = None self._last_result = None
self.sessions = set() self.sessions = set()

View File

@ -5,6 +5,7 @@ import logging
import traceback import traceback
from datetime import timedelta from datetime import timedelta
from distutils.version import StrictVersion from distutils.version import StrictVersion
from typing import List
import aiohttp import aiohttp
import discord import discord
@ -14,7 +15,7 @@ from pkg_resources import DistributionNotFound
from . import __version__, commands from . import __version__, commands
from .data_manager import storage_type from .data_manager import storage_type
from .utils.chat_formatting import inline, bordered from .utils.chat_formatting import inline, bordered, humanize_list
from .utils import fuzzy_command_search, format_fuzzy_results from .utils import fuzzy_command_search, format_fuzzy_results
log = logging.getLogger("red") log = logging.getLogger("red")
@ -67,6 +68,14 @@ def init_events(bot, cli_flags):
packages.extend(cli_flags.load_cogs) packages.extend(cli_flags.load_cogs)
if packages: if packages:
# Load permissions first, for security reasons
try:
packages.remove("permissions")
except ValueError:
pass
else:
packages.insert(0, "permissions")
to_remove = [] to_remove = []
print("Loading packages...") print("Loading packages...")
for package in packages: for package in packages:
@ -227,6 +236,21 @@ def init_events(bot, cli_flags):
await ctx.send(embed=await format_fuzzy_results(ctx, fuzzy_commands, embed=True)) await ctx.send(embed=await format_fuzzy_results(ctx, fuzzy_commands, embed=True))
else: else:
await ctx.send(await format_fuzzy_results(ctx, fuzzy_commands, embed=False)) await ctx.send(await format_fuzzy_results(ctx, fuzzy_commands, embed=False))
elif isinstance(error, commands.BotMissingPermissions):
missing_perms: List[str] = []
for perm, value in error.missing:
if value is True:
perm_name = '"' + perm.replace("_", " ").title() + '"'
missing_perms.append(perm_name)
if len(missing_perms) == 1:
plural = ""
else:
plural = "s"
await ctx.send(
"I require the {perms} permission{plural} to execute that command.".format(
perms=humanize_list(missing_perms), plural=plural
)
)
elif isinstance(error, commands.CheckFailure): elif isinstance(error, commands.CheckFailure):
pass pass
elif isinstance(error, commands.NoPrivateMessage): elif isinstance(error, commands.NoPrivateMessage):

View File

@ -3,7 +3,7 @@ from . import commands
def init_global_checks(bot): def init_global_checks(bot):
@bot.check @bot.check_once
async def global_perms(ctx): async def global_perms(ctx):
"""Check the user is/isn't globally whitelisted/blacklisted.""" """Check the user is/isn't globally whitelisted/blacklisted."""
if await bot.is_owner(ctx.author): if await bot.is_owner(ctx.author):
@ -15,7 +15,7 @@ def init_global_checks(bot):
return ctx.author.id not in await bot.db.blacklist() return ctx.author.id not in await bot.db.blacklist()
@bot.check @bot.check_once
async def local_perms(ctx: commands.Context): async def local_perms(ctx: commands.Context):
"""Check the user is/isn't locally whitelisted/blacklisted.""" """Check the user is/isn't locally whitelisted/blacklisted."""
if await bot.is_owner(ctx.author): if await bot.is_owner(ctx.author):
@ -33,7 +33,7 @@ def init_global_checks(bot):
return not any(i in local_blacklist for i in _ids) return not any(i in local_blacklist for i in _ids)
@bot.check @bot.check_once
async def bots(ctx): async def bots(ctx):
"""Check the user is not another bot.""" """Check the user is not another bot."""
return not ctx.author.bot return not ctx.author.bot

View File

@ -1,5 +1,8 @@
import itertools import itertools
from typing import Sequence, Iterator from typing import Sequence, Iterator, List
from redbot.core.i18n import Translator
_ = Translator("UtilsChatFormatting", __file__)
def error(text: str) -> str: def error(text: str) -> str:
@ -317,3 +320,33 @@ def escape(text: str, *, mass_mentions: bool = False, formatting: bool = False)
if formatting: if formatting:
text = text.replace("`", "\\`").replace("*", "\\*").replace("_", "\\_").replace("~", "\\~") text = text.replace("`", "\\`").replace("*", "\\*").replace("_", "\\_").replace("~", "\\~")
return text return text
def humanize_list(items: Sequence[str]):
"""Get comma-separted list, with the last element joined with *and*.
This uses an Oxford comma, because without one, items containing
the word *and* would make the output difficult to interpret.
Parameters
----------
items : Sequence[str]
The items of the list to join together.
Examples
--------
.. testsetup::
from redbot.core.utils.chat_formatting import humanize_list
.. doctest::
>>> humanize_list(['One', 'Two', 'Three'])
'One, Two, and Three'
>>> humanize_list(['One'])
'One'
"""
if len(items) == 1:
return items[0]
return ", ".join(items[:-1]) + _(", and ") + items[-1]

View File

@ -1,11 +1,13 @@
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
from typing import List, Iterable, Union from typing import List, Iterable, Union, TYPE_CHECKING, Dict
import discord import discord
from redbot.core import Config if TYPE_CHECKING:
from redbot.core.bot import Red from .. import Config
from ..bot import Red
from ..commands import Context
async def mass_purge(messages: List[discord.Message], channel: discord.TextChannel): async def mass_purge(messages: List[discord.Message], channel: discord.TextChannel):
@ -87,7 +89,7 @@ def get_audit_reason(author: discord.Member, reason: str = None):
async def is_allowed_by_hierarchy( async def is_allowed_by_hierarchy(
bot: Red, settings: Config, guild: discord.Guild, mod: discord.Member, user: discord.Member bot: "Red", settings: "Config", guild: discord.Guild, mod: discord.Member, user: discord.Member
): ):
if not await settings.guild(guild).respect_hierarchy(): if not await settings.guild(guild).respect_hierarchy():
return True return True
@ -95,7 +97,9 @@ async def is_allowed_by_hierarchy(
return mod.top_role.position > user.top_role.position or is_special return mod.top_role.position > user.top_role.position or is_special
async def is_mod_or_superior(bot: Red, obj: Union[discord.Message, discord.Member, discord.Role]): async def is_mod_or_superior(
bot: "Red", obj: Union[discord.Message, discord.Member, discord.Role]
):
"""Check if an object has mod or superior permissions. """Check if an object has mod or superior permissions.
If a message is passed, its author's permissions are checked. If a role is If a message is passed, its author's permissions are checked. If a role is
@ -179,7 +183,7 @@ def strfdelta(delta: timedelta):
async def is_admin_or_superior( async def is_admin_or_superior(
bot: Red, obj: Union[discord.Message, discord.Member, discord.Role] bot: "Red", obj: Union[discord.Message, discord.Member, discord.Role]
): ):
"""Same as `is_mod_or_superior` except for admin permissions. """Same as `is_mod_or_superior` except for admin permissions.
@ -225,3 +229,36 @@ async def is_admin_or_superior(
return True return True
else: else:
return False return False
async def check_permissions(ctx: "Context", perms: Dict[str, bool]) -> bool:
"""Check if the author has required permissions.
This will always return ``True`` if the author is a bot owner, or
has the ``administrator`` permission. If ``perms`` is empty, this
will only check if the user is a bot owner.
Parameters
----------
ctx : Context
The command invokation context to check.
perms : Dict[str, bool]
A dictionary mapping permissions to their required states.
Valid permission names are those listed as properties of
the `discord.Permissions` class.
Returns
-------
bool
``True`` if the author has the required permissions.
"""
if await ctx.bot.is_owner(ctx.author):
return True
elif not perms:
return False
resolved = ctx.channel.permissions_for(ctx.author)
return resolved.administrator or all(
getattr(resolved, name, None) == value for name, value in perms.items()
)

View File

@ -0,0 +1,11 @@
import pytest
from redbot.cogs.permissions import Permissions
from redbot.core import Config
@pytest.fixture()
def permissions(config, monkeypatch, red):
with monkeypatch.context() as m:
m.setattr(Config, "get_conf", lambda *args, **kwargs: config)
return Permissions(red)

View File

@ -23,6 +23,7 @@ requirements = [
"pyyaml==3.13", "pyyaml==3.13",
"raven==6.9.0", "raven==6.9.0",
"raven-aiohttp==0.7.0", "raven-aiohttp==0.7.0",
"schema==0.6.8",
"websockets==6.0", "websockets==6.0",
"yarl==1.2.6", "yarl==1.2.6",
] ]

View File

@ -0,0 +1,67 @@
from redbot.cogs.permissions.permissions import Permissions, GLOBAL
def test_schema_update():
old = {
GLOBAL: {
"owner_models": {
"cogs": {
"Admin": {"allow": [78631113035100160], "deny": [96733288462286848]},
"Audio": {"allow": [133049272517001216], "default": "deny"},
},
"commands": {
"cleanup bot": {"allow": [78631113035100160], "default": "deny"},
"ping": {
"allow": [96733288462286848],
"deny": [96733288462286848],
"default": "allow",
},
},
}
},
43733288462286848: {
"owner_models": {
"cogs": {
"Admin": {
"allow": [24231113035100160],
"deny": [35533288462286848, 24231113035100160],
},
"General": {"allow": [133049272517001216], "default": "deny"},
},
"commands": {
"cleanup bot": {"allow": [17831113035100160], "default": "allow"},
"set adminrole": {
"allow": [87733288462286848],
"deny": [95433288462286848],
"default": "allow",
},
},
}
},
}
new = Permissions._get_updated_schema(old)
assert new == (
{
"Admin": {
GLOBAL: {78631113035100160: True, 96733288462286848: False},
43733288462286848: {24231113035100160: True, 35533288462286848: False},
},
"Audio": {GLOBAL: {133049272517001216: True, "default": False}},
"General": {43733288462286848: {133049272517001216: True, "default": False}},
},
{
"cleanup bot": {
GLOBAL: {78631113035100160: True, "default": False},
43733288462286848: {17831113035100160: True, "default": True},
},
"ping": {GLOBAL: {96733288462286848: True, "default": True}},
"set adminrole": {
43733288462286848: {
87733288462286848: True,
95433288462286848: False,
"default": True,
}
},
},
)

View File

@ -101,7 +101,7 @@ async def test_bounded_gather():
if isinstance(result, RuntimeError): if isinstance(result, RuntimeError):
num_failed += 1 num_failed += 1
else: else:
assert result == i # verify original orde assert result == i # verify_permissions original orde
assert 0 <= result < num_tasks assert 0 <= result < num_tasks
assert 0 < status[1] <= num_concurrent assert 0 < status[1] <= num_concurrent

View File

@ -29,8 +29,9 @@ whitelist_externals =
basepython = python3.6 basepython = python3.6
extras = docs, mongo extras = docs, mongo
commands = commands =
sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" -W -bhtml sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out/html" -W -bhtml
sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" -W -blinkcheck sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out/linkcheck" -W -blinkcheck
sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out/doctest" -W -bdoctest
[testenv:style] [testenv:style]
description = Stylecheck the code with black to see if anything needs changes. description = Stylecheck the code with black to see if anything needs changes.