Permissions redesign (#2149)

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

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

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

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

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
This commit is contained in:
Toby Harradine
2018-10-01 13:19:25 +10:00
committed by GitHub
parent f07b78bd0d
commit 0870403299
53 changed files with 2026 additions and 1044 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = {

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)
)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ log = logging.getLogger("red.reports")
@cog_i18n(_)
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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)