diff --git a/changelog.d/2976.breaking.rst b/changelog.d/2976.breaking.rst new file mode 100644 index 000000000..4f87f3b38 --- /dev/null +++ b/changelog.d/2976.breaking.rst @@ -0,0 +1 @@ +Removes bot._counter, Makes a few more attrs private (cog_mgr, main_dir) \ No newline at end of file diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py index 6451cb5fc..64c45908b 100644 --- a/redbot/cogs/downloader/downloader.py +++ b/redbot/cogs/downloader/downloader.py @@ -65,7 +65,7 @@ class Downloader(commands.Cog): The default cog install path. """ - return await self.bot.cog_mgr.install_path() + return await self.bot._cog_mgr.install_path() async def installed_cogs(self) -> Tuple[Installable]: """Get info on installed cogs. diff --git a/redbot/core/2976.feature.rst b/redbot/core/2976.feature.rst new file mode 100644 index 000000000..c5f76df18 --- /dev/null +++ b/redbot/core/2976.feature.rst @@ -0,0 +1,6 @@ +adds a few methods and classes replacing direct config access (which is no longer supported) + + - ``redbot.core.Red.allowed_by_whitelist_blacklist`` + - ``redbot.core.Red.get_valid_prefixes`` + - ``redbot.core.Red.clear_shared_api_tokens`` + - ``redbot.core.commands.help.HelpSettings`` \ No newline at end of file diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 87b24072b..ce25f9c55 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -2,11 +2,12 @@ import asyncio import inspect import logging import os -from collections import Counter +from collections import namedtuple +from datetime import datetime from enum import Enum from importlib.machinery import ModuleSpec from pathlib import Path -from typing import Optional, Union, List, Dict +from typing import Optional, Union, List, Dict, NoReturn import discord from discord.ext.commands import when_mentioned_or @@ -18,9 +19,14 @@ from .rpc import RPCMixin from .utils import common_filters CUSTOM_GROUPS = "CUSTOM_GROUPS" +SHARED_API_TOKENS = "SHARED_API_TOKENS" log = logging.getLogger("redbot") +__all__ = ["RedBase", "Red", "ExitCodes"] + +NotMessage = namedtuple("NotMessage", "guild") + def _is_submodule(parent, child): return parent == child or child.startswith(parent + ".") @@ -63,7 +69,6 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d invite_perm=0, disabled_commands=[], disabled_command_msg="That command is disabled.", - api_tokens={}, extra_owner_destinations=[], owner_opt_out_list=[], schema_version=0, @@ -87,6 +92,9 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d self._config.init_custom(CUSTOM_GROUPS, 2) self._config.register_custom(CUSTOM_GROUPS) + self._config.init_custom(SHARED_API_TOKENS, 2) + self._config.register_custom(SHARED_API_TOKENS) + async def prefix_manager(bot, message): if not cli_flags.prefix: global_prefix = await bot._config.prefix() @@ -117,14 +125,12 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d if "command_not_found" not in kwargs: kwargs["command_not_found"] = "Command {} not found.\n{}" - self._counter = Counter() self._uptime = None self._checked_time_accuracy = None self._color = discord.Embed.Empty # This is needed or color ends up 0x000000 - self.main_dir = bot_dir - - self.cog_mgr = CogManager() + self._main_dir = bot_dir + self._cog_mgr = CogManager() super().__init__(*args, help_command=None, **kwargs) # Do not manually use the help formatter attribute here, see `send_help_for`, @@ -134,9 +140,165 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d self._permissions_hooks: List[commands.CheckPredicate] = [] + @property + def cog_mgr(self) -> NoReturn: + raise AttributeError("Please don't mess with the cog manager internals.") + + @property + def uptime(self) -> datetime: + """ Allow access to the value, but we don't want cog creators setting it """ + return self._uptime + + @uptime.setter + def uptime(self, value) -> NoReturn: + raise RuntimeError( + "Hey, we're cool with sharing info about the uptime, but don't try and assign to it please." + ) + + @property + def db(self) -> NoReturn: + raise AttributeError( + "We really don't want you touching the bot config directly. " + "If you need something in here, take a look at the exposed methods " + "and use the one which corresponds to your needs or " + "open an issue if you need an additional method for your use case." + ) + + @property + def counter(self) -> NoReturn: + raise AttributeError( + "Please make your own counter object by importing ``Counter`` from ``collections``." + ) + + @property + def color(self) -> NoReturn: + raise AttributeError("Please fetch the embed color with `get_embed_color`") + + @property + def colour(self) -> NoReturn: + raise AttributeError("Please fetch the embed colour with `get_embed_colour`") + + async def allowed_by_whitelist_blacklist( + self, + who: Optional[Union[discord.Member, discord.User]] = None, + *, + who_id: Optional[int] = None, + guild_id: Optional[int] = None, + role_ids: Optional[List[int]] = None, + ) -> bool: + """ + This checks if a user or member is allowed to run things, + as considered by Red's whitelist and blacklist. + + If given a user object, this function will check the global lists + + If given a member, this will additionally check guild lists + + If omiting a user or member, you must provide a value for ``who_id`` + + You may also provide a value for ``guild_id`` in this case + + If providing a member by guild and member ids, + you should supply ``role_ids`` as well + + Parameters + ---------- + who : Optional[Union[discord.Member, discord.User]] + The user or member object to check + + Other Parameters + ---------------- + who_id : Optional[int] + The id of the user or member to check + If not providing a value for ``who``, this is a required parameter. + guild_id : Optional[int] + When used in conjunction with a provided value for ``who_id``, checks + the lists for the corresponding guild as well. + role_ids : Optional[List[int]] + When used with both ``who_id`` and ``guild_id``, checks the role ids provided. + This is required for accurate checking of members in a guild if providing ids. + + Raises + ------ + TypeError + Did not provide ``who`` or ``who_id`` + """ + # Contributor Note: + # All config calls are delayed until needed in this section + # All changes should be made keeping in mind that this is also used as a global check + + guild = None + mocked = False # used for an accurate delayed role id expansion later. + if not who: + if not who_id: + raise TypeError("Must provide a value for either `who` or `who_id`") + mocked = True + who = discord.Object(id=who_id) + if guild_id: + guild = discord.Object(id=guild_id) + else: + guild = getattr(who, "guild", None) + + if await self.is_owner(who): + return True + + global_whitelist = await self._config.whitelist() + if global_whitelist: + if who.id not in global_whitelist: + return False + else: + # blacklist is only used when whitelist doesn't exist. + global_blacklist = await self._config.blacklist() + if who.id in global_blacklist: + return False + + if guild: + # The delayed expansion of ids to check saves time in the DM case. + # Converting to a set reduces the total lookup time in section + if mocked: + ids = {i for i in (who.id, *(role_ids or [])) if i != guild.id} + else: + # DEP-WARN + # This uses member._roles (getattr is for the user case) + # If this is removed upstream (undocumented) + # there is a silent failure potential, and role blacklist/whitelists will break. + ids = {i for i in (who.id, *(getattr(who, "_roles", []))) if i != guild.id} + + guild_whitelist = await self._config.guild(guild).whitelist() + if guild_whitelist: + if ids.isdisjoint(guild_whitelist): + return False + else: + guild_blacklist = self._config.guild(guild).blacklist() + if not ids.isdisjoint(guild_blacklist): + return False + + return True + + async def get_valid_prefixes(self, guild: Optional[discord.Guild] = None) -> List[str]: + """ + This gets the valid prefixes for a guild. + + If not provided a guild (or passed None) it will give the DM prefixes. + + This is just a fancy wrapper around ``get_prefix`` + + Parameters + ---------- + guild : Optional[discord.Guild] + The guild you want prefixes for. Omit (or pass None) for the DM prefixes + + Returns + ------- + List[str] + If a guild was specified, the valid prefixes in that guild. + If a guild was not specified, the valid prefixes for DMs + """ + return await self.get_prefix(NotMessage(guild)) + async def get_embed_color(self, location: discord.abc.Messageable) -> discord.Color: """ - Get the embed color for a location. + Get the embed color for a location. This takes into account all related settings. Parameters ---------- @@ -170,6 +332,24 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d await self._schema_0_to_1() schema_version += 1 await self._config.schema_version.set(schema_version) + if schema_version == 1: + await self._schema_1_to_2() + schema_version += 1 + await self._config.schema_version.set(schema_version) + + async def _schema_1_to_2(self): + """ + This contains the migration of shared API tokens to a custom config scope + """ + + log.info("Moving shared API tokens to a custom group") + all_shared_api_tokens = await self._config.get_raw("api_tokens", default={}) + for service_name, token_mapping in all_shared_api_tokens.items(): + service_partial = self._config.custom(SHARED_API_TOKENS, service_name) + async with service_partial.all() as basically_bulk_update: + basically_bulk_update.update(token_mapping) + + await self._config.clear_raw("api_tokens") async def _schema_0_to_1(self): """ @@ -320,7 +500,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d A Mapping of token names to tokens. This mapping exists because some services have multiple tokens. """ - return await self._config.api_tokens.get_raw(service_name, default={}) + return await self._config.custom(SHARED_API_TOKENS, service_name).all() async def set_shared_api_tokens(self, service_name: str, **tokens: str): """ @@ -330,10 +510,44 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d ``set api`` command This will not clear existing values not specified. + + Parameters + ---------- + service_name: str + The service to set tokens for + **tokens + token_name -> token + + Examples + -------- + Setting the api_key for youtube from a value in a variable ``my_key`` + + >>> await ctx.bot.set_shared_api_tokens("youtube", api_key=my_key) """ - async with self._config.api_tokens.get_attr(service_name)() as method_abuse: - method_abuse.update(**tokens) + async with self._config.custom(SHARED_API_TOKENS, service_name).all() as group: + group.update(tokens) + + async def remove_shared_api_tokens(self, service_name: str, *token_names: str): + """ + Removes shared API tokens + + Parameters + ---------- + service_name: str + The service to remove tokens for + *token_names: str + The name of each token to be removed + + Examples + -------- + Removing the api_key for youtube + + >>> await ctx.bot.remove_shared_api_tokens("youtube", "api_key") + """ + async with self._config.custom(SHARED_API_TOKENS, service_name).all() as group: + for name in token_names: + group.pop(name, None) async def get_context(self, message, *, cls=commands.Context): return await super().get_context(message, cls=cls) diff --git a/redbot/core/cli.py b/redbot/core/cli.py index 41cd59b13..7320a640e 100644 --- a/redbot/core/cli.py +++ b/redbot/core/cli.py @@ -21,7 +21,7 @@ def interactive_config(red, token_set, prefix_set): print("That doesn't look like a valid token.") token = "" if token: - loop.run_until_complete(red.db.token.set(token)) + loop.run_until_complete(red._config.token.set(token)) if not prefix_set: prefix = "" @@ -39,7 +39,7 @@ def interactive_config(red, token_set, prefix_set): if not confirm("> "): prefix = "" if prefix: - loop.run_until_complete(red.db.prefix.set([prefix])) + loop.run_until_complete(red._config.prefix.set([prefix])) return token diff --git a/redbot/core/cog_manager.py b/redbot/core/cog_manager.py index 08ea72a24..492dbbc3d 100644 --- a/redbot/core/cog_manager.py +++ b/redbot/core/cog_manager.py @@ -317,7 +317,7 @@ class CogManagerUI(commands.Cog): """ Lists current cog paths in order of priority. """ - cog_mgr = ctx.bot.cog_mgr + cog_mgr = ctx.bot._cog_mgr install_path = await cog_mgr.install_path() core_path = cog_mgr.CORE_PATH cog_paths = await cog_mgr.user_defined_paths() @@ -344,7 +344,7 @@ class CogManagerUI(commands.Cog): return try: - await ctx.bot.cog_mgr.add_path(path) + await ctx.bot._cog_mgr.add_path(path) except ValueError as e: await ctx.send(str(e)) else: @@ -362,14 +362,14 @@ class CogManagerUI(commands.Cog): await ctx.send(_("Path numbers must be positive.")) return - cog_paths = await ctx.bot.cog_mgr.user_defined_paths() + cog_paths = await ctx.bot._cog_mgr.user_defined_paths() try: to_remove = cog_paths.pop(path_number) except IndexError: await ctx.send(_("That is an invalid path number.")) return - await ctx.bot.cog_mgr.remove_path(to_remove) + await ctx.bot._cog_mgr.remove_path(to_remove) await ctx.send(_("Path successfully removed.")) @commands.command() @@ -385,7 +385,7 @@ class CogManagerUI(commands.Cog): await ctx.send(_("Path numbers must be positive.")) return - all_paths = await ctx.bot.cog_mgr.user_defined_paths() + all_paths = await ctx.bot._cog_mgr.user_defined_paths() try: to_move = all_paths.pop(from_) except IndexError: @@ -398,7 +398,7 @@ class CogManagerUI(commands.Cog): await ctx.send(_("Invalid 'to' index.")) return - await ctx.bot.cog_mgr.set_paths(all_paths) + await ctx.bot._cog_mgr.set_paths(all_paths) await ctx.send(_("Paths reordered.")) @commands.command() @@ -413,14 +413,14 @@ class CogManagerUI(commands.Cog): """ if path: if not path.is_absolute(): - path = (ctx.bot.main_dir / path).resolve() + path = (ctx.bot._main_dir / path).resolve() try: - await ctx.bot.cog_mgr.set_install_path(path) + await ctx.bot._cog_mgr.set_install_path(path) except ValueError: await ctx.send(_("That path does not exist.")) return - install_path = await ctx.bot.cog_mgr.install_path() + install_path = await ctx.bot._cog_mgr.install_path() await ctx.send( _("The bot will install new cogs to the `{}` directory.").format(install_path) ) @@ -433,7 +433,7 @@ class CogManagerUI(commands.Cog): """ loaded = set(ctx.bot.extensions.keys()) - all_cogs = set(await ctx.bot.cog_mgr.available_modules()) + all_cogs = set(await ctx.bot._cog_mgr.available_modules()) unloaded = all_cogs - loaded diff --git a/redbot/core/commands/help.py b/redbot/core/commands/help.py index 6e47b3a0f..a4a4f1c32 100644 --- a/redbot/core/commands/help.py +++ b/redbot/core/commands/help.py @@ -1,3 +1,8 @@ +# Warning: The implementation below touches several private attributes. +# While this implementation will be updated, and public interfaces maintained, derived classes +# should not assume these private attributes are version safe, and use the provided HelpSettings +# class for these settings. + # This is a full replacement of discord.py's help command # # At a later date, there should be things added to support extra formatter @@ -29,6 +34,7 @@ import asyncio from collections import namedtuple +from dataclasses import dataclass from typing import Union, List, AsyncIterator, Iterable, cast import discord @@ -40,7 +46,7 @@ from ..i18n import Translator from ..utils import menus, fuzzy_command_search, format_fuzzy_results from ..utils.chat_formatting import box, pagify -__all__ = ["red_help", "RedHelpFormatter"] +__all__ = ["red_help", "RedHelpFormatter", "HelpSettings"] T_ = Translator("Help", __file__) @@ -53,6 +59,36 @@ EmbedField = namedtuple("EmbedField", "name value inline") EMPTY_STRING = "\N{ZERO WIDTH SPACE}" +@dataclass(frozen=True) +class HelpSettings: + """ + A representation of help settings. + """ + + page_char_limit: int = 1000 + max_pages_in_guild: int = 2 + use_menus: bool = False + show_hidden: bool = False + verify_checks: bool = True + verify_exists: bool = False + tagline: str = "" + + # Contrib Note: This is intentional to not accept the bot object + # There are plans to allow guild and user specific help settings + # Adding a non-context based method now would involve a breaking change later. + # At a later date, more methods should be exposed for non-context based creation. + # + # This is also why we aren't just caching the + # current state of these settings on the bot object. + @classmethod + async def from_context(cls, context: Context): + """ + Get the HelpSettings for the current context + """ + settings = await context.bot._config.help.all() + return cls(**settings) + + class NoCommand(Exception): pass diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 52ebffb3d..86278b297 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -87,7 +87,7 @@ class CoreLogic: for name in cog_names: try: - spec = await bot.cog_mgr.find_cog(name) + spec = await bot._cog_mgr.find_cog(name) if spec: cogspecs.append((spec, name)) else: @@ -1146,7 +1146,7 @@ class Core(commands.Cog, CoreLogic): """ if ctx.channel.permissions_for(ctx.me).manage_messages: await ctx.message.delete() - await ctx.bot._config.api_tokens.set_raw(service, value=tokens) + await ctx.bot.set_shared_api_tokens(service, **tokens) await ctx.send(_("`{service}` API tokens have been set.").format(service=service)) @commands.group() @@ -2198,7 +2198,7 @@ class Core(commands.Cog, CoreLogic): async def rpc_load(self, request): cog_name = request.params[0] - spec = await self.bot.cog_mgr.find_cog(cog_name) + spec = await self.bot._cog_mgr.find_cog(cog_name) if spec is None: raise LookupError("No such cog found.") diff --git a/redbot/core/dev_commands.py b/redbot/core/dev_commands.py index 1251ea2e8..729685d37 100644 --- a/redbot/core/dev_commands.py +++ b/redbot/core/dev_commands.py @@ -61,19 +61,10 @@ class Dev(commands.Cog): return pagify(msg, delims=["\n", " "], priority=True, shorten_by=10) @staticmethod - def sanitize_output(ctx: commands.Context, keys: dict, input_: str) -> str: + def sanitize_output(ctx: commands.Context, input_: str) -> str: """Hides the bot's token from a string.""" token = ctx.bot.http.token - r = "[EXPUNGED]" - result = input_.replace(token, r) - result = result.replace(token.lower(), r) - result = result.replace(token.upper(), r) - for provider, data in keys.items(): - for name, key in data.items(): - result = result.replace(key, r) - result = result.replace(key.upper(), r) - result = result.replace(key.lower(), r) - return result + return re.sub(re.escape(token), "[EXPUNGED]", input_, re.I) @commands.command() @checks.is_owner() @@ -125,9 +116,7 @@ class Dev(commands.Cog): result = await result self._last_result = result - - api_keys = await ctx.bot._config.api_tokens() - result = self.sanitize_output(ctx, api_keys, str(result)) + result = self.sanitize_output(ctx, str(result)) await ctx.send_interactive(self.get_pages(result), box_lang="py") @@ -191,8 +180,7 @@ class Dev(commands.Cog): msg = "{}{}".format(printed, result) else: msg = printed - api_keys = await ctx.bot._config.api_tokens() - msg = self.sanitize_output(ctx, api_keys, msg) + msg = self.sanitize_output(ctx, msg) await ctx.send_interactive(self.get_pages(msg), box_lang="py") @@ -276,8 +264,7 @@ class Dev(commands.Cog): elif value: msg = "{}".format(value) - api_keys = await ctx.bot._config.api_tokens() - msg = self.sanitize_output(ctx, api_keys, msg) + msg = self.sanitize_output(ctx, msg) try: await ctx.send_interactive(self.get_pages(msg), box_lang="py") diff --git a/redbot/core/events.py b/redbot/core/events.py index 7adb7f533..10521c557 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -67,7 +67,7 @@ def init_events(bot, cli_flags): print("Loading packages...") for package in packages: try: - spec = await bot.cog_mgr.find_cog(package) + spec = await bot._cog_mgr.find_cog(package) await bot.load_extension(spec) except Exception as e: log.exception("Failed to load package {}".format(package), exc_info=e) @@ -243,7 +243,6 @@ def init_events(bot, cli_flags): @bot.event async def on_message(message): - bot._counter["messages_read"] += 1 await bot.process_commands(message) discord_now = message.created_at if ( @@ -260,14 +259,6 @@ def init_events(bot, cli_flags): ) bot._checked_time_accuracy = discord_now - @bot.event - async def on_resumed(): - bot._counter["sessions_resumed"] += 1 - - @bot.event - async def on_command(command): - bot._counter["processed_commands"] += 1 - @bot.event async def on_command_add(command: commands.Command): disabled_commands = await bot._config.disabled_commands() diff --git a/redbot/core/global_checks.py b/redbot/core/global_checks.py index 7bdb5d1a9..df4000f7a 100644 --- a/redbot/core/global_checks.py +++ b/redbot/core/global_checks.py @@ -4,34 +4,8 @@ from . import commands def init_global_checks(bot): @bot.check_once - async def global_perms(ctx): - """Check the user is/isn't globally whitelisted/blacklisted.""" - if await bot.is_owner(ctx.author): - return True - - whitelist = await bot._config.whitelist() - if whitelist: - return ctx.author.id in whitelist - - return ctx.author.id not in await bot._config.blacklist() - - @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): - return True - elif ctx.guild is None: - return True - guild_settings = bot._config.guild(ctx.guild) - local_blacklist = await guild_settings.blacklist() - local_whitelist = await guild_settings.whitelist() - - _ids = [r.id for r in ctx.author.roles if not r.is_default()] - _ids.append(ctx.author.id) - if local_whitelist: - return any(i in local_whitelist for i in _ids) - - return not any(i in local_blacklist for i in _ids) + async def whiteblacklist_checks(ctx): + return await ctx.bot.allowed_by_whitelist_blacklist(ctx.author) @bot.check_once async def bots(ctx): diff --git a/redbot/pytest/cog_manager.py b/redbot/pytest/cog_manager.py index edd6499d0..118873061 100644 --- a/redbot/pytest/cog_manager.py +++ b/redbot/pytest/cog_manager.py @@ -5,9 +5,9 @@ __all__ = ["cog_mgr", "default_dir"] @pytest.fixture() def cog_mgr(red): - return red.cog_mgr + return red._cog_mgr @pytest.fixture() def default_dir(red): - return red.main_dir + return red._main_dir