diff --git a/Pipfile.lock b/Pipfile.lock index 02280a53e..c27d841ce 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -236,6 +236,13 @@ ], "version": "==0.1.2" }, + "schema": { + "hashes": [ + "sha256:d994b0dc4966000037b26898df638e3e2a694cc73636cb2050e652614a350687", + "sha256:fa1a53fe5f3b6929725a4e81688c250f46838e25d8c1885a10a590c8c01a7b74" + ], + "version": "==0.6.8" + }, "websockets": { "hashes": [ "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'", "version": "==2.19.1" }, + "schema": { + "hashes": [ + "sha256:d994b0dc4966000037b26898df638e3e2a694cc73636cb2050e652614a350687", + "sha256:fa1a53fe5f3b6929725a4e81688c250f46838e25d8c1885a10a590c8c01a7b74" + ], + "version": "==0.6.8" + }, "six": { "hashes": [ "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", diff --git a/docs/cog_permissions.rst b/docs/cog_permissions.rst index dfcd5d6cd..01cfcb79e 100644 --- a/docs/cog_permissions.rst +++ b/docs/cog_permissions.rst @@ -8,100 +8,90 @@ Permissions Cog Reference How it works ------------ -When loaded, the permissions cog will allow you -to define extra custom rules for who can use a command +When loaded, the permissions cog will allow you to define extra custom rules for who can use a +command. -If no applicable rules are found, the command will behave as if -the cog was not loaded. +If no applicable rules are found, the command will behave normally. + +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 ------------- -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 - 2. Owner level cog specific settings - 3. Server level command specific settings - 4. Server level cog specific settings +For each of those, the first rule pertaining to one of the following models will be used: -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 - 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. +In private messages, only global rules about a user will be checked. ------------------------- -Setting Rules from a file +Setting Rules From a File ------------------------- -The permissions cog can set rules from a yaml file: -All entries are based on ID. -An example of the expected format is shown below. +The permissions cog can also set, display or update rules with a YAML file with the +``[p]permissions yaml`` command. Models must be represented by ID. Rules must be ``true`` for +allow, or ``false`` for deny. Here is an example: .. code-block:: yaml - cogs: + COG: Admin: - allow: - - 78631113035100160 - deny: - - 96733288462286848 + 78631113035100160: true + 96733288462286848: false Audio: - allow: - - 133049272517001216 - default: deny - commands: + 133049272517001216: true + default: false + COMMAND: cleanup bot: - allow: - - 78631113035100160 - default: deny + 78631113035100160: true + default: false ping: - deny: - - 96733288462286848 - default: allow + 96733288462286848: false + default: true ---------------------- 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 - [p]permissions setglobaldefault Audio deny - [p]permissions addglobalrule allow Audio [server ID or name] + [p]permissions setglobaldefault play deny + [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 - [p]permissions setguilddefault deny play - [p]permissions setguilddefault deny "playlist start" - [p]permissions addguildrule allow play [voice channel ID or name] - [p]permissions addguildrule allow "playlist start" [voice channel ID or name] + [p]permissions setserverdefault deny play + [p]permissions setserverdefault deny "playlist start" + [p]permissions addserverrule allow play [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 - [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 - [p]permissions addguildrule deny Cleanup [channel ID or mention] + [p]permissions addserverrule deny cleanup [channel ID or mention] diff --git a/docs/conf.py b/docs/conf.py index 6a1d10b5d..4df8d2b9d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,6 +39,7 @@ extensions = [ "sphinx.ext.intersphinx", "sphinx.ext.viewcode", "sphinx.ext.napoleon", + "sphinx.ext.doctest", "sphinxcontrib.asyncio", ] @@ -197,9 +198,16 @@ texinfo_documents = [ linkcheck_ignore = [r"https://java.com*"] -# Example configuration for intersphinx: refer to the Python standard library. +# -- Options for extensions ----------------------------------------------- + +# Intersphinx intersphinx_mapping = { "python": ("https://docs.python.org/3.6", None), "dpy": ("https://discordpy.readthedocs.io/en/rewrite/", 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 = "" diff --git a/docs/framework_checks.rst b/docs/framework_checks.rst new file mode 100644 index 000000000..13ecc6e05 --- /dev/null +++ b/docs/framework_checks.rst @@ -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: diff --git a/docs/framework_commands.rst b/docs/framework_commands.rst index d07a91b0a..5501cd03e 100644 --- a/docs/framework_commands.rst +++ b/docs/framework_commands.rst @@ -21,3 +21,6 @@ extend functionlities used throughout the bot, as outlined below. .. autoclass:: redbot.core.commands.Context :members: + +.. automodule:: redbot.core.commands.requires + :members: PrivilegeLevel, PermState, Requires diff --git a/docs/index.rst b/docs/index.rst index b0efd3968..43f9cda67 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,14 +33,15 @@ Welcome to Red - Discord Bot's documentation! guide_data_conversion framework_bank framework_bot + framework_checks framework_cogmanager + framework_commands framework_config framework_datamanager framework_downloader framework_events framework_i18n framework_modlog - framework_commands framework_rpc framework_utils diff --git a/redbot/__init__.py b/redbot/__init__.py index dfeacb806..37ee40eda 100644 --- a/redbot/__init__.py +++ b/redbot/__init__.py @@ -30,3 +30,5 @@ colorama.init() # Filter fuzzywuzzy slow sequence matcher warning warnings.filterwarnings("ignore", module=r"fuzzywuzzy.*") +# Prevent discord PyNaCl missing warning +discord.voice_client.VoiceClient.warn_nacl = False diff --git a/redbot/cogs/admin/admin.py b/redbot/cogs/admin/admin.py index 84ba7807b..18dae1029 100644 --- a/redbot/cogs/admin/admin.py +++ b/redbot/cogs/admin/admin.py @@ -38,8 +38,9 @@ RUNNING_ANNOUNCEMENT = ( ) -class Admin: +class Admin(commands.Cog): def __init__(self, config=Config): + super().__init__() self.conf = config.get_conf(self, 8237492837454039, force_registration=True) self.conf.register_global(serverlocked=False) diff --git a/redbot/cogs/alias/alias.py b/redbot/cogs/alias/alias.py index 0c8021ce6..514b6fde6 100644 --- a/redbot/cogs/alias/alias.py +++ b/redbot/cogs/alias/alias.py @@ -14,7 +14,7 @@ _ = Translator("Alias", __file__) @cog_i18n(_) -class Alias: +class Alias(commands.Cog): """ Alias @@ -31,6 +31,7 @@ class Alias: default_guild_settings = {"enabled": False, "entries": []} # Going to be a list of dicts def __init__(self, bot: Red): + super().__init__() self.bot = bot self._aliases = Config.get_conf(self, 8927348724) diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index fb799d40a..1eb4e89db 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -27,8 +27,9 @@ __author__ = ["aikaterna", "billy/bollo/ati"] @cog_i18n(_) -class Audio: +class Audio(commands.Cog): def __init__(self, bot): + super().__init__() self.bot = bot self.config = Config.get_conf(self, 2711759130, force_registration=True) diff --git a/redbot/cogs/bank/bank.py b/redbot/cogs/bank/bank.py index b662b68fb..bdb422cc7 100644 --- a/redbot/cogs/bank/bank.py +++ b/redbot/cogs/bank/bank.py @@ -54,10 +54,11 @@ def check_global_setting_admin(): @cog_i18n(_) -class Bank: +class Bank(commands.Cog): """Bank""" def __init__(self, bot: Red): + super().__init__() self.bot = bot # SECTION commands diff --git a/redbot/cogs/cleanup/cleanup.py b/redbot/cogs/cleanup/cleanup.py index 935805856..4feb4de45 100644 --- a/redbot/cogs/cleanup/cleanup.py +++ b/redbot/cogs/cleanup/cleanup.py @@ -14,10 +14,11 @@ _ = Translator("Cleanup", __file__) @cog_i18n(_) -class Cleanup: +class Cleanup(commands.Cog): """Commands for cleaning messages""" def __init__(self, bot: Red): + super().__init__() self.bot = bot @staticmethod diff --git a/redbot/cogs/customcom/customcom.py b/redbot/cogs/customcom/customcom.py index f2f903291..df947d3c5 100644 --- a/redbot/cogs/customcom/customcom.py +++ b/redbot/cogs/customcom/customcom.py @@ -172,12 +172,13 @@ class CommandObj: @cog_i18n(_) -class CustomCommands: +class CustomCommands(commands.Cog): """Custom commands Creates commands used to display text""" def __init__(self, bot): + super().__init__() self.bot = bot self.key = 414589031223512 self.config = Config.get_conf(self, self.key) diff --git a/redbot/cogs/dataconverter/dataconverter.py b/redbot/cogs/dataconverter/dataconverter.py index 3d42fe982..f2dc05cd8 100644 --- a/redbot/cogs/dataconverter/dataconverter.py +++ b/redbot/cogs/dataconverter/dataconverter.py @@ -11,12 +11,13 @@ _ = Translator("DataConverter", __file__) @cog_i18n(_) -class DataConverter: +class DataConverter(commands.Cog): """ Cog for importing Red v2 Data """ def __init__(self, bot: Red): + super().__init__() self.bot = bot @checks.is_owner() diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py index 7b459c454..f6ae79b64 100644 --- a/redbot/cogs/downloader/downloader.py +++ b/redbot/cogs/downloader/downloader.py @@ -23,8 +23,9 @@ _ = Translator("Downloader", __file__) @cog_i18n(_) -class Downloader: +class Downloader(commands.Cog): def __init__(self, bot: Red): + super().__init__() self.bot = bot self.conf = Config.get_conf(self, identifier=998240343, force_registration=True) diff --git a/redbot/cogs/economy/economy.py b/redbot/cogs/economy/economy.py index 40d376d51..0059a9bc0 100644 --- a/redbot/cogs/economy/economy.py +++ b/redbot/cogs/economy/economy.py @@ -105,7 +105,7 @@ class SetParser: @cog_i18n(_) -class Economy: +class Economy(commands.Cog): """Economy Get rich and have fun with imaginary currency!""" @@ -128,6 +128,7 @@ class Economy: default_user_settings = default_member_settings def __init__(self, bot: Red): + super().__init__() self.bot = bot self.file_path = "data/economy/settings.json" self.config = Config.get_conf(self, 1256844281) diff --git a/redbot/cogs/filter/filter.py b/redbot/cogs/filter/filter.py index e91dae5df..5e8d1ca85 100644 --- a/redbot/cogs/filter/filter.py +++ b/redbot/cogs/filter/filter.py @@ -10,10 +10,11 @@ _ = Translator("Filter", __file__) @cog_i18n(_) -class Filter: +class Filter(commands.Cog): """Filter-related commands""" def __init__(self, bot: Red): + super().__init__() self.bot = bot self.settings = Config.get_conf(self, 4766951341) default_guild_settings = { diff --git a/redbot/cogs/general/general.py b/redbot/cogs/general/general.py index 6daf50662..e2708d399 100644 --- a/redbot/cogs/general/general.py +++ b/redbot/cogs/general/general.py @@ -33,10 +33,11 @@ class RPSParser: @cog_i18n(_) -class General: +class General(commands.Cog): """General commands.""" def __init__(self): + super().__init__() self.stopwatches = {} self.ball = [ _("As I see it, yes"), diff --git a/redbot/cogs/image/image.py b/redbot/cogs/image/image.py index 167660294..f991b6459 100644 --- a/redbot/cogs/image/image.py +++ b/redbot/cogs/image/image.py @@ -11,12 +11,13 @@ GIPHY_API_KEY = "dc6zaTOxFJmzC" @cog_i18n(_) -class Image: +class Image(commands.Cog): """Image related commands.""" default_global = {"imgur_client_id": None} def __init__(self, bot): + super().__init__() self.bot = bot self.settings = Config.get_conf(self, identifier=2652104208, force_registration=True) self.settings.register_global(**self.default_global) diff --git a/redbot/cogs/mod/checks.py b/redbot/cogs/mod/checks.py index 62cc2f7da..c42cb1fe9 100644 --- a/redbot/cogs/mod/checks.py +++ b/redbot/cogs/mod/checks.py @@ -26,7 +26,7 @@ def mod_or_voice_permissions(**perms): else: return True - return commands.check(pred) + return commands.permissions_check(pred) def admin_or_voice_permissions(**perms): @@ -48,7 +48,7 @@ def admin_or_voice_permissions(**perms): else: return True - return commands.check(pred) + return commands.permissions_check(pred) def bot_has_voice_permissions(**perms): diff --git a/redbot/cogs/mod/mod.py b/redbot/cogs/mod/mod.py index 7a4e3b797..042b5110d 100644 --- a/redbot/cogs/mod/mod.py +++ b/redbot/cogs/mod/mod.py @@ -18,7 +18,7 @@ _ = Translator("Mod", __file__) @cog_i18n(_) -class Mod: +class Mod(commands.Cog): """Moderation tools.""" default_guild_settings = { @@ -38,6 +38,7 @@ class Mod: default_user_settings = {"past_names": []} def __init__(self, bot: Red): + super().__init__() self.bot = bot self.settings = Config.get_conf(self, 4961522000, force_registration=True) diff --git a/redbot/cogs/modlog/modlog.py b/redbot/cogs/modlog/modlog.py index d4f8a030d..5cdf5c894 100644 --- a/redbot/cogs/modlog/modlog.py +++ b/redbot/cogs/modlog/modlog.py @@ -9,10 +9,11 @@ _ = Translator("ModLog", __file__) @cog_i18n(_) -class ModLog: +class ModLog(commands.Cog): """Log for mod actions""" def __init__(self, bot: Red): + super().__init__() self.bot = bot @commands.group() diff --git a/redbot/cogs/permissions/__init__.py b/redbot/cogs/permissions/__init__.py index 7def5ea54..e13343eb5 100644 --- a/redbot/cogs/permissions/__init__.py +++ b/redbot/cogs/permissions/__init__.py @@ -1,5 +1,13 @@ from .permissions import Permissions -def setup(bot): - bot.add_cog(Permissions(bot)) +async def setup(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) diff --git a/redbot/cogs/permissions/converters.py b/redbot/cogs/permissions/converters.py index 904cd142b..0cfaddbc8 100644 --- a/redbot/cogs/permissions/converters.py +++ b/redbot/cogs/permissions/converters.py @@ -1,15 +1,21 @@ +from typing import NamedTuple, Union, Optional from redbot.core import commands -from typing import Tuple -class CogOrCommand(commands.Converter): - async def convert(self, ctx: commands.Context, arg: str) -> Tuple[str]: - ret = ctx.bot.get_cog(arg) - if ret: - return "cogs", ret.__class__.__name__ - ret = ctx.bot.get_command(arg) - if ret: - return "commands", ret.qualified_name +class CogOrCommand(NamedTuple): + type: str + name: str + obj: Union[commands.Command, commands.Cog] + + # noinspection PyArgumentList + @classmethod + 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( '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): - async def convert(self, ctx: commands.Context, arg: str) -> str: +class RuleType: + + # noinspection PyUnusedLocal + @classmethod + async def convert(cls, ctx: commands.Context, arg: str) -> bool: if arg.lower() in ("allow", "whitelist", "allowed"): - return "allow" + return True if arg.lower() in ("deny", "blacklist", "denied"): - return "deny" + return False raise commands.BadArgument( '"{arg}" is not a valid rule. Valid rules are "allow" or "deny"'.format(arg=arg) ) -class ClearableRuleType(commands.Converter): - async def convert(self, ctx: commands.Context, arg: str) -> str: +class ClearableRuleType: + + # noinspection PyUnusedLocal + @classmethod + async def convert(cls, ctx: commands.Context, arg: str) -> Optional[bool]: if arg.lower() in ("allow", "whitelist", "allowed"): - return "allow" + return True if arg.lower() in ("deny", "blacklist", "denied"): - return "deny" + return False if arg.lower() in ("clear", "reset"): - return "clear" + return None raise commands.BadArgument( - '"{arg}" is not a valid rule. Valid rules are "allow" or "deny", or "clear" to remove the rule' - "".format(arg=arg) + '"{arg}" is not a valid rule. Valid rules are "allow" or "deny", or "clear" to ' + "remove the rule".format(arg=arg) ) diff --git a/redbot/cogs/permissions/mass_resolution.py b/redbot/cogs/permissions/mass_resolution.py deleted file mode 100644 index 496f47011..000000000 --- a/redbot/cogs/permissions/mass_resolution.py +++ /dev/null @@ -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 diff --git a/redbot/cogs/permissions/permissions.py b/redbot/cogs/permissions/permissions.py index cb72d2a91..61e821760 100644 --- a/redbot/cogs/permissions/permissions.py +++ b/redbot/cogs/permissions/permissions.py @@ -1,221 +1,143 @@ -from copy import copy -import contextlib import asyncio +import io +import textwrap +from copy import copy +from typing import Union, Optional, Dict, List, Tuple, Any, Iterator, ItemsView + import discord -from redbot.core import commands +import yaml +from schema import And, Or, Schema, SchemaError, Optional as UseOptional +from redbot.core import checks, commands, config from redbot.core.bot import Red -from redbot.core import checks -from redbot.core.config import Config from redbot.core.i18n import Translator, cog_i18n -from redbot.core.utils.caching import LRUDict +from redbot.core.utils.chat_formatting import box -from .resolvers import val_if_check_is_valid, resolve_models, entries_from_ctx -from .yaml_handler import yamlset_acl, yamlget_acl from .converters import CogOrCommand, RuleType, ClearableRuleType -from .mass_resolution import mass_resolve - -_models = ["owner", "guildowner", "admin", "mod", "all"] _ = Translator("Permissions", __file__) +COG = "COG" +COMMAND = "COMMAND" +GLOBAL = 0 + +# noinspection PyDictDuplicateKeys REACTS = {"\N{WHITE HEAVY CHECK MARK}": True, "\N{NEGATIVE SQUARED CROSS MARK}": False} Y_OR_N = {"y": True, "yes": True, "n": False, "no": False} +# The strings in the schema are constants and should get extracted, but not translated until +# runtime. +translate = _ +_ = lambda s: s +YAML_SCHEMA = Schema( + Or( + { + UseOptional(COMMAND): Or( + { + str: And( + { + Or(int, "default"): And( + bool, error=_("Rules must be either `true` or `false`.") + ) + }, + error=_("Keys under command names must be IDs (numbers) or `default`."), + ) + }, + {}, + error=_("Keys under `COMMAND` must be command names (strings)."), + ), + UseOptional(COG): Or( + { + str: Or( + { + Or(int, "default"): And( + bool, error=_("Rules must be either `true` or `false`.") + ) + }, + {}, + error=_("Keys under cog names must be IDs or `default`."), + ) + }, + {}, + error=_("Keys under `COG` must be cog names (strings)."), + ), + }, + {}, + error=_("Top-level keys must be either `COG` or `COMMAND`."), + ) +) +_ = translate + +__version__ = "1.0.0" @cog_i18n(_) -class Permissions: - """ - A high level permission model - """ - - # Not sure if we will use admin or mod models in core red - # but they are explicitly supported - resolution_order = {k: _models[:i] for i, k in enumerate(_models, 1)} +class Permissions(commands.Cog): + """Customise permissions for commands and cogs.""" def __init__(self, bot: Red): + super().__init__() self.bot = bot - self.config = Config.get_conf(self, identifier=78631113035100160, force_registration=True) - self.config.register_global(owner_models={}) - self.config.register_guild(owner_models={}) - self.cache = LRUDict(size=25000) # This can be tuned later + # Config Schema: + # "COG" + # -> Cog names... + # -> Guild IDs... + # -> Model IDs... + # -> True|False + # -> "default" + # -> True|False + # "COMMAND" + # -> Command names... + # -> Guild IDs... + # -> Model IDs... + # -> True|False + # -> "default" + # -> True|False - async def get_user_ctx_overrides(self, ctx: commands.Context) -> dict: - """ - This takes a context object, and returns a dict of - - allowed: list of commands - denied: list of commands - default: list of commands - - representing how permissions interacts with the - user, channel, guild, and (possibly) voice channel - for all commands on the bot (not just the one in the context object) - - This mainly exists for use by the help formatter, - but others may find it useful - - Unlike the rest of the permission system, if other models are added later, - due to optimizations made for this, this needs to be adjusted accordingly - - This does not account for before and after permission hooks, - these need to be checked seperately - """ - return await mass_resolve(ctx=ctx, config=self.config) - - async def __global_check(self, ctx: commands.Context) -> bool: - """ - Yes, this is needed on top of hooking into checks.py - to ensure that unchecked commands can still be managed by permissions - This should return True in the case of no overrides - defering to check logic - This works since all checks must be True to run - """ - v = await self.check_overrides(ctx, "all") - - if v is False: - return False - return True - - async def check_overrides(self, ctx: commands.Context, level: str) -> bool: - """ - This checks for any overrides in the permission model - - Parameters - ---------- - ctx: `redbot.core.context.commands.Context` - The context of the command - level: `str` - One of 'owner', 'guildowner', 'admin', 'mod', 'all' - - Returns - ------- - bool - a trinary value using None + bool to resolve permissions for - checks.py - """ - if await ctx.bot.is_owner(ctx.author): - return True - - before = [ - getattr(cog, "_{0.__class__.__name__}__red_permissions_before".format(cog), None) - for cog in ctx.bot.cogs.values() - ] - for check in before: - if check is None: - continue - override = await val_if_check_is_valid(check=check, ctx=ctx, level=level) - if override is not None: - return override - - # checked ids + configureable to be checked against - cache_tup = entries_from_ctx(ctx) + ( - ctx.cog.__class__.__name__, - ctx.command.qualified_name, - ) - if cache_tup in self.cache: - override = self.cache[cache_tup] - if override is not None: - return override - else: - for model in self.resolution_order[level]: - if ctx.guild is None and model != "owner": - break - override_model = getattr(self, model + "_model", None) - override = await override_model(ctx) if override_model else None - if override is not None: - self.cache[cache_tup] = override - return override - # This is intentional not being in an else block - self.cache[cache_tup] = None - - after = [ - getattr(cog, "_{0.__class__.__name__}__red_permissions_after".format(cog), None) - for cog in ctx.bot.cogs.values() - ] - for check in after: - override = await val_if_check_is_valid(check=check, ctx=ctx, level=level) - if override is not None: - return override - - return None - - async def owner_model(self, ctx: commands.Context) -> bool: - """ - Handles owner level overrides - """ - - async with self.config.owner_models() as models: - return resolve_models(ctx=ctx, models=models) - - async def guildowner_model(self, ctx: commands.Context) -> bool: - """ - Handles guild level overrides - """ - if ctx.guild is None: - return None - async with self.config.guild(ctx.guild).owner_models() as models: - return resolve_models(ctx=ctx, models=models) - - # Either of the below function signatures could be used - # without any other modifications required at a later date - # - # async def admin_model(self, ctx: commands.Context) -> bool: - # async def mod_model(self, ctx: commands.Context) -> bool: + # Note that GLOBAL rules are denoted by an ID of 0. + self.config = config.Config.get_conf(self, identifier=78631113035100160) + self.config.register_global(version="") + self.config.register_custom(COG) + self.config.register_custom(COMMAND) @commands.group(aliases=["p"]) async def permissions(self, ctx: commands.Context): - """ - Permission management tools - """ + """Command permission management tools.""" pass - @permissions.command() - async def explain(self, ctx: commands.Context): - """ - Provides a detailed explanation of how the permission model functions - """ + @permissions.command(name="explain") + async def permissions_explain(self, ctx: commands.Context): + """Explain how permissions works.""" # Apologies in advance for the translators out there... message = _( - "This cog extends the default permission model of the bot. " - "By default, many commands are restricted based on what " - "the command can do." - "\n" - "Any command that could impact the host machine, " - "is generally owner only." - "\n" - "Commands that take administrative or moderator " - "actions in servers generally require a mod or an admin." - "\n" - "This cog allows you to refine some of those settings. " - "You can allow wider or narrower " - "access to most commands using it." - "\n\n" - "When additional rules are set using this cog, " - "those rules will be checked prior to " - "checking for the default restrictions of the command. " - "\n" - "Rules set globally (by the owner) are checked first, " - "then rules set for guilds. If multiple global or guild " - "rules apply to the case, the order they are checked is:" - "\n" - "1. Rules about a user.\n" - "2. Rules about the voice channel a user is in.\n" - "3. Rules about the text channel a command was issued in.\n" - "4. Rules about a role the user has " - "(The highest role they have with a rule will be used).\n" - "5. Rules about the guild a user is in (Owner level only)." - "\n\nFor more details, please read the official documentation." + "This cog extends the default permission model of the bot. By default, many commands " + "are restricted based on what the command can do.\n" + "This cog allows you to refine some of those restrictions. You can allow wider or " + "narrower access to most commands using it. You cannot, however, change the " + "restrictions on owner-only commands.\n\n" + "When additional rules are set using this cog, those rules will be checked prior to " + "checking for the default restrictions of the command.\n" + "Global rules (set by the owner) are checked first, then rules set for servers. If " + "multiple global or server rules apply to the case, the order they are checked is:\n" + " 1. Rules about a user.\n" + " 2. Rules about the voice channel a user is in.\n" + " 3. Rules about the text channel a command was issued in.\n" + " 4. Rules about a role the user has (The highest role they have with a rule will be " + "used).\n" + " 5. Rules about the server a user is in (Global rules only).\n\n" + "For more details, please read the [official documentation]" + "(https://red-discordbot.readthedocs.io/en/v3-develop/cog_permissions.html)." ) await ctx.maybe_send_embed(message) @permissions.command(name="canrun") - async def _test_permission_model( + async def permissions_canrun( self, ctx: commands.Context, user: discord.Member, *, command: str ): - """ - This checks if someone can run a command in the current location + """Check if a user can run a command. + + This will take the current context into account, such as the + server and text channel. """ if not command: @@ -225,15 +147,18 @@ class Permissions: message.author = user message.content = "{}{}".format(ctx.prefix, command) - com = self.bot.get_command(command) + com = ctx.bot.get_command(command) if com is None: out = _("No such command") else: try: - testcontext = await self.bot.get_context(message, cls=commands.Context) - can = await com.can_run(testcontext) and all( - [await p.can_run(testcontext) for p in com.parents] - ) + testcontext = await ctx.bot.get_context(message, cls=commands.Context) + to_check = [*reversed(com.parents)] + [com] + can = False + for cmd in to_check: + can = await cmd.can_run(testcontext) + if can is False: + break except commands.CheckFailure: can = False @@ -244,426 +169,570 @@ class Permissions: ) await ctx.send(out) - @checks.is_owner() - @permissions.command(name="setglobalacl") - async def owner_set_acl(self, ctx: commands.Context): - """ - Take a YAML file upload to set permissions from - """ - if not ctx.message.attachments: - return await ctx.send(_("You must upload a file.")) - - try: - await yamlset_acl(ctx, config=self.config.owner_models, update=False) - except Exception as e: - print(e) - return await ctx.send(_("Invalid syntax.")) - else: - await ctx.send(_("Rules set.")) - self.invalidate_cache() + @checks.guildowner_or_permissions(administrator=True) + @permissions.group(name="acl", aliases=["yaml"]) + async def permissions_acl(self, ctx: commands.Context): + """Manage permissions with YAML files.""" + if ctx.invoked_subcommand is None or ctx.invoked_subcommand == self.permissions_acl: + # Send a little guide on YAML formatting + await ctx.send( + _("Example YAML for setting rules:\n") + + box( + textwrap.dedent( + """\ + COMMAND: + ping: + 12345678901234567: true + 56789012345671234: false + COG: + General: + 56789012345671234: true + 12345678901234567: false + default: false + """ + ), + lang="yaml", + ) + ) @checks.is_owner() - @permissions.command(name="getglobalacl") - async def owner_get_acl(self, ctx: commands.Context): + @permissions_acl.command(name="setglobal") + async def permissions_acl_setglobal(self, ctx: commands.Context): + """Set global rules with a YAML file. + + **WARNING**: This will override reset *all* global rules + to the rules specified in the uploaded file. + + This does not validate the names of commands and cogs before + setting the new rules. """ - Dumps a YAML file with the current owner level permissions - """ - await yamlget_acl(ctx, config=self.config.owner_models) + await self._permissions_acl_set(ctx, guild_id=GLOBAL, update=False) @commands.guild_only() @checks.guildowner_or_permissions(administrator=True) - @permissions.command(name="setguildacl") - async def guild_set_acl(self, ctx: commands.Context): - """ - Take a YAML file upload to set permissions from - """ - if not ctx.message.attachments: - return await ctx.send(_("You must upload a file.")) + @permissions_acl.command(name="setserver", aliases=["setguild"]) + async def permissions_acl_setguild(self, ctx: commands.Context): + """Set rules for this server with a YAML file. - try: - await yamlset_acl(ctx, config=self.config.guild(ctx.guild).owner_models, update=False) - except Exception as e: - print(e) - return await ctx.send(_("Invalid syntax.")) - else: - await ctx.send(_("Rules set.")) - self.invalidate_cache(ctx.guild.id) - - @commands.guild_only() - @checks.guildowner_or_permissions(administrator=True) - @permissions.command(name="getguildacl") - async def guild_get_acl(self, ctx: commands.Context): + **WARNING**: This will override reset *all* rules in this + server to the rules specified in the uploaded file. """ - Dumps a YAML file with the current owner level permissions - """ - await yamlget_acl(ctx, config=self.config.guild(ctx.guild).owner_models) - - @commands.guild_only() - @checks.guildowner_or_permissions(administrator=True) - @permissions.command(name="updateguildacl") - async def guild_update_acl(self, ctx: commands.Context): - """ - Take a YAML file upload to update permissions from - - Use this to not lose existing rules - """ - if not ctx.message.attachments: - return await ctx.send(_("You must upload a file.")) - - try: - await yamlset_acl(ctx, config=self.config.guild(ctx.guild).owner_models, update=True) - except Exception as e: - print(e) - return await ctx.send(_("Invalid syntax.")) - else: - await ctx.send(_("Rules set.")) - self.invalidate_cache(ctx.guild.id) + await self._permissions_acl_set(ctx, guild_id=ctx.guild.id, update=False) @checks.is_owner() - @permissions.command(name="updateglobalacl") - async def owner_update_acl(self, ctx: commands.Context): - """ - Take a YAML file upload to update permissions from - - Use this to not lose existing rules - """ - if not ctx.message.attachments: - return await ctx.send(_("You must upload a file.")) - + @permissions_acl.command(name="getglobal") + async def permissions_acl_getglobal(self, ctx: commands.Context): + """Get a YAML file detailing all global rules.""" + file = await self._yaml_get_acl(guild_id=GLOBAL) try: - await yamlset_acl(ctx, config=self.config.owner_models, update=True) - except Exception as e: - print(e) - return await ctx.send(_("Invalid syntax.")) + await ctx.author.send(file=file) + except discord.Forbidden: + await ctx.send(_("I'm not allowed to DM you.")) else: - await ctx.send(_("Rules set.")) - self.invalidate_cache() + await ctx.send(_("I've just sent the file to you via DM.")) + finally: + file.close() + + @commands.guild_only() + @checks.guildowner_or_permissions(administrator=True) + @permissions_acl.command(name="getserver", aliases=["getguild"]) + async def permissions_acl_getguild(self, ctx: commands.Context): + """Get a YAML file detailing all rules in this server.""" + file = await self._yaml_get_acl(guild_id=ctx.guild.id) + try: + await ctx.author.send(file=file) + except discord.Forbidden: + await ctx.send(_("I'm not allowed to DM you.")) + else: + await ctx.send(_("I've just sent the file to you via DM.")) + finally: + file.close() + + @checks.is_owner() + @permissions_acl.command(name="updateglobal") + async def permissions_acl_updateglobal(self, ctx: commands.Context): + """Update global rules with a YAML file. + + This won't touch any rules not specified in the YAML + file. + """ + await self._permissions_acl_set(ctx, guild_id=GLOBAL, update=True) + + @commands.guild_only() + @checks.guildowner_or_permissions(administrator=True) + @permissions_acl.command(name="updateserver", aliases=["updateguild"]) + async def permissions_acl_updateguild(self, ctx: commands.Context): + """Update rules for this server with a YAML file. + + This won't touch any rules not specified in the YAML + file. + """ + await self._permissions_acl_set(ctx, guild_id=ctx.guild.id, update=True) @checks.is_owner() @permissions.command(name="addglobalrule") - async def add_to_global_rule( + async def permissions_addglobalrule( self, ctx: commands.Context, allow_or_deny: RuleType, cog_or_command: CogOrCommand, - who_or_what: str, + who_or_what: commands.GlobalPermissionModel, ): + """Add a global rule to a command. + + `` should be one of "allow" or "deny". + + `` is the cog or command to add the rule to. + This is case sensitive. + + `` is the user, channel, role or server the rule + is for. """ - Adds something to the rules - - allow_or_deny: "allow" or "deny", depending on the rule to modify - - cog_or_command: case sensitive cog or command name - nested commands should be space seperated, but enclosed in quotes - - who_or_what: what to add to the rule list. - For best results, use an ID or mention - The bot will try to uniquely match even without, - but a failure to do so will raise an error - This can be a user, role, channel, or guild - """ - obj = self.find_object_uniquely(who_or_what) - if not obj: - return await ctx.send(_("No unique matches. Try using an ID or mention.")) - model_type, type_name = cog_or_command - async with self.config.owner_models() as models: - data = {k: v for k, v in models.items()} - if model_type not in data: - data[model_type] = {} - if type_name not in data[model_type]: - data[model_type][type_name] = {} - if allow_or_deny not in data[model_type][type_name]: - data[model_type][type_name][allow_or_deny] = [] - - if obj in data[model_type][type_name][allow_or_deny]: - return await ctx.send(_("That rule already exists.")) - - data[model_type][type_name][allow_or_deny].append(obj) - models.update(data) + # noinspection PyTypeChecker + await self._add_rule( + rule=allow_or_deny, cog_or_cmd=cog_or_command, model_id=who_or_what.id, guild_id=0 + ) await ctx.send(_("Rule added.")) - self.invalidate_cache(type_name, obj) @commands.guild_only() @checks.guildowner_or_permissions(administrator=True) - @permissions.command(name="addguildrule") - async def add_to_guild_rule( + @permissions.command(name="addserverrule", aliases=["addguildrule"]) + async def permissions_addguildrule( self, ctx: commands.Context, allow_or_deny: RuleType, cog_or_command: CogOrCommand, - who_or_what: str, + who_or_what: commands.GuildPermissionModel, ): + """Add a rule to a command in this server. + + `` should be one of "allow" or "deny". + + `` is the cog or command to add the rule to. + This is case sensitive. + + `` is the user, channel or role the rule is for. """ - Adds something to the rules - - allow_or_deny: "allow" or "deny", depending on the rule to modify - - cog_or_command: case sensitive cog or command name - nested commands should be space seperated, but enclosed in quotes - - who_or_what: what to add to the rule list. - For best results, use an ID or mention - The bot will try to uniquely match even without, - but a failure to do so will raise an error - This can be a user, role, channel, or guild - """ - obj = self.find_object_uniquely(who_or_what) - if not obj: - return await ctx.send(_("No unique matches. Try using an ID or mention.")) - model_type, type_name = cog_or_command - async with self.config.guild(ctx.guild).owner_models() as models: - data = {k: v for k, v in models.items()} - if model_type not in data: - data[model_type] = {} - if type_name not in data[model_type]: - data[model_type][type_name] = {} - if allow_or_deny not in data[model_type][type_name]: - data[model_type][type_name][allow_or_deny] = [] - - if obj in data[model_type][type_name][allow_or_deny]: - return await ctx.send(_("That rule already exists.")) - - data[model_type][type_name][allow_or_deny].append(obj) - models.update(data) + # noinspection PyTypeChecker + await self._add_rule( + rule=allow_or_deny, + cog_or_cmd=cog_or_command, + model_id=who_or_what.id, + guild_id=ctx.guild.id, + ) await ctx.send(_("Rule added.")) - self.invalidate_cache(type_name, obj) @checks.is_owner() @permissions.command(name="removeglobalrule") - async def rem_from_global_rule( + async def permissions_removeglobalrule( self, ctx: commands.Context, - allow_or_deny: RuleType, cog_or_command: CogOrCommand, - who_or_what: str, + who_or_what: commands.GlobalPermissionModel, ): + """Remove a global rule from a command. + + `` is the cog or command to remove the rule + from. This is case sensitive. + + `` is the user, channel, role or server the rule + is for. """ - removes something from the rules - - allow_or_deny: "allow" or "deny", depending on the rule to modify - - cog_or_command: case sensitive cog or command name - nested commands should be space seperated, but enclosed in quotes - - who_or_what: what to add to the rule list. - For best results, use an ID or mention - The bot will try to uniquely match even without, - but a failure to do so will raise an error - This can be a user, role, channel, or guild - """ - obj = self.find_object_uniquely(who_or_what) - if not obj: - return await ctx.send(_("No unique matches. Try using an ID or mention.")) - model_type, type_name = cog_or_command - async with self.config.owner_models() as models: - data = {k: v for k, v in models.items()} - if model_type not in data: - data[model_type] = {} - if type_name not in data[model_type]: - data[model_type][type_name] = {} - if allow_or_deny not in data[model_type][type_name]: - data[model_type][type_name][allow_or_deny] = [] - - if obj not in data[model_type][type_name][allow_or_deny]: - return await ctx.send(_("That rule doesn't exist.")) - - data[model_type][type_name][allow_or_deny].remove(obj) - models.update(data) + await self._remove_rule( + cog_or_cmd=cog_or_command, model_id=who_or_what.id, guild_id=GLOBAL + ) await ctx.send(_("Rule removed.")) - self.invalidate_cache(obj, type_name) @commands.guild_only() @checks.guildowner_or_permissions(administrator=True) - @permissions.command(name="removeguildrule") - async def rem_from_guild_rule( + @permissions.command(name="removeserverrule", aliases=["removeguildrule"]) + async def permissions_removeguildrule( self, ctx: commands.Context, - allow_or_deny: RuleType, cog_or_command: CogOrCommand, - who_or_what: str, + *, + who_or_what: commands.GuildPermissionModel, ): + """Remove a server rule from a command. + + `` is the cog or command to remove the rule + from. This is case sensitive. + + `` is the user, channel or role the rule is for. """ - removes something from the rules - - allow_or_deny: "allow" or "deny", depending on the rule to modify - - cog_or_command: case sensitive cog or command name - nested commands should be space seperated, but enclosed in quotes - - who_or_what: what to add to the rule list. - For best results, use an ID or mention - The bot will try to uniquely match even without, - but a failure to do so will raise an error - This can be a user, role, channel, or guild - """ - obj = self.find_object_uniquely(who_or_what) - if not obj: - return await ctx.send(_("No unique matches. Try using an ID or mention.")) - model_type, type_name = cog_or_command - async with self.config.guild(ctx.guild).owner_models() as models: - data = {k: v for k, v in models.items()} - if model_type not in data: - data[model_type] = {} - if type_name not in data[model_type]: - data[model_type][type_name] = {} - if allow_or_deny not in data[model_type][type_name]: - data[model_type][type_name][allow_or_deny] = [] - - if obj not in data[model_type][type_name][allow_or_deny]: - return await ctx.send(_("That rule doesn't exist.")) - - data[model_type][type_name][allow_or_deny].remove(obj) - models.update(data) + await self._remove_rule( + cog_or_cmd=cog_or_command, model_id=who_or_what.id, guild_id=ctx.guild.id + ) await ctx.send(_("Rule removed.")) - self.invalidate_cache(obj, type_name) @commands.guild_only() @checks.guildowner_or_permissions(administrator=True) - @permissions.command(name="setdefaultguildrule") - async def set_default_guild_rule( + @permissions.command(name="setdefaultserverrule", aliases=["setdefaultguildrule"]) + async def permissions_setdefaultguildrule( self, ctx: commands.Context, allow_or_deny: ClearableRuleType, cog_or_command: CogOrCommand ): + """Set the default rule for a command in this server. + + This is the rule a command will default to when no other rule + is found. + + `` should be one of "allow", "deny" or "clear". + "clear" will reset the default rule. + + `` is the cog or command to set the default + rule for. This is case sensitive. """ - Sets the default behavior for a cog or command if no rule is set - """ - val_to_set = {"allow": True, "deny": False, "clear": None}.get(allow_or_deny) - - model_type, type_name = cog_or_command - async with self.config.guild(ctx.guild).owner_models() as models: - data = {k: v for k, v in models.items()} - if model_type not in data: - data[model_type] = {} - if type_name not in data[model_type]: - data[model_type][type_name] = {} - - data[model_type][type_name]["default"] = val_to_set - - models.update(data) + # noinspection PyTypeChecker + await self._set_default_rule( + rule=allow_or_deny, cog_or_cmd=cog_or_command, guild_id=ctx.guild.id + ) await ctx.send(_("Default set.")) - self.invalidate_cache(type_name) @checks.is_owner() @permissions.command(name="setdefaultglobalrule") - async def set_default_global_rule( + async def permissions_setdefaultglobalrule( self, ctx: commands.Context, allow_or_deny: ClearableRuleType, cog_or_command: CogOrCommand ): + """Set the default global rule for a command. + + This is the rule a command will default to when no other rule + is found. + + `` should be one of "allow", "deny" or "clear". + "clear" will reset the default rule. + + `` is the cog or command to set the default + rule for. This is case sensitive. """ - Sets the default behavior for a cog or command if no rule is set - """ - val_to_set = {"allow": True, "deny": False, "clear": None}.get(allow_or_deny) - - model_type, type_name = cog_or_command - async with self.config.owner_models() as models: - data = {k: v for k, v in models.items()} - if model_type not in data: - data[model_type] = {} - if type_name not in data[model_type]: - data[model_type][type_name] = {} - - data[model_type][type_name]["default"] = val_to_set - - models.update(data) + # noinspection PyTypeChecker + await self._set_default_rule( + rule=allow_or_deny, cog_or_cmd=cog_or_command, guild_id=GLOBAL + ) await ctx.send(_("Default set.")) - self.invalidate_cache(type_name) @checks.is_owner() - @permissions.command(name="clearglobalsettings") - async def clear_globals(self, ctx: commands.Context): - """ - Clears all global rules. - """ - await self._confirm_then_clear_rules(ctx, is_guild=False) - self.invalidate_cache() + @permissions.command(name="clearglobalrules") + async def permissions_clearglobalrules(self, ctx: commands.Context): + """Reset all global rules.""" + agreed = await self._confirm(ctx) + if agreed: + await self._clear_rules(guild_id=GLOBAL) + await ctx.tick() @commands.guild_only() @checks.guildowner_or_permissions(administrator=True) - @permissions.command(name="clearguildsettings") - async def clear_guild_settings(self, ctx: commands.Context): - """ - Clears all guild rules. - """ - await self._confirm_then_clear_rules(ctx, is_guild=True) - self.invalidate_cache(ctx.guild.id) + @permissions.command(name="clearserverrules", aliases=["clearguildrules"]) + async def permissions_clearguildrules(self, ctx: commands.Context): + """Reset all rules in this server.""" + agreed = await self._confirm(ctx) + if agreed: + await self._clear_rules(guild_id=ctx.guild.id) + await ctx.tick() - async def _confirm_then_clear_rules(self, ctx: commands.Context, is_guild: bool): - if ctx.guild.me.permissions_in(ctx.channel).add_reactions: - m = await ctx.send(_("Are you sure?")) - for r in REACTS.keys(): - await m.add_reaction(r) + async def cog_added(self, cog: commands.Cog) -> None: + """Event listener for `cog_add`. + + This loads rules whenever a new cog is added. + """ + self._load_rules_for( + cog_or_command=cog, + rule_dict=await self.config.custom(COMMAND, cog.__class__.__name__).all(), + ) + + async def command_added(self, command: commands.Command) -> None: + """Event listener for `command_add`. + + This loads rules whenever a new command is added. + """ + self._load_rules_for( + cog_or_command=command, + rule_dict=await self.config.custom(COMMAND, command.qualified_name).all(), + ) + + async def _add_rule( + self, rule: bool, cog_or_cmd: CogOrCommand, model_id: int, guild_id: int + ) -> None: + """Add a rule. + + Guild ID should be 0 for global rules. + + Handles config. + """ + if rule is True: + cog_or_cmd.obj.allow_for(model_id, guild_id=guild_id) + else: + cog_or_cmd.obj.deny_to(model_id, guild_id=guild_id) + + async with self.config.custom(cog_or_cmd.type, cog_or_cmd.name).all() as rules: + rules.setdefault(str(guild_id), {})[str(model_id)] = rule + + async def _remove_rule(self, cog_or_cmd: CogOrCommand, model_id: int, guild_id: int) -> None: + """Remove a rule. + + Guild ID should be 0 for global rules. + + Handles config. + """ + cog_or_cmd.obj.clear_rule_for(model_id, guild_id=guild_id) + guild_id, model_id = str(guild_id), str(model_id) + async with self.config.custom(cog_or_cmd.type, cog_or_cmd.name).all() as rules: + if guild_id in rules and rules[guild_id]: + del rules[guild_id][model_id] + + async def _set_default_rule( + self, rule: Optional[bool], cog_or_cmd: CogOrCommand, guild_id: int + ) -> None: + """Set the default rule. + + Guild ID should be 0 for the global default. + + Handles config. + """ + cog_or_cmd.obj.set_default_rule(rule, guild_id) + async with self.config.custom(cog_or_cmd.type, cog_or_cmd.name).all() as rules: + rules.setdefault(str(guild_id), {})["default"] = rule + + async def _clear_rules(self, guild_id: int) -> None: + """Clear all global rules or rules for a guild. + + Guild ID should be 0 for global rules. + + Handles config. + """ + self.bot.clear_permission_rules(guild_id) + for category in (COG, COMMAND): + async with self.config.custom(category).all() as all_rules: + for name, rules in all_rules.items(): + rules.pop(str(guild_id), None) + + async def _permissions_acl_set( + self, ctx: commands.Context, guild_id: int, update: bool + ) -> None: + """Set rules from a YAML file and handle response to users too.""" + if not ctx.message.attachments: + await ctx.send(_("You must upload a file.")) + return + + try: + await self._yaml_set_acl(ctx.message.attachments[0], guild_id=guild_id, update=update) + except yaml.MarkedYAMLError as e: + await ctx.send(_("Invalid syntax: ") + str(e)) + except SchemaError as e: + await ctx.send( + _("Your YAML file did not match the schema: ") + translate(e.errors[-1]) + ) + else: + await ctx.send(_("Rules set.")) + + async def _yaml_set_acl(self, source: discord.Attachment, guild_id: int, update: bool) -> None: + """Set rules from a YAML file.""" + with io.BytesIO() as fp: + await source.save(fp) + rules = yaml.safe_load(fp) + + if rules is None: + rules = {} + YAML_SCHEMA.validate(rules) + if update is False: + await self._clear_rules(guild_id) + + for category, getter in ((COG, self.bot.get_cog), (COMMAND, self.bot.get_command)): + rules_dict = rules.get(category) + if not rules_dict: + continue + conf = self.config.custom(category) + for cmd_name, cmd_rules in rules_dict.items(): + await conf.set_raw(cmd_name, guild_id, value=cmd_rules) + cmd_obj = getter(cmd_name) + if cmd_obj is not None: + self._load_rules_for(cmd_obj, {guild_id: cmd_rules}) + + async def _yaml_get_acl(self, guild_id: int) -> discord.File: + """Get a YAML file for all rules set in a guild.""" + guild_rules = {} + for category in (COG, COMMAND): + guild_rules.setdefault(category, {}) + rules_dict = await self.config.custom(category).all() + for cmd_name, cmd_rules in rules_dict.items(): + model_rules = cmd_rules.get(str(guild_id)) + if model_rules is not None: + guild_rules[category][cmd_name] = dict(_int_key_map(model_rules.items())) + + fp = io.BytesIO(yaml.dump(guild_rules, default_flow_style=False).encode("utf-8")) + return discord.File(fp, filename="acl.yaml") + + @staticmethod + async def _confirm(ctx: commands.Context) -> bool: + """Ask "Are you sure?" and get the response as a bool.""" + if ctx.guild is None or ctx.guild.me.permissions_in(ctx.channel).add_reactions: + msg = await ctx.send(_("Are you sure?")) + for emoji in REACTS.keys(): + await msg.add_reaction(emoji) try: - reaction, user = await self.bot.wait_for( + reaction, user = await ctx.bot.wait_for( "reaction_add", - check=lambda r, u: u == ctx.author and str(r) in REACTS, + check=lambda r, u: ( + r.message.id == msg.id and u == ctx.author and r.emoji in REACTS + ), timeout=30, ) except asyncio.TimeoutError: - return await ctx.send(_("Ok, try responding with an emoji next time.")) - - agreed = REACTS.get(str(reaction)) + agreed = False + else: + agreed = REACTS.get(reaction.emoji) + await msg.delete() else: await ctx.send(_("Are you sure? (y/n)")) try: - message = await self.bot.wait_for( + message = await ctx.bot.wait_for( "message", - check=lambda m: m.author == ctx.author and m.content in Y_OR_N, + check=lambda m: m.author == ctx.author + and m.channel == ctx.channel + and m.content in Y_OR_N, timeout=30, ) except asyncio.TimeoutError: - return await ctx.send(_("Ok, try responding with yes or no next time.")) - - agreed = Y_OR_N.get(message.content.lower()) - - if agreed: - if is_guild: - await self.config.guild(ctx.guild).owner_models.clear() - await ctx.send(_("Guild settings cleared.")) + agreed = False else: - await self.config.owner_models.clear() - await ctx.send(_("Global settings cleared.")) - else: - await ctx.send(_("Okay.")) + agreed = Y_OR_N.get(message.content.lower()) - def invalidate_cache(self, *to_invalidate): + if agreed is False: + await ctx.send(_("Action cancelled.")) + return agreed + + async def initialize(self) -> None: + """Initialize this cog. + + This will load all rules from config onto every currently + loaded command. """ - Either invalidates the entire cache (if given no objects) - or does a partial invalidation based on passed objects - """ - if len(to_invalidate) == 0: - self.cache.clear() + await self._maybe_update_schema() + await self._load_all_rules() + + async def _maybe_update_schema(self) -> None: + """Maybe update rules set by config prior to permissions 1.0.0.""" + if await self.config.version(): return - # LRUDict inherits from ordered dict, hence the syntax below - stil_valid = [ - (k, v) for k, v in self.cache.items() if not any(obj in k for obj in to_invalidate) - ] - self.cache = LRUDict(stil_valid, size=self.cache.size) + old_config = await self.config.all_guilds() + old_config[GLOBAL] = await self.config.all() + new_cog_rules, new_cmd_rules = self._get_updated_schema(old_config) + await self.config.custom(COG).set(new_cog_rules) + await self.config.custom(COMMAND).set(new_cmd_rules) + await self.config.version.set(__version__) - def find_object_uniquely(self, info: str) -> int: + _OldConfigSchema = Dict[int, Dict[str, Dict[str, Dict[str, Dict[str, List[int]]]]]] + _NewConfigSchema = Dict[str, Dict[int, Dict[str, Dict[int, bool]]]] + + @staticmethod + def _get_updated_schema( + old_config: _OldConfigSchema + ) -> Tuple[_NewConfigSchema, _NewConfigSchema]: + # Prior to 1.0.0, the schema was in this form for both global + # and guild-based rules: + # "owner_models" + # -> "cogs" + # -> Cog names... + # -> "allow" + # -> [Model IDs...] + # -> "deny" + # -> [Model IDs...] + # -> "default" + # -> "allow"|"deny" + # -> "commands" + # -> Command names... + # -> "allow" + # -> [Model IDs...] + # -> "deny" + # -> [Model IDs...] + # -> "default" + # -> "allow"|"deny" + + new_cog_rules = {} + new_cmd_rules = {} + for guild_id, old_rules in old_config.items(): + if "owner_models" not in old_rules: + continue + old_rules = old_rules["owner_models"] + for category, new_rules in zip(("cogs", "commands"), (new_cog_rules, new_cmd_rules)): + if category in old_rules: + for name, rules in old_rules[category].items(): + these_rules = new_rules.setdefault(name, {}) + guild_rules = these_rules.setdefault(guild_id, {}) + # Since allow rules would take precedence if the same model ID + # sat in both the allow and deny list, we add the deny entries + # first and let any conflicting allow entries overwrite. + for model_id in rules.get("deny", []): + guild_rules[model_id] = False + for model_id in rules.get("allow", []): + guild_rules[model_id] = True + if "default" in rules: + default = rules["default"] + if default == "allow": + guild_rules["default"] = True + elif default == "deny": + guild_rules["default"] = False + return new_cog_rules, new_cmd_rules + + async def _load_all_rules(self): + """Load all of this cog's rules into loaded commands and cogs.""" + for category, getter in ((COG, self.bot.get_cog), (COMMAND, self.bot.get_command)): + all_rules = await self.config.custom(category).all() + for name, rules in all_rules.items(): + obj = getter(name) + if obj is None: + continue + self._load_rules_for(obj, rules) + + @staticmethod + def _load_rules_for( + cog_or_command: Union[commands.Command, commands.Cog], + rule_dict: Dict[Union[int, str], Dict[Union[int, str], bool]], + ) -> None: + """Load the rules into a command or cog object. + + rule_dict should be a dict mapping Guild IDs to Model IDs to + rules. """ - Finds an object uniquely, returns it's id or returns None + for guild_id, guild_dict in _int_key_map(rule_dict.items()): + for model_id, rule in _int_key_map(guild_dict.items()): + if rule is True: + cog_or_command.allow_for(model_id, guild_id=guild_id) + elif rule is False: + cog_or_command.deny_to(model_id, guild_id=guild_id) + + def __unload(self) -> None: + self.bot.remove_listener(self.cog_added, "on_cog_add") + self.bot.remove_listener(self.command_added, "on_command_add") + self.bot.loop.create_task(self._unload_all_rules()) + + async def _unload_all_rules(self) -> None: + """Unload all rules set by this cog. + + This is done instead of just clearing all rules, which could + clear rules set by other cogs. """ - if info is None: - return None - objs = [] + for category, getter in ((COG, self.bot.get_cog), (COMMAND, self.bot.get_command)): + all_rules = await self.config.custom(category).all() + for name, rules in all_rules.items(): + obj = getter(name) + if obj is None: + continue + self._unload_rules_for(obj, rules) - objs.extend(self.bot.users) - for guild in self.bot.guilds: - objs.extend(guild.roles) - objs.extend(guild.channels) + @staticmethod + def _unload_rules_for( + cog_or_command: Union[commands.Command, commands.Cog], + rule_dict: Dict[Union[int, str], Dict[Union[int, str], bool]], + ) -> None: + """Unload the rules from a command or cog object. - try: - _id = int(info) - except ValueError: - _id = None + rule_dict should be a dict mapping Guild IDs to Model IDs to + rules. + """ + for guild_id, guild_dict in _int_key_map(rule_dict.items()): + for model_id in map(int, guild_dict.keys()): + cog_or_command.clear_rule_for(model_id, guild_id) - for function in ( - lambda x: x.id == _id, - lambda x: x.mention == info, - lambda x: str(x) == info, - lambda x: x.name == info, - lambda x: (x.nick if hasattr(x, "nick") else None) == info, - ): - canidates = list(filter(function, objs)) - if len(canidates) == 1: - return canidates[0].id - return None +def _int_key_map(items_view: ItemsView[str, Any]) -> Iterator[Tuple[int, Any]]: + return map(lambda tup: (int(tup[0]), tup[1]), items_view) diff --git a/redbot/cogs/permissions/resolvers.py b/redbot/cogs/permissions/resolvers.py deleted file mode 100644 index 996c45e0d..000000000 --- a/redbot/cogs/permissions/resolvers.py +++ /dev/null @@ -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 diff --git a/redbot/cogs/permissions/template.yaml b/redbot/cogs/permissions/template.yaml deleted file mode 100644 index 912ae5dc9..000000000 --- a/redbot/cogs/permissions/template.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/redbot/cogs/permissions/yaml_handler.py b/redbot/cogs/permissions/yaml_handler.py deleted file mode 100644 index b39ba2ad6..000000000 --- a/redbot/cogs/permissions/yaml_handler.py +++ /dev/null @@ -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() diff --git a/redbot/cogs/reports/reports.py b/redbot/cogs/reports/reports.py index f74b2cb14..1ff190dcb 100644 --- a/redbot/cogs/reports/reports.py +++ b/redbot/cogs/reports/reports.py @@ -20,7 +20,7 @@ log = logging.getLogger("red.reports") @cog_i18n(_) -class Reports: +class Reports(commands.Cog): default_guild_settings = {"output_channel": None, "active": False, "next_ticket": 1} @@ -40,6 +40,7 @@ class Reports: ] def __init__(self, bot: Red): + super().__init__() self.bot = bot self.config = Config.get_conf(self, 78631113035100160, force_registration=True) self.config.register_guild(**self.default_guild_settings) diff --git a/redbot/cogs/streams/streams.py b/redbot/cogs/streams/streams.py index 0cf654ce7..381ee3cb6 100644 --- a/redbot/cogs/streams/streams.py +++ b/redbot/cogs/streams/streams.py @@ -35,7 +35,7 @@ _ = Translator("Streams", __file__) @cog_i18n(_) -class Streams: +class Streams(commands.Cog): global_defaults = {"tokens": {}, "streams": [], "communities": []} @@ -44,6 +44,7 @@ class Streams: role_defaults = {"mention": False} def __init__(self, bot: Red): + super().__init__() self.db = Config.get_conf(self, 26262626) self.db.register_global(**self.global_defaults) diff --git a/redbot/cogs/trivia/trivia.py b/redbot/cogs/trivia/trivia.py index 11a26f9df..c05262560 100644 --- a/redbot/cogs/trivia/trivia.py +++ b/redbot/cogs/trivia/trivia.py @@ -23,10 +23,11 @@ class InvalidListError(Exception): pass -class Trivia: +class Trivia(commands.Cog): """Play trivia with friends!""" def __init__(self): + super().__init__() self.trivia_sessions = [] self.conf = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True) diff --git a/redbot/cogs/warnings/warnings.py b/redbot/cogs/warnings/warnings.py index 7e306e213..75169e1b9 100644 --- a/redbot/cogs/warnings/warnings.py +++ b/redbot/cogs/warnings/warnings.py @@ -20,7 +20,7 @@ _ = Translator("Warnings", __file__) @cog_i18n(_) -class Warnings: +class Warnings(commands.Cog): """A warning system for Red""" default_guild = {"actions": [], "reasons": {}, "allow_custom_reasons": False} @@ -28,6 +28,7 @@ class Warnings: default_member = {"total_points": 0, "status": "", "warnings": {}} def __init__(self, bot: Red): + super().__init__() self.config = Config.get_conf(self, identifier=5757575755) self.config.register_guild(**self.default_guild) self.config.register_member(**self.default_member) diff --git a/redbot/core/bot.py b/redbot/core/bot.py index d4befbc6d..d5aa86066 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -5,17 +5,12 @@ from collections import Counter from enum import Enum from importlib.machinery import ModuleSpec from pathlib import Path -from typing import Union +from typing import Optional, Union, List import discord import sys 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 . import Config, i18n, commands from .rpc import RPCMixin @@ -124,6 +119,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): self.add_command(help_) self._sentry_mgr = None + self._permissions_hooks: List[commands.CheckPredicate] = [] def enable_sentry(self): """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): return await super().get_context(message, cls=cls) - def list_packages(self): + @staticmethod + def list_packages(): """Lists packages present in the cogs the folder""" return os.listdir("cogs") @@ -234,7 +231,26 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): 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) 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) - 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): _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( _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" ) super().add_cog(cog) + self.dispatch("cog_add", cog) def add_command(self, 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) 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): """ diff --git a/redbot/core/checks.py b/redbot/core/checks.py index fa4f3b750..f0cf34f73 100644 --- a/redbot/core/checks.py +++ b/redbot/core/checks.py @@ -1,126 +1,77 @@ +import warnings +from typing import Awaitable, TYPE_CHECKING, Dict + import discord -from redbot.core import commands + +from .commands import ( + bot_has_permissions, + has_permissions, + is_owner, + guildowner, + guildowner_or_permissions, + admin, + admin_or_permissions, + mod, + mod_or_permissions, + check as _check_decorator, +) +from .utils.mod import ( + is_mod_or_superior as _is_mod_or_superior, + is_admin_or_superior as _is_admin_or_superior, + check_permissions as _check_permissions, +) + +if TYPE_CHECKING: + from .bot import Red + from .commands import Context + +__all__ = [ + "bot_has_permissions", + "has_permissions", + "is_owner", + "guildowner", + "guildowner_or_permissions", + "admin", + "admin_or_permissions", + "mod", + "mod_or_permissions", + "is_mod_or_superior", + "is_admin_or_superior", + "bot_in_a_guild", + "check_permissions", +] -async def check_overrides(ctx, *, level): - if await ctx.bot.is_owner(ctx.author): - return True - perm_cog = ctx.bot.get_cog("Permissions") - if not perm_cog or ctx.cog == perm_cog: - return None - # don't break if someone loaded a cog named - # permissions that doesn't implement this - func = getattr(perm_cog, "check_overrides", None) - val = None if func is None else await func(ctx, level) - return val +def bot_in_a_guild(): + """Deny the command if the bot is not in a guild.""" - -def is_owner(**kwargs): - async def check(ctx): - 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() - ) - - -async def is_mod_or_superior(ctx): - if ctx.guild is None: - return await ctx.bot.is_owner(ctx.author) - else: - author = ctx.author - settings = ctx.bot.db.guild(ctx.guild) - mod_role_id = await settings.mod_role() - admin_role_id = await settings.admin_role() - - mod_role = discord.utils.get(ctx.guild.roles, id=mod_role_id) - admin_role = discord.utils.get(ctx.guild.roles, id=admin_role_id) - - return ( - await ctx.bot.is_owner(ctx.author) - or mod_role in author.roles - or admin_role in author.roles - or author == ctx.guild.owner - ) - - -async def is_admin_or_superior(ctx): - if ctx.guild is None: - 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): return len(ctx.bot.guilds) > 0 - return commands.check(predicate) + return _check_decorator(predicate) -def guildowner_or_permissions(**perms): - async def predicate(ctx): - has_perms_or_is_owner = await check_permissions(ctx, perms) - if ctx.guild is None: - return has_perms_or_is_owner - is_guild_owner = ctx.author == ctx.guild.owner - - 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 is_mod_or_superior(bot: "Red", member: discord.Member) -> Awaitable[bool]: + warnings.warn( + "`redbot.core.checks.is_mod_or_superior` is deprecated and will be removed in a future " + "release, please use `redbot.core.utils.mod.is_mod_or_superior` instead.", + category=DeprecationWarning, + ) + return _is_mod_or_superior(bot, member) -def guildowner(): - return guildowner_or_permissions() +def is_admin_or_superior(bot: "Red", member: discord.Member) -> Awaitable[bool]: + 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(): - return admin_or_permissions() - - -def mod(): - return mod_or_permissions() +def check_permissions(ctx: "Context", perms: Dict[str, bool]) -> Awaitable[bool]: + 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`." + ) + return _check_permissions(ctx, perms) diff --git a/redbot/core/cog_manager.py b/redbot/core/cog_manager.py index 229321512..f63cc42ed 100644 --- a/redbot/core/cog_manager.py +++ b/redbot/core/cog_manager.py @@ -311,7 +311,7 @@ _ = Translator("CogManagerUI", __file__) @cog_i18n(_) -class CogManagerUI: +class CogManagerUI(commands.Cog): """Commands to interface with Red's cog manager.""" @staticmethod diff --git a/redbot/core/commands/__init__.py b/redbot/core/commands/__init__.py index ec2a4dc45..aac40617d 100644 --- a/redbot/core/commands/__init__.py +++ b/redbot/core/commands/__init__.py @@ -2,4 +2,6 @@ from discord.ext.commands import * from .commands import * from .context import * +from .converter import * from .errors import * +from .requires import * diff --git a/redbot/core/commands/commands.py b/redbot/core/commands/commands.py index 3d3a76361..448f570cc 100644 --- a/redbot/core/commands/commands.py +++ b/redbot/core/commands/commands.py @@ -5,33 +5,118 @@ replace those from the `discord.ext.commands` module. """ import inspect import weakref -from typing import Awaitable, Callable, TYPE_CHECKING +from typing import Awaitable, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING import discord from discord.ext import commands +from . import converter as converters from .errors import ConversionFailure +from .requires import PermState, PrivilegeLevel, Requires from ..i18n import Translator if TYPE_CHECKING: from .context import Context -__all__ = ["Command", "GroupMixin", "Group", "command", "group"] +__all__ = [ + "Cog", + "CogCommandMixin", + "CogGroupMixin", + "Command", + "Group", + "GroupMixin", + "command", + "group", +] _ = 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. 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): - self._help_override = kwargs.pop("help_override", None) super().__init__(*args, **kwargs) + self._help_override = kwargs.pop("help_override", None) self.translator = kwargs.pop("i18n", None) @property @@ -59,11 +144,10 @@ class Command(commands.Command): pass @property - def parents(self): - """ - Returns all parent commands of this command. + def parents(self) -> List["Group"]: + """List[Group] : 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. """ cmd = self.parent @@ -73,6 +157,33 @@ class Command(commands.Command): cmd = cmd.parent 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( self, ctx: "Context", converter, argument: str, param: inspect.Parameter ): @@ -179,8 +290,32 @@ class Command(commands.Command): else: 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. This class inherits from :class:`discord.ext.commands.GroupMixin`. @@ -211,7 +346,34 @@ class GroupMixin(commands.GroupMixin): 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. 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) super().__init__(*args, **kwargs) - async def invoke(self, ctx): + async def invoke(self, ctx: "Context"): view = ctx.view previous = view.index view.skip_ws() @@ -247,7 +409,12 @@ class Group(GroupMixin, Command, commands.Group): 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): diff --git a/redbot/core/commands/context.py b/redbot/core/commands/context.py index 4c5db1e38..65b5a862a 100644 --- a/redbot/core/commands/context.py +++ b/redbot/core/commands/context.py @@ -4,8 +4,9 @@ from typing import Iterable, List import discord from discord.ext import commands -from redbot.core.utils.chat_formatting import box -from redbot.core.utils import common_filters +from .requires import PermState +from ..utils.chat_formatting import box +from ..utils import common_filters TICK = "\N{WHITE HEAVY CHECK MARK}" @@ -20,6 +21,10 @@ class Context(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): """Sends a message to the destination with the content given. diff --git a/redbot/core/commands/converter.py b/redbot/core/commands/converter.py new file mode 100644 index 000000000..c58c584b7 --- /dev/null +++ b/redbot/core/commands/converter.py @@ -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 diff --git a/redbot/core/commands/errors.py b/redbot/core/commands/errors.py index 310bef464..5eb0b70bb 100644 --- a/redbot/core/commands/errors.py +++ b/redbot/core/commands/errors.py @@ -1,8 +1,9 @@ """Errors module for the commands package.""" import inspect +import discord from discord.ext import commands -__all__ = ["ConversionFailure"] +__all__ = ["ConversionFailure", "BotMissingPermissions"] class ConversionFailure(commands.BadArgument): @@ -13,3 +14,11 @@ class ConversionFailure(commands.BadArgument): self.argument = argument self.param = param 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) diff --git a/redbot/core/commands/requires.py b/redbot/core/commands/requires.py new file mode 100644 index 000000000..b5b4b8859 --- /dev/null +++ b/redbot/core/commands/requires.py @@ -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"" + ) + + +# 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) diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index d26cd6079..284ae48fe 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -241,7 +241,7 @@ class CoreLogic: @i18n.cog_i18n(_) -class Core(CoreLogic): +class Core(commands.Cog, CoreLogic): """Commands related to core functions""" def __init__(self, bot): diff --git a/redbot/core/dev_commands.py b/redbot/core/dev_commands.py index 09868f92a..58abb2463 100644 --- a/redbot/core/dev_commands.py +++ b/redbot/core/dev_commands.py @@ -25,10 +25,11 @@ _ = Translator("Dev", __file__) START_CODE_BLOCK_RE = re.compile(r"^((```py)(?=\s)|(```))") -class Dev: +class Dev(commands.Cog): """Various development focused utilities.""" def __init__(self): + super().__init__() self._last_result = None self.sessions = set() diff --git a/redbot/core/events.py b/redbot/core/events.py index 528aa89ba..e10fcaecb 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -5,6 +5,7 @@ import logging import traceback from datetime import timedelta from distutils.version import StrictVersion +from typing import List import aiohttp import discord @@ -14,7 +15,7 @@ from pkg_resources import DistributionNotFound from . import __version__, commands 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 log = logging.getLogger("red") @@ -67,6 +68,14 @@ def init_events(bot, cli_flags): packages.extend(cli_flags.load_cogs) if packages: + # Load permissions first, for security reasons + try: + packages.remove("permissions") + except ValueError: + pass + else: + packages.insert(0, "permissions") + to_remove = [] print("Loading 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)) else: 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): pass elif isinstance(error, commands.NoPrivateMessage): diff --git a/redbot/core/global_checks.py b/redbot/core/global_checks.py index 0f2b8672b..a84f8e2e1 100644 --- a/redbot/core/global_checks.py +++ b/redbot/core/global_checks.py @@ -3,7 +3,7 @@ from . import commands def init_global_checks(bot): - @bot.check + @bot.check_once async def global_perms(ctx): """Check the user is/isn't globally whitelisted/blacklisted.""" 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() - @bot.check + @bot.check_once async def local_perms(ctx: commands.Context): """Check the user is/isn't locally whitelisted/blacklisted.""" 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) - @bot.check + @bot.check_once async def bots(ctx): """Check the user is not another bot.""" return not ctx.author.bot diff --git a/redbot/core/utils/chat_formatting.py b/redbot/core/utils/chat_formatting.py index 13d3e9590..7e69ae12c 100644 --- a/redbot/core/utils/chat_formatting.py +++ b/redbot/core/utils/chat_formatting.py @@ -1,5 +1,8 @@ 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: @@ -317,3 +320,33 @@ def escape(text: str, *, mass_mentions: bool = False, formatting: bool = False) if formatting: text = text.replace("`", "\\`").replace("*", "\\*").replace("_", "\\_").replace("~", "\\~") 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] diff --git a/redbot/core/utils/mod.py b/redbot/core/utils/mod.py index aa3ab50e5..06480e25b 100644 --- a/redbot/core/utils/mod.py +++ b/redbot/core/utils/mod.py @@ -1,11 +1,13 @@ import asyncio from datetime import timedelta -from typing import List, Iterable, Union +from typing import List, Iterable, Union, TYPE_CHECKING, Dict import discord -from redbot.core import Config -from redbot.core.bot import Red +if TYPE_CHECKING: + from .. import Config + from ..bot import Red + from ..commands import Context 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( - 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(): return True @@ -95,7 +97,9 @@ async def is_allowed_by_hierarchy( 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. 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( - 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. @@ -225,3 +229,36 @@ async def is_admin_or_superior( return True else: 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() + ) diff --git a/redbot/pytest/permissions.py b/redbot/pytest/permissions.py new file mode 100644 index 000000000..e3a86d41d --- /dev/null +++ b/redbot/pytest/permissions.py @@ -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) diff --git a/setup.py b/setup.py index da72ca729..63b45257a 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,7 @@ requirements = [ "pyyaml==3.13", "raven==6.9.0", "raven-aiohttp==0.7.0", + "schema==0.6.8", "websockets==6.0", "yarl==1.2.6", ] diff --git a/tests/cogs/test_permissions.py b/tests/cogs/test_permissions.py new file mode 100644 index 000000000..40388cce3 --- /dev/null +++ b/tests/cogs/test_permissions.py @@ -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, + } + }, + }, + ) diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index 3f6056bfe..fddc3d822 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -101,7 +101,7 @@ async def test_bounded_gather(): if isinstance(result, RuntimeError): num_failed += 1 else: - assert result == i # verify original orde + assert result == i # verify_permissions original orde assert 0 <= result < num_tasks assert 0 < status[1] <= num_concurrent diff --git a/tox.ini b/tox.ini index f31542c95..2f34b0cc3 100644 --- a/tox.ini +++ b/tox.ini @@ -29,8 +29,9 @@ whitelist_externals = basepython = python3.6 extras = docs, mongo commands = - sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" -W -bhtml - sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" -W -blinkcheck + sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out/html" -W -bhtml + 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] description = Stylecheck the code with black to see if anything needs changes.