From febca8ccbb10d4a618a20c5a25df86ca3532acb0 Mon Sep 17 00:00:00 2001 From: jack1142 <6032823+jack1142@users.noreply.github.com> Date: Sun, 3 Apr 2022 03:21:20 +0200 Subject: [PATCH] Migration to discord.py 2.0 (#5600) * Temporarily set d.py to use latest git revision * Remove `bot` param to Client.start * Switch to aware datetimes A lot of this is removing `.replace(...)` which while not technically needed, simplifies the code base. There's only a few changes that are actually necessary here. * Update to work with new Asset design * [threads] Update core ModLog API to support threads - Added proper support for passing `Thread` to `channel` when creating/editing case - Added `parent_channel_id` attribute to Modlog API's Case - Added `parent_channel` property that tries to get parent channel - Updated case's content to show both thread and parent information * [threads] Disallow usage of threads in some of the commands - announceset channel - filter channel clear - filter channel add - filter channel remove - GlobalUniqueObjectFinder converter - permissions addglobalrule - permissions removeglobalrule - permissions removeserverrule - Permissions cog does not perform any validation for IDs when setting through YAML so that has not been touched - streamalert twitch/youtube/picarto - embedset channel - set ownernotifications adddestination * [threads] Handle threads in Red's permissions system (Requires) - Made permissions system apply rules of (only) parent in threads * [threads] Update embed_requested to support threads - Threads don't have their own embed settings and inherit from parent * [threads] Update Red.message_eligible_as_command to support threads * [threads] Properly handle invocation of [p](un)mutechannel in threads Usage of a (un)mutechannel will mute/unmute user in the parent channel if it's invoked in a thread. * [threads] Update Filter cog to properly handle threads - `[p]filter channel list` in a threads sends list for parent channel - Checking for filter hits for a message in a thread checks its parent channel's word list. There's no separate word list for threads. * [threads] Support threads in Audio cog - Handle threads being notify channels - Update type hint for `is_query_allowed()` * [threads] Update type hints and documentation to reflect thread support - Documented that `{channel}` in CCs might be a thread - Allowed (documented) usage of threads with `Config.channel()` - Separate thread scope is still in the picture though if it were to be done, it's going to be in separate in PR - GuildContext.channel might be Thread * Use less costy channel check in customcom's on_message_without_command This isn't needed for d.py 2.0 but whatever... * Update for in-place edits * Embed's bool changed behavior, I'm hoping it doesn't affect us * Address User.permissions_in() removal * Swap VerificationLevel.extreme with VerificationLevel.highest * Change to keyword-only parameters * Change of `Guild.vanity_invite()` return type * avatar -> display_avatar * Fix metaclass shenanigans with Converter * Update Red.add_cog() to be inline with `dpy_commands.Bot.add_cog()` This means adding `override` keyword-only parameter and causing small breakage by swapping RuntimeError with discord.ClientException. * Address all DEP-WARNs * Remove Context.clean_prefix and use upstream implementation instead * Remove commands.Literal and use upstream implementation instead Honestly, this was a rather bad implementation anyway... Breaking but actually not really - it was provisional. * Update Command.callback's setter Support for functools.partial is now built into d.py * Add new perms in HUMANIZED_PERM mapping (some from d.py 1.7 it seems) BTW, that should really be in core instead of what we have now... * Remove the part of do_conversion that has not worked for a long while * Stop wrapping BadArgument in ConversionFailure This is breaking but it's best to resolve it like this. The functionality of ConversionFailure can be replicated with Context.current_parameter and Context.current_argument. * Add custom errors for int and float converters * Remove Command.__call__ as it's now implemented in d.py * Get rid of _dpy_reimplements These were reimplemented for the purpose of typing so it is no longer needed now that d.py is type hinted. * Add return to Red.remove_cog * Ensure we don't delete messages that differ only by used sticker * discord.InvalidArgument->ValueError * Move from raw syntax to discord.utils.format_dt() * Address AsyncIter removal * Swap to pos-only for params that are pos-only in upstream * Update for changes to Command.params * [threads] Support threads in ignore checks and allow ignoring them - Updated `[p](un)ignore channel` to accept threads - Updated `[p]ignore list` to list ignored threads - Updated logic in `Red.ignored_channel_or_guild()` Ignores for guild channels now work as follows (only changes for threads): - if channel is not a thread: - check if user has manage channels perm in channel and allow command usage if so - check if channel is ignored and disallow command usage if so - allow command usage if none of the conditions above happened - if channel is a thread: - check if user has manage channels perm in parent channel and allow command usage if so - check if parent channel is ignored and disallow command usage if so - check if user has manage thread perm in parent channel and allow command usage if so - check if thread is ignored and disallow command usage if so - allow command usage if none of the conditions above happened * [partial] Raise TypeError when channel is of PartialMessageable type - Red.embed_requested - Red.ignored_channel_or_guild * [partial] Discard command messages when channel is PartialMessageable * [threads] Add utilities for checking appropriate perms in both channels & threads * [threads] Update code to use can_react_in() and @bot_can_react() * [threads] Update code to use can_send_messages_in * [threads] Add send_messages_in_threads perm to mute role and overrides * [threads] Update code to use (bot/user)_can_manage_channel * [threads] Update [p]diagnoseissues to work with threads * Type hint fix * [threads] Patch vendored discord.ext.menus to check proper perms in threads I guess we've reached time when we have to patch the lib we vendor... * Make docs generation work with non-final d.py releases * Update discord.utils.oauth_url() usage * Swap usage of discord.Embed.Empty/discord.embeds.EmptyEmbed to None * Update usage of Guild.member_count to work with `None` * Switch from Guild.vanity_invite() to Guild.vanity_url * Update startup process to work with d.py's new asynchronous startup * Use setup_hook() for pre-connect actions * Update core's add_cog, remove_cog, and load_extension methods * Update all setup functions to async and add awaits to bot.add_cog calls * Modernize cogs by using async cog_load and cog_unload * Address StoreChannel removal * [partial] Disallow passing PartialMessageable to Case.channel * [partial] Update cogs and utils to work better with PartialMessageable - Ignore messages with PartialMessageable channel in CustomCommands cog - In Filter cog, don't pass channel to modlog.create_case() if it's PartialMessageable - In Trivia cog, only compare channel IDs - Make `.utils.menus.menu()` work for messages with PartialMessageable channel - Make checks in `.utils.tunnel.Tunnel.communicate()` more rigid * Add few missing DEP-WARNs --- docs/cog_customcom.rst | 30 +-- docs/cog_guides/admin.rst | 6 +- docs/cog_guides/core.rst | 12 +- docs/cog_guides/customcommands.rst | 2 +- docs/cog_guides/mod.rst | 4 +- docs/cog_guides/mutes.rst | 4 +- docs/cog_guides/permissions.rst | 2 +- docs/cog_guides/reports.rst | 2 +- docs/cog_permissions.rst | 2 +- docs/conf.py | 16 +- docs/framework_checks.rst | 2 +- docs/framework_config.rst | 4 +- docs/framework_modlog.rst | 8 +- docs/framework_rpc.rst | 4 +- docs/framework_utils.rst | 2 +- docs/guide_cog_creation.rst | 4 +- redbot/__main__.py | 7 +- redbot/cogs/admin/__init__.py | 6 +- redbot/cogs/admin/admin.py | 21 +- redbot/cogs/alias/__init__.py | 6 +- redbot/cogs/alias/alias.py | 39 +-- redbot/cogs/alias/alias_entry.py | 2 +- redbot/cogs/audio/__init__.py | 4 +- redbot/cogs/audio/apis/interface.py | 2 +- redbot/cogs/audio/core/__init__.py | 1 - redbot/cogs/audio/core/abc.py | 4 +- redbot/cogs/audio/core/commands/audioset.py | 16 +- redbot/cogs/audio/core/commands/controller.py | 3 +- redbot/cogs/audio/core/commands/equalizer.py | 3 +- .../cogs/audio/core/commands/localtracks.py | 3 +- .../cogs/audio/core/commands/miscellaneous.py | 3 +- redbot/cogs/audio/core/commands/player.py | 16 +- redbot/cogs/audio/core/commands/playlists.py | 59 ++--- redbot/cogs/audio/core/commands/queue.py | 3 +- redbot/cogs/audio/core/events/cog.py | 2 +- redbot/cogs/audio/core/events/dpy.py | 52 ++-- redbot/cogs/audio/core/events/lavalink.py | 10 +- redbot/cogs/audio/core/tasks/startup.py | 4 +- .../cogs/audio/core/utilities/formatting.py | 3 +- .../audio/core/utilities/miscellaneous.py | 14 +- redbot/cogs/audio/core/utilities/player.py | 3 +- redbot/cogs/audio/core/utilities/playlists.py | 3 +- .../cogs/audio/core/utilities/validation.py | 2 +- redbot/cogs/cleanup/__init__.py | 4 +- redbot/cogs/cleanup/cleanup.py | 17 +- redbot/cogs/customcom/__init__.py | 6 +- redbot/cogs/customcom/customcom.py | 15 +- redbot/cogs/downloader/__init__.py | 6 +- redbot/cogs/downloader/downloader.py | 5 +- redbot/cogs/economy/__init__.py | 4 +- redbot/cogs/economy/economy.py | 4 +- redbot/cogs/filter/__init__.py | 4 +- redbot/cogs/filter/filter.py | 57 ++++- redbot/cogs/general/__init__.py | 6 +- redbot/cogs/general/general.py | 32 ++- redbot/cogs/image/__init__.py | 8 +- redbot/cogs/image/image.py | 16 +- redbot/cogs/mod/__init__.py | 6 +- redbot/cogs/mod/events.py | 6 +- redbot/cogs/mod/kickban.py | 60 ++--- redbot/cogs/mod/mod.py | 8 +- redbot/cogs/mod/names.py | 17 +- redbot/cogs/mod/slowmode.py | 6 +- redbot/cogs/modlog/__init__.py | 4 +- redbot/cogs/modlog/modlog.py | 20 +- redbot/cogs/mutes/__init__.py | 5 +- redbot/cogs/mutes/mutes.py | 129 +++++++--- redbot/cogs/mutes/voicemutes.py | 4 +- redbot/cogs/permissions/__init__.py | 6 +- redbot/cogs/permissions/converters.py | 2 +- redbot/cogs/permissions/permissions.py | 9 +- redbot/cogs/reports/__init__.py | 4 +- redbot/cogs/reports/reports.py | 4 +- redbot/cogs/streams/__init__.py | 7 +- redbot/cogs/streams/streams.py | 60 +++-- redbot/cogs/trivia/__init__.py | 7 +- redbot/cogs/trivia/session.py | 4 +- redbot/cogs/trivia/trivia.py | 14 +- redbot/cogs/warnings/__init__.py | 6 +- redbot/cogs/warnings/warnings.py | 8 +- redbot/core/_diagnoser.py | 94 +++++--- redbot/core/bot.py | 181 +++++++++----- redbot/core/commands/__init__.py | 60 +++-- redbot/core/commands/_dpy_reimplements.py | 137 ----------- redbot/core/commands/commands.py | 122 ++-------- redbot/core/commands/context.py | 16 +- redbot/core/commands/converter.py | 44 +--- redbot/core/commands/errors.py | 5 +- redbot/core/commands/help.py | 31 +-- redbot/core/commands/requires.py | 161 ++++++++++++- redbot/core/config.py | 4 +- redbot/core/core_commands.py | 100 ++++---- redbot/core/dev_commands.py | 3 +- redbot/core/events.py | 20 +- redbot/core/modlog.py | 123 +++++++--- redbot/core/settings_caches.py | 6 +- redbot/core/utils/__init__.py | 223 +++++++++++++++++- redbot/core/utils/_internal_utils.py | 2 + redbot/core/utils/menus.py | 5 +- redbot/core/utils/mod.py | 6 +- redbot/core/utils/predicates.py | 81 ++++--- redbot/core/utils/tunnel.py | 14 +- redbot/vendored/discord/ext/menus/__init__.py | 11 +- setup.cfg | 2 +- 104 files changed, 1427 insertions(+), 999 deletions(-) delete mode 100644 redbot/core/commands/_dpy_reimplements.py diff --git a/docs/cog_customcom.rst b/docs/cog_customcom.rst index 3b6d7c0f4..18e7347d2 100644 --- a/docs/cog_customcom.rst +++ b/docs/cog_customcom.rst @@ -19,7 +19,7 @@ Cooldowns You can set cooldowns for your custom commands. If a command is on cooldown, it will not be triggered. -You can set cooldowns per member or per channel, or set a cooldown guild-wide. You can also set multiple types of cooldown on a single custom command. All cooldowns must pass before the command will trigger. +You can set cooldowns per member or per thread/channel, or set a cooldown guild-wide. You can also set multiple types of cooldown on a single custom command. All cooldowns must pass before the command will trigger. ------------------ Context Parameters @@ -27,19 +27,19 @@ Context Parameters You can enhance your custom command's response by leaving spaces for the bot to substitute. -+-----------+----------------------------------------+ -| Argument | Substitute | -+===========+========================================+ -| {message} | The message the bot is responding to. | -+-----------+----------------------------------------+ -| {author} | The user who called the command. | -+-----------+----------------------------------------+ -| {channel} | The channel the command was called in. | -+-----------+----------------------------------------+ -| {server} | The server the command was called in. | -+-----------+----------------------------------------+ -| {guild} | Same as with {server}. | -+-----------+----------------------------------------+ ++-----------+--------------------------------------------------+ +| Argument | Substitute | ++===========+==================================================+ +| {message} | The message the bot is responding to. | ++-----------+--------------------------------------------------+ +| {author} | The user who called the command. | ++-----------+--------------------------------------------------+ +| {channel} | The channel or thread the command was called in. | ++-----------+--------------------------------------------------+ +| {server} | The server the command was called in. | ++-----------+--------------------------------------------------+ +| {guild} | Same as with {server}. | ++-----------+--------------------------------------------------+ You can further refine the response with dot notation. For example, {author.mention} will mention the user who called the command. @@ -81,7 +81,7 @@ Showing your own avatar .. code-block:: none - [p]customcom add simple avatar {author.avatar_url} + [p]customcom add simple avatar {author.display_avatar} [p]avatar https://cdn.discordapp.com/avatars/133801473317404673/be4c4a4fe47cb3e74c31a0504e7a295e.webp?size=1024 diff --git a/docs/cog_guides/admin.rst b/docs/cog_guides/admin.rst index 5c3c048d6..decf426d9 100644 --- a/docs/cog_guides/admin.rst +++ b/docs/cog_guides/admin.rst @@ -389,7 +389,7 @@ announceset channel .. code-block:: none - [p]announceset channel [channel] + [p]announceset channel **Description** @@ -397,8 +397,8 @@ Sets the channel where the bot owner announcements will be sent. **Arguments** -* ``[channel]``: The channel that will be used for bot announcements. - |channel-input| Defaults to where you typed the command. +* ````: The channel that will be used for bot announcements. + |channel-input| .. _admin-command-announceset-clearchannel: diff --git a/docs/cog_guides/core.rst b/docs/cog_guides/core.rst index ca26f27d6..9045c4efc 100644 --- a/docs/cog_guides/core.rst +++ b/docs/cog_guides/core.rst @@ -1842,9 +1842,9 @@ ignore channel **Description** -Ignore commands in the channel or category. +Ignore commands in the channel, thread, or category. -Defaults to the current channel. +Defaults to the current thread or channel. .. Note:: Owners, Admins, and those with Manage Channel permissions override ignored channels. @@ -1856,7 +1856,7 @@ Defaults to the current channel. - ``[p]ignore channel 356236713347252226`` - Also accepts IDs. **Arguments:** - - ```` - The channel to ignore. Can be a category channel. + - ```` - The channel to ignore. This can also be a thread or category channel. .. _core-command-ignore-list: @@ -4045,9 +4045,9 @@ unignore channel **Description** -Remove a channel or category from the ignore list. +Remove a channel, thread, or category from the ignore list. -Defaults to the current channel. +Defaults to the current thread or channel. **Examples:** - ``[p]unignore channel #general`` - Unignores commands in the #general channel. @@ -4056,7 +4056,7 @@ Defaults to the current channel. - ``[p]unignore channel 356236713347252226`` - Also accepts IDs. Use this method to unignore categories. **Arguments:** - - ```` - The channel to unignore. This can be a category channel. + - ```` - The channel to unignore. This can also be a thread or category channel. .. _core-command-unignore-server: diff --git a/docs/cog_guides/customcommands.rst b/docs/cog_guides/customcommands.rst index 1a947702e..c4c3ee32f 100644 --- a/docs/cog_guides/customcommands.rst +++ b/docs/cog_guides/customcommands.rst @@ -68,7 +68,7 @@ customcom cooldown Set, edit, or view the cooldown for a custom command. -You may set cooldowns per member, channel, or guild. Multiple +You may set cooldowns per member, thread/channel, or guild. Multiple cooldowns may be set. All cooldowns must be cooled to call the custom command. diff --git a/docs/cog_guides/mod.rst b/docs/cog_guides/mod.rst index 989a8b2e3..9c37d3644 100644 --- a/docs/cog_guides/mod.rst +++ b/docs/cog_guides/mod.rst @@ -574,14 +574,14 @@ slowmode **Description** -Changes channel's slowmode setting. +Changes thread's or channel's slowmode setting. Interval can be anything from 0 seconds to 6 hours. Use without parameters to disable. **Arguments** -* ``[interval=0:00:00]``: The time for the channel's slowmode settings. +* ``[interval=0:00:00]``: The time for the thread's/channel's slowmode settings. .. note:: Interval can be anything from 0 seconds to 6 hours. diff --git a/docs/cog_guides/mutes.rst b/docs/cog_guides/mutes.rst index c0ab4c3d7..cbab3fba5 100644 --- a/docs/cog_guides/mutes.rst +++ b/docs/cog_guides/mutes.rst @@ -91,7 +91,7 @@ mutechannel **Description** -Mute a user in the current text channel. +Mute a user in the current text channel (or in the parent of the current thread). Examples: @@ -355,7 +355,7 @@ unmutechannel **Description** -Unmute a user in this channel. +Unmute a user in this channel (or in the parent of this thread). **Arguments** diff --git a/docs/cog_guides/permissions.rst b/docs/cog_guides/permissions.rst index b392b46c4..57b6c04d8 100644 --- a/docs/cog_guides/permissions.rst +++ b/docs/cog_guides/permissions.rst @@ -29,7 +29,7 @@ Global rules (set by the owner) are checked first, then rules set for servers. I 1. Rules about a user. 2. Rules about the voice channel a user is in. -3. Rules about the text channel a command was issued in. +3. Rules about the text channel or a parent of the thread a command was issued in. 4. Rules about a role the user has (The highest role they have with a rule will be used). 5. Rules about the server a user is in (Global rules only). diff --git a/docs/cog_guides/reports.rst b/docs/cog_guides/reports.rst index 8a3561e27..24d429cf5 100644 --- a/docs/cog_guides/reports.rst +++ b/docs/cog_guides/reports.rst @@ -73,7 +73,7 @@ report interact Open a message tunnel. -This tunnel will forward things you say in this channel +This tunnel will forward things you say in this channel or thread to the ticket opener's direct messages. Tunnels do not persist across bot restarts. diff --git a/docs/cog_permissions.rst b/docs/cog_permissions.rst index 1ae6a7a04..dbbe9a7fc 100644 --- a/docs/cog_permissions.rst +++ b/docs/cog_permissions.rst @@ -32,7 +32,7 @@ For each of those, the first rule pertaining to one of the following models will 1. User 2. Voice channel -3. Text channel +3. Text channel (parent text channel in case of invocations in threads) 4. Channel category 5. Roles, highest to lowest 6. Server (can only be in global rules) diff --git a/docs/conf.py b/docs/conf.py index a93ad0545..99dad0857 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -70,7 +70,7 @@ author = "Cog Creators" # built documents. # from redbot.core import __version__ -from discord import __version__ as dpy_version +from discord import __version__ as dpy_version, version_info as dpy_version_info # The short X.Y version. version = __version__ @@ -225,10 +225,20 @@ linkcheck_retries = 3 # -- Options for extensions ----------------------------------------------- +if dpy_version_info.releaselevel == "final": + # final release - versioned docs should be available + dpy_docs_url = f"https://discordpy.readthedocs.io/en/v{dpy_version}/" +elif dpy_version_info.minor == dpy_version_info.micro == 0: + # alpha release of a new major version - `master` version of docs should be used + dpy_docs_url = "https://discordpy.readthedocs.io/en/master/" +else: + # alpha release of a new minor or micro version - `latest` version of docs should be used + dpy_docs_url = "https://discordpy.readthedocs.io/en/latest/" + # Intersphinx intersphinx_mapping = { "python": ("https://docs.python.org/3", None), - "dpy": (f"https://discordpy.readthedocs.io/en/v{dpy_version}/", None), + "dpy": (dpy_docs_url, None), "motor": ("https://motor.readthedocs.io/en/stable/", None), "babel": ("http://babel.pocoo.org/en/stable/", None), "dateutil": ("https://dateutil.readthedocs.io/en/stable/", None), @@ -238,7 +248,7 @@ intersphinx_mapping = { # This allows to create links to d.py docs with # :dpy_docs:`link text ` extlinks = { - "dpy_docs": (f"https://discordpy.readthedocs.io/en/v{dpy_version}/%s", None), + "dpy_docs": (f"{dpy_docs_url}/%s", None), "issue": ("https://github.com/Cog-Creators/Red-DiscordBot/issues/%s", "#"), "ghuser": ("https://github.com/%s", "@"), } diff --git a/docs/framework_checks.rst b/docs/framework_checks.rst index e48551ad6..6fe3b5cc4 100644 --- a/docs/framework_checks.rst +++ b/docs/framework_checks.rst @@ -8,4 +8,4 @@ The following are all decorators for commands, which add restrictions to where a run. .. automodule:: redbot.core.commands - :members: permissions_check, bot_has_permissions, bot_in_a_guild, has_permissions, has_guild_permissions, is_owner, guildowner, guildowner_or_permissions, admin, admin_or_permissions, mod, mod_or_permissions + :members: permissions_check, bot_has_permissions, bot_in_a_guild, bot_can_manage_channel, bot_can_react, has_permissions, can_manage_channel, has_guild_permissions, is_owner, guildowner, guildowner_or_can_manage_channel, guildowner_or_permissions, admin, admin_or_can_manage_channel, admin_or_permissions, mod, mod_or_can_manage_channel, mod_or_permissions diff --git a/docs/framework_config.rst b/docs/framework_config.rst index 7087ecc74..2827cd582 100644 --- a/docs/framework_config.rst +++ b/docs/framework_config.rst @@ -161,7 +161,7 @@ Here is an example of the :code:`async with` syntax: * :py:meth:`Config.member` which takes :py:class:`discord.Member`. * :py:meth:`Config.user` which takes :py:class:`discord.User`. * :py:meth:`Config.role` which takes :py:class:`discord.Role`. - * :py:meth:`Config.channel` which takes :py:class:`discord.TextChannel`. + * :py:meth:`Config.channel` which takes :py:class:`discord.abc.GuildChannel` or :py:class:`discord.Thread`. If you need to wipe data from the config, you want to look at :py:meth:`Group.clear`, or :py:meth:`Config.clear_all` and similar methods, such as :py:meth:`Config.clear_all_guilds`. @@ -467,7 +467,7 @@ much the same way they would in V2. The following examples will demonstrate how async def setup(bot): cog = ExampleCog() await cog.load_data() - bot.add_cog(cog) + await bot.add_cog(cog) ************************************ Best practices and performance notes diff --git a/docs/framework_modlog.rst b/docs/framework_modlog.rst index 39d1b7b48..59ee2a27b 100644 --- a/docs/framework_modlog.rst +++ b/docs/framework_modlog.rst @@ -35,8 +35,7 @@ Basic Usage Registering Case types ********************** -To register case types, use an asynchronous ``initialize()`` method and call -it from your setup function: +To register case types, use a special ``cog_load()`` method which is called when you add a cog: .. code-block:: python @@ -46,7 +45,7 @@ it from your setup function: class MyCog(commands.Cog): - async def initialize(self): + async def cog_load(self): await self.register_casetypes() @staticmethod @@ -87,8 +86,7 @@ it from your setup function: async def setup(bot): cog = MyCog() - await cog.initialize() - bot.add_cog(cog) + await bot.add_cog(cog) .. important:: Image should be the emoji you want to represent your case type with. diff --git a/docs/framework_rpc.rst b/docs/framework_rpc.rst index ed8299060..45757b128 100644 --- a/docs/framework_rpc.rst +++ b/docs/framework_rpc.rst @@ -20,9 +20,9 @@ Examples .. code-block:: Python - def setup(bot): + async def setup(bot): c = Cog() - bot.add_cog(c) + await bot.add_cog(c) bot.register_rpc_handler(c.rpc_method) ******************************* diff --git a/docs/framework_utils.rst b/docs/framework_utils.rst index 1e869a9e1..8ed4d9225 100644 --- a/docs/framework_utils.rst +++ b/docs/framework_utils.rst @@ -8,7 +8,7 @@ General Utility =============== .. automodule:: redbot.core.utils - :members: deduplicate_iterables, bounded_gather, bounded_gather_iter, get_end_user_data_statement, get_end_user_data_statement_or_raise + :members: deduplicate_iterables, bounded_gather, bounded_gather_iter, get_end_user_data_statement, get_end_user_data_statement_or_raise, can_user_send_messages_in, can_user_manage_channel, can_user_react_in .. autoclass:: AsyncIter :members: diff --git a/docs/guide_cog_creation.rst b/docs/guide_cog_creation.rst index 11d724f9a..0bde5a68a 100644 --- a/docs/guide_cog_creation.rst +++ b/docs/guide_cog_creation.rst @@ -102,8 +102,8 @@ Open :code:`__init__.py`. In that file, place the following: from .mycog import MyCog - def setup(bot): - bot.add_cog(MyCog(bot)) + async def setup(bot): + await bot.add_cog(MyCog(bot)) Make sure that both files are saved. diff --git a/redbot/__main__.py b/redbot/__main__.py index 7cfc7a9a2..40e3208ad 100644 --- a/redbot/__main__.py +++ b/redbot/__main__.py @@ -378,10 +378,10 @@ async def run_bot(red: Red, cli_flags: Namespace) -> None: sys.exit(1) if cli_flags.dry_run: - await red.http.close() sys.exit(0) try: - await red.start(token, bot=True) + # `async with red:` is unnecessary here because we call red.close() in shutdown handler + await red.start(token) except discord.LoginFailure: log.critical("This token doesn't seem to be valid.") db_token = await red._config.token() @@ -451,7 +451,8 @@ async def shutdown_handler(red, signal_type=None, exit_code=None): red._shutdown_mode = exit_code try: - await red.close() + if not red.is_closed(): + await red.close() finally: # Then cancels all outstanding tasks other than ourselves pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] diff --git a/redbot/cogs/admin/__init__.py b/redbot/cogs/admin/__init__.py index 5c96f762c..024428346 100644 --- a/redbot/cogs/admin/__init__.py +++ b/redbot/cogs/admin/__init__.py @@ -1,5 +1,7 @@ +from redbot.core.bot import Red + from .admin import Admin -def setup(bot): - bot.add_cog(Admin(bot)) +async def setup(bot: Red) -> None: + await bot.add_cog(Admin(bot)) diff --git a/redbot/cogs/admin/admin.py b/redbot/cogs/admin/admin.py index 48de0e2d4..142d627f0 100644 --- a/redbot/cogs/admin/admin.py +++ b/redbot/cogs/admin/admin.py @@ -84,12 +84,9 @@ class Admin(commands.Cog): ) self.__current_announcer = None - self._ready = asyncio.Event() - asyncio.create_task(self.handle_migrations()) - # As this is a data migration, don't store this for cancelation. - async def cog_before_invoke(self, ctx: commands.Context): - await self._ready.wait() + async def cog_load(self) -> None: + await self.handle_migrations() async def red_delete_data_for_user(self, **kwargs): """Nothing to delete""" @@ -106,9 +103,7 @@ class Admin(commands.Cog): await self.migrate_config_from_0_to_1() await self.config.schema_version.set(1) - self._ready.set() - - async def migrate_config_from_0_to_1(self): + async def migrate_config_from_0_to_1(self) -> None: all_guilds = await self.config.all_guilds() for guild_id, guild_data in all_guilds.items(): @@ -354,14 +349,8 @@ class Admin(commands.Cog): pass @announceset.command(name="channel") - async def announceset_channel(self, ctx, *, channel: discord.TextChannel = None): - """ - Change the channel where the bot will send announcements. - - If channel is left blank it defaults to the current channel. - """ - if channel is None: - channel = ctx.channel + async def announceset_channel(self, ctx, *, channel: discord.TextChannel): + """Change the channel where the bot will send announcements.""" await self.config.guild(ctx.guild).announce_channel.set(channel.id) await ctx.send( _("The announcement channel has been set to {channel.mention}").format(channel=channel) diff --git a/redbot/cogs/alias/__init__.py b/redbot/cogs/alias/__init__.py index 3bdd12415..c7784cbd1 100644 --- a/redbot/cogs/alias/__init__.py +++ b/redbot/cogs/alias/__init__.py @@ -2,7 +2,5 @@ from .alias import Alias from redbot.core.bot import Red -async def setup(bot: Red): - cog = Alias(bot) - bot.add_cog(cog) - cog.sync_init() +async def setup(bot: Red) -> None: + await bot.add_cog(Alias(bot)) diff --git a/redbot/cogs/alias/alias.py b/redbot/cogs/alias/alias.py index bf893564e..421a5ff5f 100644 --- a/redbot/cogs/alias/alias.py +++ b/redbot/cogs/alias/alias.py @@ -50,7 +50,12 @@ class Alias(commands.Cog): self.config.register_global(entries=[], handled_string_creator=False) self.config.register_guild(entries=[]) self._aliases: AliasCache = AliasCache(config=self.config, cache_enabled=True) - self._ready_event = asyncio.Event() + + async def cog_load(self) -> None: + await self._maybe_handle_string_keys() + + if not self._aliases._loaded: + await self._aliases.load_aliases() async def red_delete_data_for_user( self, @@ -61,12 +66,8 @@ class Alias(commands.Cog): if requester != "discord_deleted_user": return - await self._ready_event.wait() await self._aliases.anonymize_aliases(user_id) - async def cog_before_invoke(self, ctx): - await self._ready_event.wait() - async def _maybe_handle_string_keys(self): # This isn't a normal schema migration because it's being added # after the fact for GH-3788 @@ -119,28 +120,6 @@ class Alias(commands.Cog): await self.config.handled_string_creator.set(True) - def sync_init(self): - t = asyncio.create_task(self._initialize()) - - def done_callback(fut: asyncio.Future): - try: - t.result() - except Exception as exc: - log.exception("Failed to load alias cog", exc_info=exc) - # Maybe schedule extension unloading with message to owner in future - - t.add_done_callback(done_callback) - - async def _initialize(self): - """Should only ever be a task""" - - await self._maybe_handle_string_keys() - - if not self._aliases._loaded: - await self._aliases.load_aliases() - - self._ready_event.set() - def is_command(self, alias_name: str) -> bool: """ The logic here is that if this returns true, the name should not be used for an alias @@ -461,7 +440,7 @@ class Alias(commands.Cog): @alias.command(name="list") @commands.guild_only() - @checks.bot_has_permissions(add_reactions=True) + @commands.bot_can_react() async def _list_alias(self, ctx: commands.Context): """List the available aliases on this server.""" guild_aliases = await self._aliases.get_guild_aliases(ctx.guild) @@ -470,7 +449,7 @@ class Alias(commands.Cog): await self.paginate_alias_list(ctx, guild_aliases) @global_.command(name="list") - @checks.bot_has_permissions(add_reactions=True) + @commands.bot_can_react() async def _list_global_alias(self, ctx: commands.Context): """List the available global aliases on this bot.""" global_aliases = await self._aliases.get_global_aliases() @@ -480,8 +459,6 @@ class Alias(commands.Cog): @commands.Cog.listener() async def on_message_without_command(self, message: discord.Message): - await self._ready_event.wait() - if message.guild is not None: if await self.bot.cog_disabled_in_guild(self, message.guild): return diff --git a/redbot/cogs/alias/alias_entry.py b/redbot/cogs/alias/alias_entry.py index cbc015503..49cf224ab 100644 --- a/redbot/cogs/alias/alias_entry.py +++ b/redbot/cogs/alias/alias_entry.py @@ -2,7 +2,7 @@ from typing import Tuple, Dict, Optional, List, Union from re import findall import discord -from discord.ext.commands.view import StringView +from discord.ext.commands.view import StringView # DEP-WARN from redbot.core import commands, Config from redbot.core.i18n import Translator from redbot.core.utils import AsyncIter diff --git a/redbot/cogs/audio/__init__.py b/redbot/cogs/audio/__init__.py index 8ad61af23..80278f7ec 100644 --- a/redbot/cogs/audio/__init__.py +++ b/redbot/cogs/audio/__init__.py @@ -3,7 +3,7 @@ from redbot.core.bot import Red from .core import Audio -def setup(bot: Red): +async def setup(bot: Red) -> None: cog = Audio(bot) - bot.add_cog(cog) + await bot.add_cog(cog) cog.start_up_task() diff --git a/redbot/cogs/audio/apis/interface.py b/redbot/cogs/audio/apis/interface.py index 636cece65..1fa55ab0f 100644 --- a/redbot/cogs/audio/apis/interface.py +++ b/redbot/cogs/audio/apis/interface.py @@ -973,7 +973,7 @@ class AudioAPIInterface: and not query.local_track_path.exists() ): continue - notify_channel = player.guild.get_channel(notify_channel_id) + notify_channel = player.guild.get_channel_or_thread(notify_channel_id) if not await self.cog.is_query_allowed( self.config, notify_channel, diff --git a/redbot/cogs/audio/core/__init__.py b/redbot/cogs/audio/core/__init__.py index 3d436423f..d78d4c56b 100644 --- a/redbot/cogs/audio/core/__init__.py +++ b/redbot/cogs/audio/core/__init__.py @@ -74,7 +74,6 @@ class Audio( self.permission_cache = discord.Permissions( embed_links=True, read_messages=True, - send_messages=True, read_message_history=True, add_reactions=True, ) diff --git a/redbot/cogs/audio/core/abc.py b/redbot/cogs/audio/core/abc.py index 0e3d4e357..5e4d27a9c 100644 --- a/redbot/cogs/audio/core/abc.py +++ b/redbot/cogs/audio/core/abc.py @@ -196,7 +196,7 @@ class MixinMeta(ABC): async def is_query_allowed( self, config: Config, - ctx_or_channel: Optional[Union[Context, discord.TextChannel]], + ctx_or_channel: Optional[Union[Context, discord.TextChannel, discord.Thread]], query: str, query_obj: Query, ) -> bool: @@ -250,7 +250,7 @@ class MixinMeta(ABC): raise NotImplementedError() @abstractmethod - def _has_notify_perms(self, channel: discord.TextChannel) -> bool: + def _has_notify_perms(self, channel: Union[discord.TextChannel, discord.Thread]) -> bool: raise NotImplementedError() @abstractmethod diff --git a/redbot/cogs/audio/core/commands/audioset.py b/redbot/cogs/audio/core/commands/audioset.py index a23949019..2d4453af7 100644 --- a/redbot/cogs/audio/core/commands/audioset.py +++ b/redbot/cogs/audio/core/commands/audioset.py @@ -78,7 +78,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): ) @command_audioset_perms_global_whitelist.command(name="list") - @commands.bot_has_permissions(add_reactions=True) + @commands.bot_can_react() async def command_audioset_perms_global_whitelist_list(self, ctx: commands.Context): """List all keywords added to the whitelist.""" whitelist = await self.config.url_keyword_whitelist() @@ -172,7 +172,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): ) @command_audioset_perms_global_blacklist.command(name="list") - @commands.bot_has_permissions(add_reactions=True) + @commands.bot_can_react() async def command_audioset_perms_global_blacklist_list(self, ctx: commands.Context): """List all keywords added to the blacklist.""" blacklist = await self.config.url_keyword_blacklist() @@ -268,7 +268,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): ) @command_audioset_perms_whitelist.command(name="list") - @commands.bot_has_permissions(add_reactions=True) + @commands.bot_can_react() async def command_audioset_perms_whitelist_list(self, ctx: commands.Context): """List all keywords added to the whitelist.""" whitelist = await self.config.guild(ctx.guild).url_keyword_whitelist() @@ -361,7 +361,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): ) @command_audioset_perms_blacklist.command(name="list") - @commands.bot_has_permissions(add_reactions=True) + @commands.bot_can_react() async def command_audioset_perms_blacklist_list(self, ctx: commands.Context): """List all keywords added to the blacklist.""" blacklist = await self.config.guild(ctx.guild).url_keyword_blacklist() @@ -453,7 +453,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): await self.set_player_settings(ctx) @command_audioset_autoplay.command(name="playlist", usage=" [args]") - @commands.bot_has_permissions(add_reactions=True) + @commands.bot_can_react() async def command_audioset_autoplay_playlist( self, ctx: commands.Context, @@ -496,9 +496,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -782,7 +780,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): @command_audioset.command(name="localpath") @commands.is_owner() - @commands.bot_has_permissions(add_reactions=True) + @commands.bot_can_react() async def command_audioset_localpath(self, ctx: commands.Context, *, local_path=None): """Set the localtracks path if the Lavalink.jar is not run from the Audio data folder. diff --git a/redbot/cogs/audio/core/commands/controller.py b/redbot/cogs/audio/core/commands/controller.py index 040990c19..b12b0a318 100644 --- a/redbot/cogs/audio/core/commands/controller.py +++ b/redbot/cogs/audio/core/commands/controller.py @@ -81,7 +81,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): @commands.command(name="now") @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) + @commands.bot_has_permissions(embed_links=True) + @commands.bot_can_react() async def command_now(self, ctx: commands.Context): """Now playing.""" if not self._player_check(ctx): diff --git a/redbot/cogs/audio/core/commands/equalizer.py b/redbot/cogs/audio/core/commands/equalizer.py index cab7fcd45..808188e8e 100644 --- a/redbot/cogs/audio/core/commands/equalizer.py +++ b/redbot/cogs/audio/core/commands/equalizer.py @@ -25,7 +25,8 @@ class EqualizerCommands(MixinMeta, metaclass=CompositeMetaClass): @commands.group(name="eq", invoke_without_command=True) @commands.guild_only() @commands.cooldown(1, 15, commands.BucketType.guild) - @commands.bot_has_permissions(embed_links=True, add_reactions=True) + @commands.bot_has_permissions(embed_links=True) + @commands.bot_can_react() async def command_equalizer(self, ctx: commands.Context): """Equalizer management. diff --git a/redbot/cogs/audio/core/commands/localtracks.py b/redbot/cogs/audio/core/commands/localtracks.py index e72f3d4c1..c364520f4 100644 --- a/redbot/cogs/audio/core/commands/localtracks.py +++ b/redbot/cogs/audio/core/commands/localtracks.py @@ -21,7 +21,8 @@ _ = Translator("Audio", Path(__file__)) class LocalTrackCommands(MixinMeta, metaclass=CompositeMetaClass): @commands.group(name="local") @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) + @commands.bot_has_permissions(embed_links=True) + @commands.bot_can_react() async def command_local(self, ctx: commands.Context): """Local playback commands.""" diff --git a/redbot/cogs/audio/core/commands/miscellaneous.py b/redbot/cogs/audio/core/commands/miscellaneous.py index 9b55f5059..1422d55ad 100644 --- a/redbot/cogs/audio/core/commands/miscellaneous.py +++ b/redbot/cogs/audio/core/commands/miscellaneous.py @@ -41,7 +41,8 @@ class MiscellaneousCommands(MixinMeta, metaclass=CompositeMetaClass): @commands.command(name="audiostats") @commands.guild_only() @commands.is_owner() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) + @commands.bot_has_permissions(embed_links=True) + @commands.bot_can_react() async def command_audiostats(self, ctx: commands.Context): """Audio stats.""" server_num = len(lavalink.active_players()) diff --git a/redbot/cogs/audio/core/commands/player.py b/redbot/cogs/audio/core/commands/player.py index 3e03f02e6..b425b80f9 100644 --- a/redbot/cogs/audio/core/commands/player.py +++ b/redbot/cogs/audio/core/commands/player.py @@ -9,7 +9,6 @@ import discord import lavalink from red_commons.logging import getLogger -from discord.embeds import EmptyEmbed from lavalink import NodeNotFound from redbot.core import commands @@ -67,7 +66,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink node has failed") - desc = EmptyEmbed + desc = None if await self.bot.is_owner(ctx.author): desc = _("Please check your console or logs for details.") return await self.send_embed_msg(ctx, title=msg, description=desc) @@ -175,7 +174,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink node has failed") - desc = EmptyEmbed + desc = None if await self.bot.is_owner(ctx.author): desc = _("Please check your console or logs for details.") return await self.send_embed_msg(ctx, title=msg, description=desc) @@ -438,7 +437,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink node has failed") - desc = EmptyEmbed + desc = None if await self.bot.is_owner(ctx.author): desc = _("Please check your console or logs for details.") return await self.send_embed_msg(ctx, title=msg, description=desc) @@ -554,7 +553,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink node has failed") - desc = EmptyEmbed + desc = None if await self.bot.is_owner(ctx.author): desc = _("Please check your console or logs for details.") return await self.send_embed_msg(ctx, title=msg, description=desc) @@ -610,7 +609,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): except DatabaseError: notify_channel = player.fetch("notify_channel") if notify_channel: - notify_channel = ctx.guild.get_channel(notify_channel) + notify_channel = ctx.guild.get_channel_or_thread(notify_channel) await self.send_embed_msg(notify_channel, title=_("Couldn't get a valid track.")) return except TrackEnqueueError: @@ -636,7 +635,8 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): @commands.command(name="search") @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) + @commands.bot_has_permissions(embed_links=True) + @commands.bot_can_react() async def command_search(self, ctx: commands.Context, *, query: str): """Pick a track with a search. @@ -678,7 +678,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink has failed") - desc = EmptyEmbed + desc = None if await self.bot.is_owner(ctx.author): desc = _("Please check your console or logs for details.") return await self.send_embed_msg(ctx, title=msg, description=desc) diff --git a/redbot/cogs/audio/core/commands/playlists.py b/redbot/cogs/audio/core/commands/playlists.py index 3a0276f71..5871368bd 100644 --- a/redbot/cogs/audio/core/commands/playlists.py +++ b/redbot/cogs/audio/core/commands/playlists.py @@ -38,7 +38,8 @@ _ = Translator("Audio", Path(__file__)) class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): @commands.group(name="playlist") @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) + @commands.bot_has_permissions(embed_links=True) + @commands.bot_can_react() async def command_playlist(self, ctx: commands.Context): """Playlist configuration options. @@ -263,9 +264,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [ @@ -392,9 +391,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -471,9 +468,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -561,9 +556,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) async with ctx.typing(): if scope_data is None: @@ -696,9 +689,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -829,9 +820,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -952,9 +941,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -1107,9 +1094,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) async with ctx.typing(): if scope_data is None: @@ -1217,9 +1202,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -1335,9 +1318,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -1461,9 +1442,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -1639,9 +1618,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -1810,9 +1787,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] @@ -1976,9 +1951,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): ctx, title=_("Playlists Are Not Available"), description=_("The playlist section of Audio is currently unavailable"), - footer=discord.Embed.Empty - if not await self.bot.is_owner(ctx.author) - else _("Check your logs."), + footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."), ) if scope_data is None: scope_data = [None, ctx.author, ctx.guild, False] diff --git a/redbot/cogs/audio/core/commands/queue.py b/redbot/cogs/audio/core/commands/queue.py index fb9a76ddb..f8c21e888 100644 --- a/redbot/cogs/audio/core/commands/queue.py +++ b/redbot/cogs/audio/core/commands/queue.py @@ -33,7 +33,8 @@ _ = Translator("Audio", Path(__file__)) class QueueCommands(MixinMeta, metaclass=CompositeMetaClass): @commands.group(name="queue", invoke_without_command=True) @commands.guild_only() - @commands.bot_has_permissions(embed_links=True, add_reactions=True) + @commands.bot_has_permissions(embed_links=True) + @commands.bot_can_react() async def command_queue(self, ctx: commands.Context, *, page: int = 1): """List the songs in the queue.""" diff --git a/redbot/cogs/audio/core/events/cog.py b/redbot/cogs/audio/core/events/cog.py index 48dca0ffb..8d5acdd96 100644 --- a/redbot/cogs/audio/core/events/cog.py +++ b/redbot/cogs/audio/core/events/cog.py @@ -197,7 +197,7 @@ class AudioEvents(MixinMeta, metaclass=CompositeMetaClass): ): if not guild: return - notify_channel = guild.get_channel(player.fetch("notify_channel")) + notify_channel = guild.get_channel_or_thread(player.fetch("notify_channel")) has_perms = self._has_notify_perms(notify_channel) tries = 0 while not player._is_playing: diff --git a/redbot/cogs/audio/core/events/dpy.py b/redbot/cogs/audio/core/events/dpy.py index 8ce2daca7..2e6652a30 100644 --- a/redbot/cogs/audio/core/events/dpy.py +++ b/redbot/cogs/audio/core/events/dpy.py @@ -18,6 +18,7 @@ from lavalink import NodeNotFound, PlayerNotFound from redbot.core import commands from redbot.core.i18n import Translator +from redbot.core.utils import can_user_send_messages_in from redbot.core.utils.antispam import AntiSpam from redbot.core.utils.chat_formatting import box, humanize_list, underline, bold @@ -61,6 +62,16 @@ HUMANIZED_PERM = { "manage_roles": _("Manage Roles"), "manage_webhooks": _("Manage Webhooks"), "manage_emojis": _("Manage Emojis"), + "use_slash_commands": _("Use Slash Commands"), + "request_to_speak": _("Request to Speak"), + "manage_events": _("Manage Events"), + "manage_threads": _("Manage Threads"), + "create_public_threads": _("Create Public Threads"), + "create_private_threads": _("Create Private Threads"), + "external_stickers": _("Use External Stickers"), + "send_messages_in_threads": _("Send Messages in Threads"), + "start_embedded_activities": _("Start Activities"), + "moderate_members": _("Moderate Member"), } DANGEROUS_COMMANDS = { @@ -175,13 +186,31 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): "Not running Audio command due to invalid machine architecture for the managed Lavalink node." ) - current_perms = ctx.channel.permissions_for(ctx.me) surpass_ignore = ( isinstance(ctx.channel, discord.abc.PrivateChannel) or await ctx.bot.is_owner(ctx.author) or await ctx.bot.is_admin(ctx.author) ) guild = ctx.guild + if guild and not can_user_send_messages_in(ctx.me, ctx.channel): + log.debug( + "Missing perms to send messages in %d, Owner ID: %d", + guild.id, + guild.owner.id, + ) + if not surpass_ignore: + text = _( + "I'm missing permissions to send messages in this server. " + "Please address this as soon as possible." + ) + log.info( + "Missing write permission in %d, Owner ID: %d", + guild.id, + guild.owner.id, + ) + raise CheckFailure(message=text) + + current_perms = ctx.channel.permissions_for(ctx.me) if guild and not current_perms.is_superset(self.permission_cache): current_perms_set = set(iter(current_perms)) expected_perms_set = set(iter(self.permission_cache)) @@ -207,14 +236,7 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): perm=_(HUMANIZED_PERM.get(perm, perm)), ) text = text.strip() - if current_perms.send_messages and current_perms.read_messages: - await ctx.send(box(text=text, lang="ini")) - else: - log.info( - "Missing write permission in %s, Owner ID: %s", - ctx.guild.id, - ctx.guild.owner.id, - ) + await ctx.send(box(text=text, lang="ini")) raise CheckFailure(message=text) with contextlib.suppress(Exception): @@ -312,7 +334,7 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): ) if error.send_cmd_help: await ctx.send_help() - elif isinstance(error, commands.ConversionFailure): + elif isinstance(error, commands.BadArgument): handled = True if error.args: if match := RE_CONVERSION.search(error.args[0]): @@ -390,10 +412,10 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): if not handled: await self.bot.on_command_error(ctx, error, unhandled_by_cog=True) - def cog_unload(self) -> None: + async def cog_unload(self) -> None: if not self.cog_cleaned_up: self.bot.dispatch("red_audio_unload", self) - self.session.detach() + await self.session.close() if self.player_automated_timer_task: self.player_automated_timer_task.cancel() @@ -408,10 +430,10 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): lavalink.unregister_event_listener(self.lavalink_event_handler) lavalink.unregister_update_listener(self.lavalink_update_handler) - asyncio.create_task(lavalink.close(self.bot)) - asyncio.create_task(self._close_database()) + await lavalink.close(self.bot) + await self._close_database() if self.managed_node_controller is not None: - asyncio.create_task(self.managed_node_controller.shutdown()) + await self.managed_node_controller.shutdown() self.cog_cleaned_up = True diff --git a/redbot/cogs/audio/core/events/lavalink.py b/redbot/cogs/audio/core/events/lavalink.py index 04d50a3c7..ab0688bef 100644 --- a/redbot/cogs/audio/core/events/lavalink.py +++ b/redbot/cogs/audio/core/events/lavalink.py @@ -165,14 +165,14 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): try: await self.api_interface.autoplay(player, self.playlist_api) except DatabaseError: - notify_channel = guild.get_channel(notify_channel_id) + notify_channel = guild.get_channel_or_thread(notify_channel_id) if notify_channel and self._has_notify_perms(notify_channel): await self.send_embed_msg( notify_channel, title=_("Couldn't get a valid track.") ) return except TrackEnqueueError: - notify_channel = guild.get_channel(notify_channel_id) + notify_channel = guild.get_channel_or_thread(notify_channel_id) if notify_channel and self._has_notify_perms(notify_channel): await self.send_embed_msg( notify_channel, @@ -185,7 +185,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): return if event_type == lavalink.LavalinkEvents.TRACK_START and notify: notify_channel_id = player.fetch("notify_channel") - notify_channel = guild.get_channel(notify_channel_id) + notify_channel = guild.get_channel_or_thread(notify_channel_id) if notify_channel and self._has_notify_perms(notify_channel): if player.fetch("notify_message") is not None: with contextlib.suppress(discord.HTTPException): @@ -226,7 +226,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): if event_type == lavalink.LavalinkEvents.QUEUE_END: if not autoplay: notify_channel_id = player.fetch("notify_channel") - notify_channel = guild.get_channel(notify_channel_id) + notify_channel = guild.get_channel_or_thread(notify_channel_id) if notify_channel and notify and self._has_notify_perms(notify_channel): await self.send_embed_msg(notify_channel, title=_("Queue ended.")) if disconnect: @@ -282,7 +282,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): self._ll_guild_updates.discard(guild_id) self.bot.dispatch("red_audio_audio_disconnect", guild) if message_channel: - message_channel = guild.get_channel(message_channel) + message_channel = guild.get_channel_or_thread(message_channel) if early_exit: log.warning( "Audio detected multiple continuous errors during playback " diff --git a/redbot/cogs/audio/core/tasks/startup.py b/redbot/cogs/audio/core/tasks/startup.py index 81001bd99..351ab2e31 100644 --- a/redbot/cogs/audio/core/tasks/startup.py +++ b/redbot/cogs/audio/core/tasks/startup.py @@ -267,14 +267,14 @@ class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass): try: await self.api_interface.autoplay(player, self.playlist_api) except DatabaseError: - notify_channel = guild.get_channel(notify_channel) + notify_channel = guild.get_channel_or_thread(notify_channel) if notify_channel: await self.send_embed_msg( notify_channel, title=_("Couldn't get a valid track.") ) return except TrackEnqueueError: - notify_channel = guild.get_channel(notify_channel) + notify_channel = guild.get_channel_or_thread(notify_channel) if notify_channel: await self.send_embed_msg( notify_channel, diff --git a/redbot/cogs/audio/core/utilities/formatting.py b/redbot/cogs/audio/core/utilities/formatting.py index d83794adc..2ba32a3d5 100644 --- a/redbot/cogs/audio/core/utilities/formatting.py +++ b/redbot/cogs/audio/core/utilities/formatting.py @@ -9,7 +9,6 @@ import discord import lavalink from red_commons.logging import getLogger -from discord.embeds import EmptyEmbed from lavalink import NodeNotFound from redbot.core import commands @@ -94,7 +93,7 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink node has failed") - description = EmptyEmbed + description = None if await self.bot.is_owner(ctx.author): description = _("Please check your console or logs for details.") return await self.send_embed_msg(ctx, title=msg, description=description) diff --git a/redbot/cogs/audio/core/utilities/miscellaneous.py b/redbot/cogs/audio/core/utilities/miscellaneous.py index 4b03f36bb..585af4186 100644 --- a/redbot/cogs/audio/core/utilities/miscellaneous.py +++ b/redbot/cogs/audio/core/utilities/miscellaneous.py @@ -10,13 +10,12 @@ from typing import Any, Final, Mapping, MutableMapping, Pattern, Union, cast import discord import lavalink -from discord.embeds import EmptyEmbed from red_commons.logging import getLogger from redbot.core import bank, commands from redbot.core.commands import Context from redbot.core.i18n import Translator -from redbot.core.utils import AsyncIter +from redbot.core.utils import AsyncIter, can_user_send_messages_in from redbot.core.utils.chat_formatting import humanize_number from ...apis.playlist_interface import get_all_playlist_for_migration23 @@ -65,10 +64,10 @@ class MiscellaneousUtilities(MixinMeta, metaclass=CompositeMetaClass): self, ctx: commands.Context, author: Mapping[str, str] = None, **kwargs ) -> discord.Message: colour = kwargs.get("colour") or kwargs.get("color") or await self.bot.get_embed_color(ctx) - title = kwargs.get("title", EmptyEmbed) or EmptyEmbed + title = kwargs.get("title") or None _type = kwargs.get("type", "rich") or "rich" - url = kwargs.get("url", EmptyEmbed) or EmptyEmbed - description = kwargs.get("description", EmptyEmbed) or EmptyEmbed + url = kwargs.get("url") or None + description = kwargs.get("description") or None timestamp = kwargs.get("timestamp") footer = kwargs.get("footer") thumbnail = kwargs.get("thumbnail") @@ -84,7 +83,6 @@ class MiscellaneousUtilities(MixinMeta, metaclass=CompositeMetaClass): embed = discord.Embed.from_dict(contents) embed.color = colour if timestamp and isinstance(timestamp, datetime.datetime): - timestamp = timestamp.replace(tzinfo=datetime.timezone.utc) embed.timestamp = timestamp else: embed.timestamp = datetime.datetime.now(tz=datetime.timezone.utc) @@ -101,9 +99,9 @@ class MiscellaneousUtilities(MixinMeta, metaclass=CompositeMetaClass): embed.set_author(name=name) return await ctx.send(embed=embed) - def _has_notify_perms(self, channel: discord.TextChannel) -> bool: + def _has_notify_perms(self, channel: Union[discord.TextChannel, discord.Thread]) -> bool: perms = channel.permissions_for(channel.guild.me) - return all((perms.send_messages, perms.embed_links)) + return all((can_user_send_messages_in(channel.guild.me, channel), perms.embed_links)) async def maybe_run_pending_db_tasks(self, ctx: commands.Context) -> None: if self.api_interface is not None: diff --git a/redbot/cogs/audio/core/utilities/player.py b/redbot/cogs/audio/core/utilities/player.py index 615577dcb..1a6e1a45d 100644 --- a/redbot/cogs/audio/core/utilities/player.py +++ b/redbot/cogs/audio/core/utilities/player.py @@ -8,7 +8,6 @@ import discord import lavalink from red_commons.logging import getLogger -from discord.embeds import EmptyEmbed from lavalink import NodeNotFound, PlayerNotFound from redbot.core import commands @@ -585,7 +584,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): except IndexError: self.update_player_lock(ctx, False) title = _("Nothing found") - desc = EmptyEmbed + desc = None if await self.bot.is_owner(ctx.author): desc = _("Please check your console or logs for details.") return await self.send_embed_msg(ctx, title=title, description=desc) diff --git a/redbot/cogs/audio/core/utilities/playlists.py b/redbot/cogs/audio/core/utilities/playlists.py index f8a94a5f6..55d85bd6e 100644 --- a/redbot/cogs/audio/core/utilities/playlists.py +++ b/redbot/cogs/audio/core/utilities/playlists.py @@ -12,7 +12,6 @@ from typing import List, MutableMapping, Optional, Tuple, Union import aiohttp import discord import lavalink -from discord.embeds import EmptyEmbed from lavalink import NodeNotFound from red_commons.logging import getLogger @@ -525,7 +524,7 @@ class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass): if not self._player_check(ctx): if self.lavalink_connection_aborted: msg = _("Connection to Lavalink node has failed") - desc = EmptyEmbed + desc = None if await self.bot.is_owner(ctx.author): desc = _("Please check your console or logs for details.") await self.send_embed_msg(ctx, title=msg, description=desc) diff --git a/redbot/cogs/audio/core/utilities/validation.py b/redbot/cogs/audio/core/utilities/validation.py index c2a1af19d..7b825ce7e 100644 --- a/redbot/cogs/audio/core/utilities/validation.py +++ b/redbot/cogs/audio/core/utilities/validation.py @@ -60,7 +60,7 @@ class ValidationUtilities(MixinMeta, metaclass=CompositeMetaClass): async def is_query_allowed( self, config: Config, - ctx_or_channel: Optional[Union[Context, discord.TextChannel]], + ctx_or_channel: Optional[Union[Context, discord.TextChannel, discord.Thread]], query: str, query_obj: Query, ) -> bool: diff --git a/redbot/cogs/cleanup/__init__.py b/redbot/cogs/cleanup/__init__.py index e684f80bb..80657a70e 100644 --- a/redbot/cogs/cleanup/__init__.py +++ b/redbot/cogs/cleanup/__init__.py @@ -2,5 +2,5 @@ from .cleanup import Cleanup from redbot.core.bot import Red -def setup(bot: Red): - bot.add_cog(Cleanup(bot)) +async def setup(bot: Red) -> None: + await bot.add_cog(Cleanup(bot)) diff --git a/redbot/cogs/cleanup/cleanup.py b/redbot/cogs/cleanup/cleanup.py index fad25b594..09110ebfe 100644 --- a/redbot/cogs/cleanup/cleanup.py +++ b/redbot/cogs/cleanup/cleanup.py @@ -1,6 +1,6 @@ import contextlib import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Callable, List, Optional, Set, Union import discord @@ -75,7 +75,7 @@ class Cleanup(commands.Cog): @staticmethod async def get_messages_for_deletion( *, - channel: Union[discord.TextChannel, discord.DMChannel], + channel: Union[discord.TextChannel, discord.DMChannel, discord.Thread], number: Optional[PositiveInt] = None, check: Callable[[discord.Message], bool] = lambda x: True, limit: Optional[PositiveInt] = None, @@ -99,7 +99,7 @@ class Cleanup(commands.Cog): """ # This isn't actually two weeks ago to allow some wiggle room on API limits - two_weeks_ago = datetime.utcnow() - timedelta(days=14, minutes=-5) + two_weeks_ago = datetime.now(timezone.utc) - timedelta(days=14, minutes=-5) def message_filter(message): return ( @@ -129,7 +129,7 @@ class Cleanup(commands.Cog): async def send_optional_notification( self, num: int, - channel: Union[discord.TextChannel, discord.DMChannel], + channel: Union[discord.TextChannel, discord.DMChannel, discord.Thread], *, subtract_invoking: bool = False, ) -> None: @@ -149,7 +149,7 @@ class Cleanup(commands.Cog): @staticmethod async def get_message_from_reference( - channel: discord.TextChannel, reference: discord.MessageReference + channel: Union[discord.TextChannel, discord.Thread], reference: discord.MessageReference ) -> Optional[discord.Message]: message = None resolved = reference.resolved @@ -696,7 +696,12 @@ class Cleanup(commands.Cog): def check(m): if m.attachments: return False - c = (m.author.id, m.content, [e.to_dict() for e in m.embeds]) + c = ( + m.author.id, + m.content, + [embed.to_dict() for embed in m.embeds], + [sticker.id for sticker in m.stickers], + ) if c in msgs: spam.append(m) return True diff --git a/redbot/cogs/customcom/__init__.py b/redbot/cogs/customcom/__init__.py index 962dedad7..6515c2e46 100644 --- a/redbot/cogs/customcom/__init__.py +++ b/redbot/cogs/customcom/__init__.py @@ -1,5 +1,7 @@ +from redbot.core.bot import Red + from .customcom import CustomCommands -def setup(bot): - bot.add_cog(CustomCommands(bot)) +async def setup(bot: Red) -> None: + await bot.add_cog(CustomCommands(bot)) diff --git a/redbot/cogs/customcom/customcom.py b/redbot/cogs/customcom/customcom.py index 22eb36c8f..6d6c30ac8 100644 --- a/redbot/cogs/customcom/customcom.py +++ b/redbot/cogs/customcom/customcom.py @@ -3,7 +3,6 @@ import re import random from datetime import datetime, timedelta from inspect import Parameter -from collections import OrderedDict from typing import Iterable, List, Mapping, Tuple, Dict, Set, Literal, Union from urllib.parse import quote_plus @@ -540,7 +539,7 @@ class CustomCommands(commands.Cog): ) @customcom.command(name="list") - @checks.bot_has_permissions(add_reactions=True) + @commands.bot_can_react() async def cc_list(self, ctx: commands.Context): """List all available custom commands. @@ -636,12 +635,15 @@ class CustomCommands(commands.Cog): @commands.Cog.listener() async def on_message_without_command(self, message): - is_private = isinstance(message.channel, discord.abc.PrivateChannel) + is_private = message.guild is None # user_allowed check, will be replaced with self.bot.user_allowed or # something similar once it's added user_allowed = True + if isinstance(message.channel, discord.PartialMessageable): + return + if len(message.content) < 2 or is_private or not user_allowed or message.author.bot: return @@ -705,9 +707,8 @@ class CustomCommands(commands.Cog): @staticmethod def prepare_args(raw_response) -> Mapping[str, Parameter]: args = re.findall(r"{(\d+)[^:}]*(:[^.}]*)?[^}]*\}", raw_response) - default = [("ctx", Parameter("ctx", Parameter.POSITIONAL_OR_KEYWORD))] if not args: - return OrderedDict(default) + return {} allowed_builtins = { "bool": bool, "complex": complex, @@ -775,9 +776,7 @@ class CustomCommands(commands.Cog): i if i < high else "final", ) fin[i] = fin[i].replace(name=name) - # insert ctx parameter for discord.py parsing - fin = default + [(p.name, p) for p in fin] - return OrderedDict(fin) + return dict((p.name, p) for p in fin) def test_cooldowns(self, ctx, command, cooldowns): now = datetime.utcnow() diff --git a/redbot/cogs/downloader/__init__.py b/redbot/cogs/downloader/__init__.py index 1c8d1b710..d535760c0 100644 --- a/redbot/cogs/downloader/__init__.py +++ b/redbot/cogs/downloader/__init__.py @@ -1,7 +1,9 @@ +from redbot.core.bot import Red + from .downloader import Downloader -async def setup(bot): +async def setup(bot: Red) -> None: cog = Downloader(bot) - bot.add_cog(cog) + await bot.add_cog(cog) cog.create_init_task() diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py index 9f2f1e565..4c9fae370 100644 --- a/redbot/cogs/downloader/downloader.py +++ b/redbot/cogs/downloader/downloader.py @@ -13,6 +13,7 @@ from redbot.core import checks, commands, Config, version_info as red_version_in from redbot.core.bot import Red from redbot.core.data_manager import cog_data_path from redbot.core.i18n import Translator, cog_i18n +from redbot.core.utils import can_user_react_in from redbot.core.utils.chat_formatting import box, pagify, humanize_list, inline from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate @@ -933,7 +934,7 @@ class Downloader(commands.Cog): poss_installed_path = (await self.cog_install_path()) / real_name if poss_installed_path.exists(): with contextlib.suppress(commands.ExtensionNotLoaded): - ctx.bot.unload_extension(real_name) + await ctx.bot.unload_extension(real_name) await ctx.bot.remove_loaded_package(real_name) await self._delete_cog(poss_installed_path) uninstalled_cogs.append(inline(real_name)) @@ -1665,7 +1666,7 @@ class Downloader(commands.Cog): if len(updated_cognames) > 1 else _("Would you like to reload the updated cog?") ) - can_react = ctx.channel.permissions_for(ctx.me).add_reactions + can_react = can_user_react_in(ctx.me, ctx.channel) if not can_react: message += " (yes/no)" query: discord.Message = await ctx.send(message) diff --git a/redbot/cogs/economy/__init__.py b/redbot/cogs/economy/__init__.py index 346970b3f..b117218fa 100644 --- a/redbot/cogs/economy/__init__.py +++ b/redbot/cogs/economy/__init__.py @@ -2,5 +2,5 @@ from redbot.core.bot import Red from .economy import Economy -def setup(bot: Red): - bot.add_cog(Economy(bot)) +async def setup(bot: Red) -> None: + await bot.add_cog(Economy(bot)) diff --git a/redbot/cogs/economy/economy.py b/redbot/cogs/economy/economy.py index de2153917..4b0d84742 100644 --- a/redbot/cogs/economy/economy.py +++ b/redbot/cogs/economy/economy.py @@ -434,11 +434,11 @@ class Economy(commands.Cog): if show_global and await bank.is_global(): # show_global is only applicable if bank is global bank_sorted = await bank.get_leaderboard(positions=top, guild=None) - base_embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) + base_embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.display_avatar) else: bank_sorted = await bank.get_leaderboard(positions=top, guild=guild) if guild: - base_embed.set_author(name=guild.name, icon_url=guild.icon_url) + base_embed.set_author(name=guild.name, icon_url=guild.icon) try: bal_len = len(humanize_number(bank_sorted[0][1]["balance"])) diff --git a/redbot/cogs/filter/__init__.py b/redbot/cogs/filter/__init__.py index 1e1b9b7d0..342e83194 100644 --- a/redbot/cogs/filter/__init__.py +++ b/redbot/cogs/filter/__init__.py @@ -3,6 +3,4 @@ from redbot.core.bot import Red async def setup(bot: Red) -> None: - cog = Filter(bot) - await cog.initialize() - bot.add_cog(cog) + await bot.add_cog(Filter(bot)) diff --git a/redbot/cogs/filter/filter.py b/redbot/cogs/filter/filter.py index fe61775c7..8a50d01a1 100644 --- a/redbot/cogs/filter/filter.py +++ b/redbot/cogs/filter/filter.py @@ -56,7 +56,7 @@ class Filter(commands.Cog): if user_id in guild_data: await self.config.member_from_ids(guild_id, user_id).clear() - async def initialize(self) -> None: + async def cog_load(self) -> None: await self.register_casetypes() @staticmethod @@ -205,6 +205,14 @@ class Filter(commands.Cog): async def _channel_clear(self, ctx: commands.Context): """Clears this channel's filter list.""" channel = ctx.channel + if isinstance(channel, discord.Thread): + await ctx.send( + _( + "Threads can't have a filter list set up. If you want to clear this list for" + " the parent channel, send the command in that channel." + ) + ) + return author = ctx.author filter_list = await self.config.channel(channel).filter() if not filter_list: @@ -228,7 +236,7 @@ class Filter(commands.Cog): @_filter_channel.command(name="list") async def _channel_list(self, ctx: commands.Context): """Send a list of the channel's filtered words.""" - channel = ctx.channel + channel = ctx.channel.parent if isinstance(ctx.channel, discord.Thread) else ctx.channel author = ctx.author word_list = await self.config.channel(channel).filter() if not word_list: @@ -257,6 +265,14 @@ class Filter(commands.Cog): - `[words...]` The words or sentences to filter. """ channel = ctx.channel + if isinstance(channel, discord.Thread): + await ctx.send( + _( + "Threads can't have a filter list set up. If you want to add words to" + " the list of the parent channel, send the command in that channel." + ) + ) + return added = await self.add_to_filter(channel, words) if added: self.invalidate_cache(ctx.guild, ctx.channel) @@ -279,6 +295,14 @@ class Filter(commands.Cog): - `[words...]` The words or sentences to no longer filter. """ channel = ctx.channel + if isinstance(channel, discord.Thread): + await ctx.send( + _( + "Threads can't have a filter list set up. If you want to remove words from" + " the list of the parent channel, send the command in that channel." + ) + ) + return removed = await self.remove_from_filter(channel, words) if removed: await ctx.send(_("Words removed from filter.")) @@ -397,14 +421,19 @@ class Filter(commands.Cog): return removed async def filter_hits( - self, text: str, server_or_channel: Union[discord.Guild, discord.TextChannel] + self, + text: str, + server_or_channel: Union[discord.Guild, discord.TextChannel, discord.Thread], ) -> Set[str]: - try: - guild = server_or_channel.guild - channel = server_or_channel - except AttributeError: + if isinstance(server_or_channel, discord.Guild): guild = server_or_channel channel = None + else: + guild = server_or_channel.guild + if isinstance(server_or_channel, discord.Thread): + channel = server_or_channel.parent + else: + channel = server_or_channel hits: Set[str] = set() @@ -437,7 +466,7 @@ class Filter(commands.Cog): filter_time = guild_data["filterban_time"] user_count = member_data["filter_count"] next_reset_time = member_data["next_reset_time"] - created_at = message.created_at.replace(tzinfo=timezone.utc) + created_at = message.created_at if filter_count > 0 and filter_time > 0: if created_at.timestamp() >= next_reset_time: @@ -451,10 +480,16 @@ class Filter(commands.Cog): hits = await self.filter_hits(message.content, message.channel) if hits: + # modlog doesn't accept PartialMessageable + channel = ( + None + if isinstance(message.channel, discord.PartialMessageable) + else message.channel + ) await modlog.create_case( bot=self.bot, guild=guild, - created_at=message.created_at.replace(tzinfo=timezone.utc), + created_at=created_at, action_type="filterhit", user=author, moderator=guild.me, @@ -463,7 +498,7 @@ class Filter(commands.Cog): if len(hits) > 1 else _("Filtered word used: {word}").format(word=list(hits)[0]) ), - channel=message.channel, + channel=channel, ) try: await message.delete() @@ -484,7 +519,7 @@ class Filter(commands.Cog): await modlog.create_case( self.bot, guild, - message.created_at.replace(tzinfo=timezone.utc), + message.created_at, "filterban", author, guild.me, diff --git a/redbot/cogs/general/__init__.py b/redbot/cogs/general/__init__.py index 04d160dbc..a02793eaf 100644 --- a/redbot/cogs/general/__init__.py +++ b/redbot/cogs/general/__init__.py @@ -1,5 +1,7 @@ +from redbot.core.bot import Red + from .general import General -def setup(bot): - bot.add_cog(General()) +async def setup(bot: Red) -> None: + await bot.add_cog(General(bot)) diff --git a/redbot/cogs/general/general.py b/redbot/cogs/general/general.py index 7d81095d1..c1f2188e7 100644 --- a/redbot/cogs/general/general.py +++ b/redbot/cogs/general/general.py @@ -7,6 +7,7 @@ import urllib.parse import aiohttp import discord from redbot.core import commands +from redbot.core.bot import Red from redbot.core.i18n import Translator, cog_i18n from redbot.core.utils.menus import menu, DEFAULT_CONTROLS from redbot.core.utils.chat_formatting import ( @@ -72,8 +73,9 @@ class General(commands.Cog): ] _ = T_ - def __init__(self): + def __init__(self, bot: Red) -> None: super().__init__() + self.bot = bot self.stopwatches = {} async def red_delete_data_for_user(self, **kwargs): @@ -254,18 +256,22 @@ class General(commands.Cog): Default to False. """ guild = ctx.guild - created_at = _("Created on . That's !").format( - int(guild.created_at.replace(tzinfo=datetime.timezone.utc).timestamp()), + created_at = _("Created on {date_and_time}. That's {relative_time}!").format( + date_and_time=discord.utils.format_dt(guild.created_at), + relative_time=discord.utils.format_dt(guild.created_at, "R"), ) online = humanize_number( len([m.status for m in guild.members if m.status != discord.Status.offline]) ) - total_users = humanize_number(guild.member_count) + total_users = guild.member_count and humanize_number(guild.member_count) text_channels = humanize_number(len(guild.text_channels)) voice_channels = humanize_number(len(guild.voice_channels)) if not details: data = discord.Embed(description=created_at, colour=await ctx.embed_colour()) - data.add_field(name=_("Users online"), value=f"{online}/{total_users}") + data.add_field( + name=_("Users online"), + value=f"{online}/{total_users}" if total_users else _("Not available"), + ) data.add_field(name=_("Text Channels"), value=text_channels) data.add_field(name=_("Voice Channels"), value=voice_channels) data.add_field(name=_("Roles"), value=humanize_number(len(guild.roles))) @@ -277,9 +283,9 @@ class General(commands.Cog): command=f"{ctx.clean_prefix}serverinfo 1" ) ) - if guild.icon_url: - data.set_author(name=guild.name, url=guild.icon_url) - data.set_thumbnail(url=guild.icon_url) + if guild.icon: + data.set_author(name=guild.name, url=guild.icon) + data.set_thumbnail(url=guild.icon) else: data.set_author(name=guild.name) else: @@ -342,7 +348,7 @@ class General(commands.Cog): "low": _("1 - Low"), "medium": _("2 - Medium"), "high": _("3 - High"), - "extreme": _("4 - Extreme"), + "highest": _("4 - Highest"), } features = { @@ -389,10 +395,10 @@ class General(commands.Cog): if "VERIFIED" in guild.features else "https://cdn.discordapp.com/emojis/508929941610430464.png" if "PARTNERED" in guild.features - else discord.Embed.Empty, + else None, ) - if guild.icon_url: - data.set_thumbnail(url=guild.icon_url) + if guild.icon: + data.set_thumbnail(url=guild.icon) data.add_field(name=_("Members:"), value=member_msg) data.add_field( name=_("Channels:"), @@ -444,7 +450,7 @@ class General(commands.Cog): ) data.add_field(name=_("Nitro Boost:"), value=nitro_boost) if guild.splash: - data.set_image(url=guild.splash_url_as(format="png")) + data.set_image(url=guild.splash.replace(format="png")) data.set_footer(text=joined_on) await ctx.send(embed=data) diff --git a/redbot/cogs/image/__init__.py b/redbot/cogs/image/__init__.py index 8a2a89582..1c5c1d5f4 100644 --- a/redbot/cogs/image/__init__.py +++ b/redbot/cogs/image/__init__.py @@ -1,7 +1,7 @@ +from redbot.core.bot import Red + from .image import Image -async def setup(bot): - cog = Image(bot) - await cog.initialize() - bot.add_cog(cog) +async def setup(bot: Red) -> None: + await bot.add_cog(Image(bot)) diff --git a/redbot/cogs/image/image.py b/redbot/cogs/image/image.py index 7d92547fd..b5f3ede88 100644 --- a/redbot/cogs/image/image.py +++ b/redbot/cogs/image/image.py @@ -24,14 +24,7 @@ class Image(commands.Cog): self.session = aiohttp.ClientSession() self.imgur_base_url = "https://api.imgur.com/3/" - def cog_unload(self): - self.session.detach() - - async def red_delete_data_for_user(self, **kwargs): - """Nothing to delete""" - return - - async def initialize(self) -> None: + async def cog_load(self) -> None: """Move the API keys from cog stored config to core bot config if they exist.""" imgur_token = await self.config.imgur_client_id() if imgur_token is not None: @@ -39,6 +32,13 @@ class Image(commands.Cog): await self.bot.set_shared_api_tokens("imgur", client_id=imgur_token) await self.config.imgur_client_id.clear() + async def cog_unload(self): + await self.session.close() + + async def red_delete_data_for_user(self, **kwargs): + """Nothing to delete""" + return + @commands.group(name="imgur") async def _imgur(self, ctx): """Retrieve pictures from Imgur. diff --git a/redbot/cogs/mod/__init__.py b/redbot/cogs/mod/__init__.py index eac692c5b..41b4f1515 100644 --- a/redbot/cogs/mod/__init__.py +++ b/redbot/cogs/mod/__init__.py @@ -2,7 +2,5 @@ from redbot.core.bot import Red from .mod import Mod -async def setup(bot: Red): - cog = Mod(bot) - bot.add_cog(cog) - await cog.initialize() +async def setup(bot: Red) -> None: + await bot.add_cog(Mod(bot)) diff --git a/redbot/cogs/mod/events.py b/redbot/cogs/mod/events.py index 80b742b97..6e9a8357c 100644 --- a/redbot/cogs/mod/events.py +++ b/redbot/cogs/mod/events.py @@ -64,7 +64,7 @@ class Events(MixinMeta): await modlog.create_case( self.bot, guild, - message.created_at.replace(tzinfo=timezone.utc), + message.created_at, "ban", author, guild.me, @@ -88,7 +88,7 @@ class Events(MixinMeta): await modlog.create_case( self.bot, guild, - message.created_at.replace(tzinfo=timezone.utc), + message.created_at, "kick", author, guild.me, @@ -120,7 +120,7 @@ class Events(MixinMeta): await modlog.create_case( self.bot, guild, - message.created_at.replace(tzinfo=timezone.utc), + message.created_at, "warning", author, guild.me, diff --git a/redbot/cogs/mod/kickban.py b/redbot/cogs/mod/kickban.py index 24fe5ae1d..d14720a7b 100644 --- a/redbot/cogs/mod/kickban.py +++ b/redbot/cogs/mod/kickban.py @@ -29,23 +29,13 @@ class KickBanMixin(MixinMeta): """ @staticmethod - async def get_invite_for_reinvite(ctx: commands.Context, max_age: int = 86400): - """Handles the reinvite logic for getting an invite - to send the newly unbanned user - :returns: :class:`Invite`""" + async def get_invite_for_reinvite(ctx: commands.Context, max_age: int = 86400) -> str: + """Handles the reinvite logic for getting an invite to send the newly unbanned user""" guild = ctx.guild my_perms: discord.Permissions = guild.me.guild_permissions if my_perms.manage_guild or my_perms.administrator: - if "VANITY_URL" in guild.features: - # guild has a vanity url so use it as the one to send - try: - return await guild.vanity_invite() - except discord.NotFound: - # If a guild has the vanity url feature, - # but does not have it set up, - # this prevents the command from failing - # and defaults back to another regular invite. - pass + if guild.vanity_url is not None: + return guild.vanity_url invites = await guild.invites() else: invites = [] @@ -55,22 +45,22 @@ class KickBanMixin(MixinMeta): # has unlimited uses, doesn't expire, and # doesn't grant temporary membership # (i.e. they won't be kicked on disconnect) - return inv + return inv.url else: # No existing invite found that is valid - channels_and_perms = zip( - guild.text_channels, map(guild.me.permissions_in, guild.text_channels) + channels_and_perms = ( + (channel, channel.permissions_for(guild.me)) for channel in guild.text_channels ) channel = next( (channel for channel, perms in channels_and_perms if perms.create_instant_invite), None, ) if channel is None: - return + return "" try: # Create invite that expires after max_age - return await channel.create_invite(max_age=max_age) + return (await channel.create_invite(max_age=max_age)).url except discord.HTTPException: - return + return "" @staticmethod async def _voice_perm_check( @@ -220,7 +210,7 @@ class KickBanMixin(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, ban_type, user, author, @@ -356,7 +346,7 @@ class KickBanMixin(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "kick", member, author, @@ -566,7 +556,7 @@ class KickBanMixin(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "hackban", user_id, author, @@ -636,8 +626,6 @@ class KickBanMixin(MixinMeta): await ctx.send(_("Invalid days. Must be between 0 and 7.")) return invite = await self.get_invite_for_reinvite(ctx, int(duration.total_seconds() + 86400)) - if invite is None: - invite = "" await self.config.member(member).banned_until.set(unban_time.timestamp()) async with self.config.guild(guild).current_tempbans() as current_tempbans: @@ -646,7 +634,7 @@ class KickBanMixin(MixinMeta): with contextlib.suppress(discord.HTTPException): # We don't want blocked DMs preventing us from banning msg = _("You have been temporarily banned from {server_name} until {date}.").format( - server_name=guild.name, date=f"" + server_name=guild.name, date=discord.utils.format_dt(unban_time) ) if guild_data["dm_on_kickban"] and reason: msg += _("\n\n**Reason:** {reason}").format(reason=reason) @@ -668,7 +656,7 @@ class KickBanMixin(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "tempban", member, author, @@ -706,8 +694,6 @@ class KickBanMixin(MixinMeta): audit_reason = get_audit_reason(author, reason, shorten=True) invite = await self.get_invite_for_reinvite(ctx) - if invite is None: - invite = "" try: # We don't want blocked DMs preventing us from banning msg = await member.send( @@ -750,7 +736,7 @@ class KickBanMixin(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "softban", member, author, @@ -797,7 +783,7 @@ class KickBanMixin(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "vkick", member, author, @@ -840,7 +826,7 @@ class KickBanMixin(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "voiceunban", member, author, @@ -881,7 +867,7 @@ class KickBanMixin(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "voiceban", member, author, @@ -921,7 +907,7 @@ class KickBanMixin(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "unban", ban_entry.user, author, @@ -946,7 +932,7 @@ class KickBanMixin(MixinMeta): _( "You've been unbanned from {server}.\n" "Here is an invite for that server: {invite_link}" - ).format(server=guild.name, invite_link=invite.url) + ).format(server=guild.name, invite_link=invite) ) except discord.Forbidden: await ctx.send( @@ -954,12 +940,12 @@ class KickBanMixin(MixinMeta): "I failed to send an invite to that user. " "Perhaps you may be able to send it for me?\n" "Here's the invite link: {invite_link}" - ).format(invite_link=invite.url) + ).format(invite_link=invite) ) except discord.HTTPException: await ctx.send( _( "Something went wrong when attempting to send that user " "an invite. Here's the link so you can try: {invite_link}" - ).format(invite_link=invite.url) + ).format(invite_link=invite) ) diff --git a/redbot/cogs/mod/mod.py b/redbot/cogs/mod/mod.py index cdc556fc4..481f411ba 100644 --- a/redbot/cogs/mod/mod.py +++ b/redbot/cogs/mod/mod.py @@ -84,8 +84,6 @@ class Mod( self.tban_expiry_task = asyncio.create_task(self.tempban_expirations_task()) self.last_case: dict = defaultdict(dict) - self._ready = asyncio.Event() - async def red_delete_data_for_user( self, *, @@ -114,12 +112,8 @@ class Mod( pass # possible with a context switch between here and getting all guilds - async def initialize(self): + async def cog_load(self) -> None: await self._maybe_update_config() - self._ready.set() - - async def cog_before_invoke(self, ctx: commands.Context) -> None: - await self._ready.wait() def cog_unload(self): self.tban_expiry_task.cancel() diff --git a/redbot/cogs/mod/names.py b/redbot/cogs/mod/names.py index f23c0bf49..3cc5970bc 100644 --- a/redbot/cogs/mod/names.py +++ b/redbot/cogs/mod/names.py @@ -195,9 +195,8 @@ class ModInfo(MixinMeta): if is_special: joined_at = special_date - elif joined_at := member.joined_at: - joined_at = joined_at.replace(tzinfo=datetime.timezone.utc) - user_created = int(member.created_at.replace(tzinfo=datetime.timezone.utc).timestamp()) + else: + joined_at = member.joined_at voice_state = member.voice member_number = ( sorted(guild.members, key=lambda m: m.joined_at or ctx.message.created_at).index( @@ -206,9 +205,15 @@ class ModInfo(MixinMeta): + 1 ) - created_on = "\n()".format(user_created) + created_on = ( + f"{discord.utils.format_dt(member.created_at)}\n" + f"{discord.utils.format_dt(member.created_at, 'R')}" + ) if joined_at is not None: - joined_on = "\n()".format(int(joined_at.timestamp())) + joined_on = ( + f"{discord.utils.format_dt(joined_at)}\n" + f"{discord.utils.format_dt(joined_at, 'R')}" + ) else: joined_on = _("Unknown") @@ -296,7 +301,7 @@ class ModInfo(MixinMeta): name = " ~ ".join((name, member.nick)) if member.nick else name name = filter_invites(name) - avatar = member.avatar_url_as(static_format="png") + avatar = member.display_avatar.replace(static_format="png") data.set_author(name=f"{statusemoji} {name}", url=avatar) data.set_thumbnail(url=avatar) diff --git a/redbot/cogs/mod/slowmode.py b/redbot/cogs/mod/slowmode.py index 8ff24cd3c..261c62fac 100644 --- a/redbot/cogs/mod/slowmode.py +++ b/redbot/cogs/mod/slowmode.py @@ -14,8 +14,8 @@ class Slowmode(MixinMeta): @commands.command() @commands.guild_only() - @commands.bot_has_permissions(manage_channels=True) - @checks.admin_or_permissions(manage_channels=True) + @commands.bot_can_manage_channel() + @commands.admin_or_can_manage_channel() async def slowmode( self, ctx, @@ -24,7 +24,7 @@ class Slowmode(MixinMeta): minimum=timedelta(seconds=0), maximum=timedelta(hours=6), default_unit="seconds" ) = timedelta(seconds=0), ): - """Changes channel's slowmode setting. + """Changes thread's or channel's slowmode setting. Interval can be anything from 0 seconds to 6 hours. Use without parameters to disable. diff --git a/redbot/cogs/modlog/__init__.py b/redbot/cogs/modlog/__init__.py index 3175d76c3..b6090d398 100644 --- a/redbot/cogs/modlog/__init__.py +++ b/redbot/cogs/modlog/__init__.py @@ -2,5 +2,5 @@ from redbot.core.bot import Red from .modlog import ModLog -def setup(bot: Red): - bot.add_cog(ModLog(bot)) +async def setup(bot: Red) -> None: + await bot.add_cog(ModLog(bot)) diff --git a/redbot/cogs/modlog/modlog.py b/redbot/cogs/modlog/modlog.py index eb2356b69..711fe738c 100644 --- a/redbot/cogs/modlog/modlog.py +++ b/redbot/cogs/modlog/modlog.py @@ -40,7 +40,11 @@ class ModLog(commands.Cog): if await ctx.embed_requested(): await ctx.send(embed=await case.message_content(embed=True)) else: - message = f"{await case.message_content(embed=False)}\n{bold(_('Timestamp:'))} " + created_at = datetime.fromtimestamp(case.created_at, tz=timezone.utc) + message = ( + f"{await case.message_content(embed=False)}\n" + f"{bold(_('Timestamp:'))} {discord.utils.format_dt(created_at)}" + ) await ctx.send(message) @commands.command() @@ -73,7 +77,11 @@ class ModLog(commands.Cog): else: rendered_cases = [] for case in cases: - message = f"{await case.message_content(embed=False)}\n{bold(_('Timestamp:'))} " + created_at = datetime.fromtimestamp(case.created_at, tz=timezone.utc) + message = ( + f"{await case.message_content(embed=False)}\n" + f"{bold(_('Timestamp:'))} {discord.utils.format_dt(created_at)}" + ) rendered_cases.append(message) await menu(ctx, rendered_cases, DEFAULT_CONTROLS) @@ -104,7 +112,11 @@ class ModLog(commands.Cog): rendered_cases = [] message = "" for case in cases: - message += f"{await case.message_content(embed=False)}\n{bold(_('Timestamp:'))} " + created_at = datetime.fromtimestamp(case.created_at, tz=timezone.utc) + message += ( + f"{await case.message_content(embed=False)}\n" + f"{bold(_('Timestamp:'))} {discord.utils.format_dt(created_at)}" + ) for page in pagify(message, ["\n\n", "\n"], priority=True): rendered_cases.append(page) await menu(ctx, rendered_cases, DEFAULT_CONTROLS) @@ -143,7 +155,7 @@ class ModLog(commands.Cog): to_modify = {"reason": reason} if case_obj.moderator != author: to_modify["amended_by"] = author - to_modify["modified_at"] = ctx.message.created_at.replace(tzinfo=timezone.utc).timestamp() + to_modify["modified_at"] = ctx.message.created_at.timestamp() await case_obj.edit(to_modify) await ctx.send( _("Reason for case #{num} has been updated.").format(num=case_obj.case_number) diff --git a/redbot/cogs/mutes/__init__.py b/redbot/cogs/mutes/__init__.py index 0c45c180f..bce1e30db 100644 --- a/redbot/cogs/mutes/__init__.py +++ b/redbot/cogs/mutes/__init__.py @@ -2,6 +2,7 @@ from redbot.core.bot import Red from .mutes import Mutes -def setup(bot: Red): +async def setup(bot: Red) -> None: cog = Mutes(bot) - bot.add_cog(cog) + await bot.add_cog(cog) + cog.create_init_task() diff --git a/redbot/cogs/mutes/mutes.py b/redbot/cogs/mutes/mutes.py index 4fc1cf18c..e1a5b4ad0 100644 --- a/redbot/cogs/mutes/mutes.py +++ b/redbot/cogs/mutes/mutes.py @@ -12,7 +12,7 @@ from .voicemutes import VoiceMutes from redbot.core.bot import Red from redbot.core import commands, checks, i18n, modlog, Config -from redbot.core.utils import AsyncIter, bounded_gather +from redbot.core.utils import AsyncIter, bounded_gather, can_user_react_in from redbot.core.utils.chat_formatting import ( bold, humanize_timedelta, @@ -87,28 +87,42 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): "dm": False, "show_mod": False, } - self.config.register_global(force_role_mutes=True, schema_version=0) # Tbh I would rather force everyone to use role mutes. # I also honestly think everyone would agree they're the # way to go. If for whatever reason someone wants to # enable channel overwrite mutes for their bot they can. # Channel overwrite logic still needs to be in place # for channel mutes methods. + self.config.register_global(force_role_mutes=True, schema_version=0) self.config.register_guild(**default_guild) self.config.register_member(perms_cache={}) self.config.register_channel(muted_users={}) self._server_mutes: Dict[int, Dict[int, dict]] = {} self._channel_mutes: Dict[int, Dict[int, dict]] = {} - self._ready = asyncio.Event() self._unmute_tasks: Dict[str, asyncio.Task] = {} - self._unmute_task = None + self._unmute_task: Optional[asyncio.Task] = None self.mute_role_cache: Dict[int, int] = {} - self._channel_mute_events: Dict[int, asyncio.Event] = {} # this is a dict of guild ID's and asyncio.Events # to wait for a guild to finish channel unmutes before # checking for manual overwrites + self._channel_mute_events: Dict[int, asyncio.Event] = {} + self._ready = asyncio.Event() + self._init_task: Optional[asyncio.Task] = None + self._ready_raised = False - self._init_task = asyncio.create_task(self._initialize()) + def create_init_task(self) -> None: + def _done_callback(task: asyncio.Task) -> None: + exc = task.exception() + if exc is not None: + log.error( + "An unexpected error occurred during Mutes's initialization.", + exc_info=exc, + ) + self._ready_raised = True + self._ready.set() + + self._init_task = asyncio.create_task(self.initialize()) + self._init_task.add_done_callback(_done_callback) async def red_delete_data_for_user( self, @@ -125,13 +139,17 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): return await self._ready.wait() + if self._ready_raised: + raise RuntimeError( + "Mutes cog is in a bad state, can't proceed with data deletion request." + ) all_members = await self.config.all_members() for g_id, data in all_members.items(): for m_id, mutes in data.items(): if m_id == user_id: await self.config.member_from_ids(g_id, m_id).clear() - async def _initialize(self): + async def initialize(self): await self.bot.wait_until_red_ready() await self._maybe_update_config() @@ -184,11 +202,21 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): ) async def cog_before_invoke(self, ctx: commands.Context): - await self._ready.wait() + if not self._ready.is_set(): + async with ctx.typing(): + await self._ready.wait() + if self._ready_raised: + await ctx.send( + "There was an error during Mutes's initialization." + " Check logs for more information." + ) + raise commands.CheckFailure() def cog_unload(self): - self._init_task.cancel() - self._unmute_task.cancel() + if self._init_task is not None: + self._init_task.cancel() + if self._unmute_task is not None: + self._unmute_task.cancel() for task in self._unmute_tasks.values(): task.cancel() @@ -208,6 +236,8 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): """ await self.bot.wait_until_red_ready() await self._ready.wait() + if self._ready_raised: + raise RuntimeError("Mutes cog is in a bad state, cancelling automatic unmute task.") while True: await self._clean_tasks() try: @@ -533,7 +563,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): if duration: duration_str = humanize_timedelta(timedelta=duration) until = datetime.now(timezone.utc) + duration - until_str = f"" + until_str = discord.utils.format_dt(until) if moderator is None: moderator_str = _("Unknown") @@ -549,7 +579,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): description=reason, color=await self.bot.get_embed_color(user), ) - em.timestamp = datetime.utcnow() + em.timestamp = datetime.now(timezone.utc) if duration: em.add_field(name=_("Until"), value=until_str) em.add_field(name=_("Duration"), value=duration_str) @@ -663,21 +693,18 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): } to_del: List[int] = [] for user_id in self._channel_mutes[after.id].keys(): - send_messages = False - speak = False + unmuted = False if user_id in after_perms: - send_messages = ( - after_perms[user_id]["send_messages"] is None - or after_perms[user_id]["send_messages"] is True - ) - speak = ( - after_perms[user_id]["speak"] is None - or after_perms[user_id]["speak"] is True - ) + for perm_name in ( + "send_messages", + "send_messages_in_threads", + "create_public_threads", + "create_private_threads", + "speak", + ): + unmuted = unmuted or after_perms[user_id][perm_name] is not False # explicit is better than implicit :thinkies: - if user_id in before_perms and ( - user_id not in after_perms or any((send_messages, speak)) - ): + if user_id in before_perms and (user_id not in after_perms or unmuted): user = after.guild.get_member(user_id) send_dm_notification = True if not user: @@ -900,7 +927,14 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): ) async with ctx.typing(): perms = discord.Permissions() - perms.update(send_messages=False, speak=False, add_reactions=False) + perms.update( + send_messages=False, + send_messages_in_threads=False, + create_public_threads=False, + create_private_threads=False, + speak=False, + add_reactions=False, + ) try: role = await ctx.guild.create_role( name=name, permissions=perms, reason=_("Mute role setup") @@ -942,6 +976,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): return channel.mention overs = discord.PermissionOverwrite() overs.send_messages = False + overs.send_messages_in_threads = False + overs.create_public_threads = False + overs.create_private_threads = False overs.add_reactions = False overs.speak = False try: @@ -1007,7 +1044,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): "Role mutes do not have this issue.\n\n" "Are you sure you want to continue with channel overwrites? " ) - can_react = ctx.channel.permissions_for(ctx.me).add_reactions + can_react = can_user_react_in(ctx.me, ctx.channel) if can_react: msg += _( "Reacting with \N{WHITE HEAVY CHECK MARK} will continue " @@ -1178,7 +1215,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "smute", user, author, @@ -1233,7 +1270,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): "Some users could not be properly muted. Would you like to see who, where, and why?" ) - can_react = ctx.channel.permissions_for(ctx.me).add_reactions + can_react = can_user_react_in(ctx.me, ctx.channel) if not can_react: message += " (y/n)" query: discord.Message = await ctx.send(message) @@ -1279,7 +1316,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): *, time_and_reason: MuteTime = {}, ): - """Mute a user in the current text channel. + """Mute a user in the current text channel (or in the parent of the current thread). `` is a space separated list of usernames, ID's, or mentions. `[time_and_reason]` is the time to mute for and reason. Time is @@ -1313,6 +1350,8 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): ) author = ctx.message.author channel = ctx.message.channel + if isinstance(channel, discord.Thread): + channel = channel.parent guild = ctx.guild audit_reason = get_audit_reason(author, reason, shorten=True) issue_list = [] @@ -1327,7 +1366,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "cmute", user, author, @@ -1347,7 +1386,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): msg = _("{users} has been muted in this channel{time}.") if len(success_list) > 1: msg = _("{users} have been muted in this channel{time}.") - await channel.send( + await ctx.send( msg.format(users=humanize_list([f"{u}" for u in success_list]), time=time) ) if issue_list: @@ -1397,7 +1436,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "sunmute", user, author, @@ -1435,7 +1474,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): *, reason: Optional[str] = None, ): - """Unmute a user in this channel. + """Unmute a user in this channel (or in the parent of this thread). `` is a space separated list of usernames, ID's, or mentions. `[reason]` is the reason for the unmute. @@ -1448,6 +1487,8 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): return await ctx.send(_("You cannot unmute yourself.")) async with ctx.typing(): channel = ctx.channel + if isinstance(channel, discord.Thread): + channel = channel.parent author = ctx.author guild = ctx.guild audit_reason = get_audit_reason(author, reason, shorten=True) @@ -1463,7 +1504,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "cunmute", user, author, @@ -1654,7 +1695,14 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): new_overs: dict = {} move_channel = False - new_overs.update(send_messages=False, add_reactions=False, speak=False) + new_overs.update( + send_messages=False, + send_messages_in_threads=False, + create_public_threads=False, + create_private_threads=False, + add_reactions=False, + speak=False, + ) send_reason = None if user.voice and user.voice.channel: if channel.permissions_for(guild.me).move_members: @@ -1756,7 +1804,14 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass): if channel.id in perms_cache: old_values = perms_cache[channel.id] else: - old_values = {"send_messages": None, "add_reactions": None, "speak": None} + old_values = { + "send_messages": None, + "send_messages_in_threads": None, + "create_public_threads": None, + "create_private_threads": None, + "add_reactions": None, + "speak": None, + } if user.voice and user.voice.channel: if channel.permissions_for(guild.me).move_members: diff --git a/redbot/cogs/mutes/voicemutes.py b/redbot/cogs/mutes/voicemutes.py index cdcfe8c90..d737eeba5 100644 --- a/redbot/cogs/mutes/voicemutes.py +++ b/redbot/cogs/mutes/voicemutes.py @@ -135,7 +135,7 @@ class VoiceMutes(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "vmute", user, author, @@ -211,7 +211,7 @@ class VoiceMutes(MixinMeta): await modlog.create_case( self.bot, guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "vunmute", user, author, diff --git a/redbot/cogs/permissions/__init__.py b/redbot/cogs/permissions/__init__.py index fa080cb1f..78f259728 100644 --- a/redbot/cogs/permissions/__init__.py +++ b/redbot/cogs/permissions/__init__.py @@ -1,7 +1,9 @@ +from redbot.core.bot import Red + from .permissions import Permissions -async def setup(bot): +async def setup(bot: Red) -> None: cog = Permissions(bot) await cog.initialize() # We should add the rules for the Permissions cog and its own commands *before* adding the cog. @@ -9,4 +11,4 @@ async def setup(bot): await cog._on_cog_add(cog) for command in cog.__cog_commands__: await cog._on_command_add(command) - bot.add_cog(cog) + await bot.add_cog(cog) diff --git a/redbot/cogs/permissions/converters.py b/redbot/cogs/permissions/converters.py index acd6cc697..308fd71a7 100644 --- a/redbot/cogs/permissions/converters.py +++ b/redbot/cogs/permissions/converters.py @@ -31,7 +31,7 @@ class GlobalUniqueObjectFinder(commands.Converter): if guild is not None: return guild channel: discord.abc.GuildChannel = bot.get_channel(_id) - if channel is not None: + if channel is not None and not isinstance(channel, discord.Thread): return channel user: discord.User = bot.get_user(_id) diff --git a/redbot/cogs/permissions/permissions.py b/redbot/cogs/permissions/permissions.py index eaba0f931..0061df3d0 100644 --- a/redbot/cogs/permissions/permissions.py +++ b/redbot/cogs/permissions/permissions.py @@ -10,6 +10,7 @@ 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.i18n import Translator, cog_i18n +from redbot.core.utils import can_user_react_in from redbot.core.utils.chat_formatting import box from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.predicates import ReactionPredicate, MessagePredicate @@ -221,7 +222,7 @@ class Permissions(commands.Cog): "multiple global or server rules apply to the case, the order they are checked in 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" + " 3. Rules about the text channel or a parent of the thread 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" @@ -686,7 +687,7 @@ class Permissions(commands.Cog): @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: + if ctx.guild is None or can_user_react_in(ctx.guild.me, ctx.channel): msg = await ctx.send(_("Are you sure?")) # noinspection PyAsyncCall task = start_adding_reactions(msg, ReactionPredicate.YES_OR_NO_EMOJIS) @@ -815,8 +816,8 @@ class Permissions(commands.Cog): elif rule is False: cog_or_command.deny_to(model_id, guild_id=guild_id) - def cog_unload(self) -> None: - asyncio.create_task(self._unload_all_rules()) + async def cog_unload(self) -> None: + await self._unload_all_rules() async def _unload_all_rules(self) -> None: """Unload all rules set by this cog. diff --git a/redbot/cogs/reports/__init__.py b/redbot/cogs/reports/__init__.py index ebec1167c..36de02e42 100644 --- a/redbot/cogs/reports/__init__.py +++ b/redbot/cogs/reports/__init__.py @@ -2,5 +2,5 @@ from redbot.core.bot import Red from .reports import Reports -def setup(bot: Red): - bot.add_cog(Reports(bot)) +async def setup(bot: Red) -> None: + await bot.add_cog(Reports(bot)) diff --git a/redbot/cogs/reports/reports.py b/redbot/cogs/reports/reports.py index aacb770d4..149f9c1c4 100644 --- a/redbot/cogs/reports/reports.py +++ b/redbot/cogs/reports/reports.py @@ -212,7 +212,7 @@ class Reports(commands.Cog): name=_("Report from {author}{maybe_nick}").format( author=author, maybe_nick=(f" ({author.nick})" if author.nick else "") ), - icon_url=author.avatar_url, + icon_url=author.display_avatar, ) em.set_footer(text=_("Report #{}").format(ticket_number)) send_content = None @@ -391,7 +391,7 @@ class Reports(commands.Cog): async def response(self, ctx, ticket_number: int): """Open a message tunnel. - This tunnel will forward things you say in this channel + This tunnel will forward things you say in this channel or thread to the ticket opener's direct messages. Tunnels do not persist across bot restarts. diff --git a/redbot/cogs/streams/__init__.py b/redbot/cogs/streams/__init__.py index fdccd538e..9a84b1e15 100644 --- a/redbot/cogs/streams/__init__.py +++ b/redbot/cogs/streams/__init__.py @@ -1,6 +1,7 @@ +from redbot.core.bot import Red + from .streams import Streams -def setup(bot): - cog = Streams(bot) - bot.add_cog(cog) +async def setup(bot: Red) -> None: + await bot.add_cog(Streams(bot)) diff --git a/redbot/cogs/streams/streams.py b/redbot/cogs/streams/streams.py index 8b52721c1..c007af29d 100644 --- a/redbot/cogs/streams/streams.py +++ b/redbot/cogs/streams/streams.py @@ -79,9 +79,6 @@ class Streams(commands.Cog): self.yt_cid_pattern = re.compile("^UC[-_A-Za-z0-9]{21}[AQgw]$") - self._ready_event: asyncio.Event = asyncio.Event() - self._init_task: asyncio.Task = asyncio.create_task(self.initialize()) - async def red_delete_data_for_user(self, **kwargs): """Nothing to delete""" return @@ -92,10 +89,8 @@ class Streams(commands.Cog): return True return False - async def initialize(self) -> None: + async def cog_load(self) -> None: """Should be called straight after cog instantiation.""" - await self.bot.wait_until_ready() - try: await self.move_api_keys() await self.get_twitch_bearer_token() @@ -104,16 +99,11 @@ class Streams(commands.Cog): except Exception as error: log.exception("Failed to initialize Streams cog:", exc_info=error) - self._ready_event.set() - @commands.Cog.listener() async def on_red_api_tokens_update(self, service_name, api_tokens): if service_name == "twitch": await self.get_twitch_bearer_token(api_tokens) - async def cog_before_invoke(self, ctx: commands.Context): - await self._ready_event.wait() - async def move_api_keys(self) -> None: """Move the API keys from cog stored config to core bot config if they exist.""" tokens = await self.config.tokens() @@ -127,6 +117,29 @@ class Streams(commands.Cog): await self.bot.set_shared_api_tokens("twitch", client_id=token) await self.config.tokens.clear() + async def _notify_owner_about_missing_twitch_secret(self) -> None: + message = _( + "You need a client secret key if you want to use the Twitch API on this cog.\n" + "Follow these steps:\n" + "1. Go to this page: {link}.\n" + '2. Click "Manage" on your application.\n' + '3. Click on "New secret".\n' + "5. Copy your client ID and your client secret into:\n" + "{command}" + "\n\n" + "Note: These tokens are sensitive and should only be used in a private channel " + "or in DM with the bot." + ).format( + link="https://dev.twitch.tv/console/apps", + command=inline( + "[p]set api twitch client_id {} client_secret {}".format( + _(""), _("") + ) + ), + ) + await send_to_owners_with_prefix_replaced(self.bot, message) + await self.config.notified_owner_missing_twitch_secret.set(True) + async def get_twitch_bearer_token(self, api_tokens: Optional[Dict] = None) -> None: tokens = ( await self.bot.get_shared_api_tokens("twitch") if api_tokens is None else api_tokens @@ -140,28 +153,8 @@ class Streams(commands.Cog): if notified_owner_missing_twitch_secret is True: await self.config.notified_owner_missing_twitch_secret.set(False) except KeyError: - message = _( - "You need a client secret key if you want to use the Twitch API on this cog.\n" - "Follow these steps:\n" - "1. Go to this page: {link}.\n" - '2. Click "Manage" on your application.\n' - '3. Click on "New secret".\n' - "5. Copy your client ID and your client secret into:\n" - "{command}" - "\n\n" - "Note: These tokens are sensitive and should only be used in a private channel " - "or in DM with the bot." - ).format( - link="https://dev.twitch.tv/console/apps", - command=inline( - "[p]set api twitch client_id {} client_secret {}".format( - _(""), _("") - ) - ), - ) if notified_owner_missing_twitch_secret is False: - await send_to_owners_with_prefix_replaced(self.bot, message) - await self.config.notified_owner_missing_twitch_secret.set(True) + asyncio.create_task(self._notify_owner_about_missing_twitch_secret()) async with aiohttp.ClientSession() as session: async with session.post( "https://id.twitch.tv/oauth2/token", @@ -391,6 +384,9 @@ class Streams(commands.Cog): await ctx.send(page) async def stream_alert(self, ctx: commands.Context, _class, channel_name): + if isinstance(ctx.channel, discord.Thread): + await ctx.send("Stream alerts cannot be set up in threads.") + return stream = self.get_stream(_class, channel_name) if not stream: token = await self.bot.get_shared_api_tokens(_class.token_name) diff --git a/redbot/cogs/trivia/__init__.py b/redbot/cogs/trivia/__init__.py index 3b65601d1..772875651 100644 --- a/redbot/cogs/trivia/__init__.py +++ b/redbot/cogs/trivia/__init__.py @@ -1,10 +1,11 @@ """Package for Trivia cog.""" +from redbot.core.bot import Red + from .trivia import * from .session import * from .log import * -def setup(bot): +async def setup(bot: Red) -> None: """Load Trivia.""" - cog = Trivia() - bot.add_cog(cog) + await bot.add_cog(Trivia(bot)) diff --git a/redbot/cogs/trivia/session.py b/redbot/cogs/trivia/session.py index bd83627d9..e687d3c5a 100644 --- a/redbot/cogs/trivia/session.py +++ b/redbot/cogs/trivia/session.py @@ -249,7 +249,9 @@ class TriviaSession: answers = tuple(s.lower() for s in answers) def _pred(message: discord.Message): - early_exit = message.channel != self.ctx.channel or message.author == self.ctx.guild.me + early_exit = ( + message.channel.id != self.ctx.channel.id or message.author == self.ctx.guild.me + ) if early_exit: return False diff --git a/redbot/cogs/trivia/trivia.py b/redbot/cogs/trivia/trivia.py index f23be42f9..12beef029 100644 --- a/redbot/cogs/trivia/trivia.py +++ b/redbot/cogs/trivia/trivia.py @@ -3,7 +3,7 @@ import asyncio import math import pathlib from collections import Counter -from typing import Any, Dict, List, Literal +from typing import Any, Dict, List, Literal, Union from schema import Schema, Optional, Or, SchemaError import io @@ -11,9 +11,10 @@ import yaml import discord from redbot.core import Config, commands, checks, bank +from redbot.core.bot import Red from redbot.core.data_manager import cog_data_path from redbot.core.i18n import Translator, cog_i18n -from redbot.core.utils import AsyncIter +from redbot.core.utils import AsyncIter, can_user_react_in from redbot.core.utils.chat_formatting import box, pagify, bold from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate @@ -54,8 +55,9 @@ class InvalidListError(Exception): class Trivia(commands.Cog): """Play trivia with friends!""" - def __init__(self): + def __init__(self, bot: Red) -> None: super().__init__() + self.bot = bot self.trivia_sessions = [] self.config = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True) @@ -672,7 +674,7 @@ class Trivia(commands.Cog): filename=filename ) - can_react = ctx.channel.permissions_for(ctx.me).add_reactions + can_react = can_user_react_in(ctx.me, ctx.channel) if not can_react: overwrite_message += " (yes/no)" @@ -706,7 +708,9 @@ class Trivia(commands.Cog): fp.write(buffer.read()) await ctx.send(_("Saved Trivia list as {filename}.").format(filename=filename)) - def _get_trivia_session(self, channel: discord.TextChannel) -> TriviaSession: + def _get_trivia_session( + self, channel: Union[discord.TextChannel, discord.Thread] + ) -> TriviaSession: return next( (session for session in self.trivia_sessions if session.ctx.channel == channel), None ) diff --git a/redbot/cogs/warnings/__init__.py b/redbot/cogs/warnings/__init__.py index 3581b83cd..8b04c899c 100644 --- a/redbot/cogs/warnings/__init__.py +++ b/redbot/cogs/warnings/__init__.py @@ -1,5 +1,7 @@ +from redbot.core.bot import Red + from .warnings import Warnings -def setup(bot): - bot.add_cog(Warnings(bot)) +async def setup(bot: Red) -> None: + await bot.add_cog(Warnings(bot)) diff --git a/redbot/cogs/warnings/warnings.py b/redbot/cogs/warnings/warnings.py index c5f420a99..a3caa4e8e 100644 --- a/redbot/cogs/warnings/warnings.py +++ b/redbot/cogs/warnings/warnings.py @@ -47,7 +47,9 @@ class Warnings(commands.Cog): self.config.register_guild(**self.default_guild) self.config.register_member(**self.default_member) self.bot = bot - self.registration_task = asyncio.create_task(self.register_warningtype()) + + async def cog_load(self) -> None: + await self.register_warningtype() async def red_delete_data_for_user( self, @@ -508,7 +510,7 @@ class Warnings(commands.Cog): await modlog.create_case( self.bot, ctx.guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "warning", member, ctx.message.author, @@ -632,7 +634,7 @@ class Warnings(commands.Cog): await modlog.create_case( self.bot, ctx.guild, - ctx.message.created_at.replace(tzinfo=timezone.utc), + ctx.message.created_at, "unwarned", member, ctx.message.author, diff --git a/redbot/core/_diagnoser.py b/redbot/core/_diagnoser.py index 43a062dfd..a6be078b8 100644 --- a/redbot/core/_diagnoser.py +++ b/redbot/core/_diagnoser.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Awaitable, Callable, Iterable, List, Optional, import discord from redbot.core import commands from redbot.core.i18n import Translator +from redbot.core.utils import can_user_send_messages_in from redbot.core.utils.chat_formatting import ( bold, escape, @@ -37,7 +38,7 @@ class IssueDiagnoserBase: self, bot: Red, original_ctx: commands.Context, - channel: discord.TextChannel, + channel: Union[discord.TextChannel, discord.Thread], author: discord.Member, command: commands.Command, ) -> None: @@ -59,6 +60,7 @@ class IssueDiagnoserBase: self.message.channel = self.channel self.message.content = self._original_ctx.prefix + self.command.qualified_name # clear the cached properties + # DEP-WARN for attr in self.message._CACHED_SLOTS: # type: ignore[attr-defined] try: delattr(self.message, attr) @@ -117,18 +119,27 @@ class DetailedGlobalCallOnceChecksMixin(IssueDiagnoserBase): async def _check_can_bot_send_messages(self) -> CheckResult: label = _("Check if the bot can send messages in the given channel") - if self.channel.permissions_for(self.guild.me).send_messages: - return CheckResult(True, label) - return CheckResult( - False, - label, - _("Bot doesn't have permission to send messages in the given channel."), - _( - "To fix this issue, ensure that the permissions setup allows the bot" - " to send messages per Discord's role hierarchy:\n" - "https://support.discord.com/hc/en-us/articles/206141927" - ), - ) + # This is checked by send messages check but this allows us to + # give more detailed information. + if not self.guild.me.guild_permissions.administrator and self.guild.me.is_timed_out(): + return CheckResult( + False, + label, + _("Bot is timed out in the given channel."), + _("To fix this issue, remove timeout from the bot."), + ) + if not can_user_send_messages_in(self.guild.me, self.channel): + return CheckResult( + False, + label, + _("Bot doesn't have permission to send messages in the given channel."), + _( + "To fix this issue, ensure that the permissions setup allows the bot" + " to send messages per Discord's role hierarchy:\n" + "https://support.discord.com/hc/en-us/articles/206141927" + ), + ) + return CheckResult(True, label) # While the following 2 checks could show even more precise error message, # it would require a usage of private attribute rather than the public API @@ -139,24 +150,47 @@ class DetailedGlobalCallOnceChecksMixin(IssueDiagnoserBase): return CheckResult(True, label) if self.channel.category is None: - resolution = _( - "To fix this issue, check the list returned by the {command} command" - " and ensure that the {channel} channel and the server aren't a part of that list." - ).format( - command=self._format_command_name("ignore list"), - channel=self.channel.mention, - ) + if isinstance(self.channel, discord.Thread): + resolution = _( + "To fix this issue, check the list returned by the {command} command" + " and ensure that the {thread} thread, its parent channel," + " and the server aren't a part of that list." + ).format( + command=self._format_command_name("ignore list"), + thread=self.channel.mention, + ) + else: + resolution = _( + "To fix this issue, check the list returned by the {command} command" + " and ensure that the {channel} channel" + " and the server aren't a part of that list." + ).format( + command=self._format_command_name("ignore list"), + channel=self.channel.mention, + ) else: - resolution = _( - "To fix this issue, check the list returned by the {command} command" - " and ensure that the {channel} channel," - " the channel category it belongs to ({channel_category})," - " and the server aren't a part of that list." - ).format( - command=self._format_command_name("ignore list"), - channel=self.channel.mention, - channel_category=self.channel.category.mention, - ) + if isinstance(self.channel, discord.Thread): + resolution = _( + "To fix this issue, check the list returned by the {command} command" + " and ensure that the {thread} thread, its parent channel," + " the channel category it belongs to ({channel_category})," + " and the server aren't a part of that list." + ).format( + command=self._format_command_name("ignore list"), + thread=self.channel.mention, + channel_category=self.channel.category.mention, + ) + else: + resolution = _( + "To fix this issue, check the list returned by the {command} command" + " and ensure that the {channel} channel," + " the channel category it belongs to ({channel_category})," + " and the server aren't a part of that list." + ).format( + command=self._format_command_name("ignore list"), + channel=self.channel.mention, + channel_category=self.channel.category.mention, + ) return CheckResult( False, diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 9a7e6ba31..2aa23366a 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -51,7 +51,7 @@ from .settings_caches import ( I18nManager, ) from .rpc import RPCMixin -from .utils import common_filters, AsyncIter +from .utils import can_user_send_messages_in, common_filters, AsyncIter from .utils._internal_utils import send_to_owners_with_prefix_replaced CUSTOM_GROUPS = "CUSTOM_GROUPS" @@ -375,12 +375,12 @@ class Red( return del dev.env_extensions[name] - def get_command(self, name: str) -> Optional[commands.Command]: + def get_command(self, name: str, /) -> Optional[commands.Command]: com = super().get_command(name) assert com is None or isinstance(com, commands.Command) return com - def get_cog(self, name: str) -> Optional[commands.Cog]: + def get_cog(self, name: str, /) -> Optional[commands.Cog]: cog = super().get_cog(name) assert cog is None or isinstance(cog, commands.Cog) return cog @@ -444,7 +444,7 @@ class Red( """ self._red_before_invoke_objs.discard(coro) - def before_invoke(self, coro: T_BIC) -> T_BIC: + def before_invoke(self, coro: T_BIC, /) -> T_BIC: """ Overridden decorator method for Red's ``before_invoke`` behavior. @@ -809,9 +809,14 @@ class Red( if message.author.bot: return False + # We do not consider messages with PartialMessageable channel as eligible. + # See `process_commands()` for our handling of it. + if isinstance(channel, discord.PartialMessageable): + return False + if guild: - assert isinstance(channel, discord.abc.GuildChannel) # nosec - if not channel.permissions_for(guild.me).send_messages: + assert isinstance(channel, (discord.abc.GuildChannel, discord.Thread)) + if not can_user_send_messages_in(guild.me, channel): return False if not (await self.ignored_channel_or_guild(message)): return False @@ -838,7 +843,14 @@ class Red( ------- bool `True` if commands are allowed in the channel, `False` otherwise + + Raises + ------ + TypeError + ``ctx.channel`` is of `discord.PartialMessageable` type. """ + if isinstance(ctx.channel, discord.PartialMessageable): + raise TypeError("Can't check permissions for PartialMessageable.") perms = ctx.channel.permissions_for(ctx.author) surpass_ignore = ( isinstance(ctx.channel, discord.abc.PrivateChannel) @@ -846,11 +858,38 @@ class Red( or await self.is_owner(ctx.author) or await self.is_admin(ctx.author) ) + # guild-wide checks if surpass_ignore: return True guild_ignored = await self._ignored_cache.get_ignored_guild(ctx.guild) - chann_ignored = await self._ignored_cache.get_ignored_channel(ctx.channel) - return not (guild_ignored or chann_ignored and not perms.manage_channels) + if guild_ignored: + return False + + # (parent) channel checks + if perms.manage_channels: + return True + + if isinstance(ctx.channel, discord.Thread): + channel = ctx.channel.parent + thread = ctx.channel + else: + channel = ctx.channel + thread = None + + chann_ignored = await self._ignored_cache.get_ignored_channel(channel) + if chann_ignored: + return False + if thread is None: + return True + + # thread checks + if perms.manage_threads: + return True + thread_ignored = await self._ignored_cache.get_ignored_channel( + thread, + check_category=False, # already checked for parent + ) + return not thread_ignored async def get_valid_prefixes(self, guild: Optional[discord.Guild] = None) -> List[str]: """ @@ -1062,10 +1101,10 @@ class Red( """ This should only be run once, prior to connecting to Discord gateway. """ - self.add_cog(Core(self)) - self.add_cog(CogManagerUI()) + await self.add_cog(Core(self)) + await self.add_cog(CogManagerUI()) if self._cli_flags.dev: - self.add_cog(Dev()) + await self.add_cog(Dev()) await modlog._init(self) await bank._init() @@ -1179,15 +1218,16 @@ class Red( if not self.owner_ids: raise _NoOwnerSet("Bot doesn't have any owner set!") - async def start(self, *args, **kwargs): - """ - Overridden start which ensures that cog load and other pre-connection tasks are handled. - """ + async def start(self, token: str) -> None: + # Overriding start to call _pre_login() before login() await self._pre_login() - await self.login(*args) + await self.login(token) + # Pre-connect actions are done by setup_hook() which is called at the end of d.py's login() + await self.connect() + + async def setup_hook(self) -> None: await self._pre_fetch_owners() await self._pre_connect() - await self.connect() async def send_help_for( self, @@ -1205,7 +1245,9 @@ class Red( async def embed_requested( self, - channel: Union[discord.TextChannel, commands.Context, discord.User, discord.Member], + channel: Union[ + discord.TextChannel, commands.Context, discord.User, discord.Member, discord.Thread + ], *, command: Optional[commands.Command] = None, check_permissions: bool = True, @@ -1215,7 +1257,7 @@ class Red( Arguments --------- - channel : `discord.abc.Messageable` + channel : Union[`discord.TextChannel`, `commands.Context`, `discord.User`, `discord.Member`, `discord.Thread`] The target messageable object to check embed settings for. Keyword Arguments @@ -1236,9 +1278,8 @@ class Red( Raises ------ TypeError - When the passed channel is of type `discord.GroupChannel` - or `discord.DMChannel` - + When the passed channel is of type `discord.GroupChannel`, + `discord.DMChannel`, or `discord.PartialMessageable`. """ async def get_command_setting(guild_id: int) -> Optional[bool]: @@ -1247,9 +1288,6 @@ class Red( scope = self._config.custom(COMMAND_SCOPE, command.qualified_name, guild_id) return await scope.embeds() - if isinstance(channel, (discord.GroupChannel, discord.DMChannel)): - raise TypeError("You cannot pass a GroupChannel or DMChannel to this method") - # using dpy_commands.Context to keep the Messageable contract in full if isinstance(channel, dpy_commands.Context): command = command or channel.command @@ -1259,11 +1297,21 @@ class Red( else channel.channel ) - if isinstance(channel, discord.TextChannel): + if isinstance( + channel, (discord.GroupChannel, discord.DMChannel, discord.PartialMessageable) + ): + raise TypeError( + "You cannot pass a GroupChannel, DMChannel, or PartialMessageable to this method." + ) + + if isinstance(channel, (discord.TextChannel, discord.Thread)): + channel_id = channel.parent_id if isinstance(channel, discord.Thread) else channel.id + if check_permissions and not channel.permissions_for(channel.guild.me).embed_links: return False - if (channel_setting := await self._config.channel(channel).embeds()) is not None: + channel_setting = await self._config.channel_from_id(channel_id).embeds() + if channel_setting is not None: return channel_setting if (command_setting := await get_command_setting(channel.guild.id)) is not None: @@ -1282,7 +1330,7 @@ class Red( global_setting = await self._config.embeds() return global_setting - async def is_owner(self, user: Union[discord.User, discord.Member]) -> bool: + async def is_owner(self, user: Union[discord.User, discord.Member], /) -> bool: """ Determines if the user should be considered a bot owner. @@ -1317,10 +1365,10 @@ class Red( """ data = await self._config.all() commands_scope = data["invite_commands_scope"] - scopes = ("bot", "applications.commands") if commands_scope else None + scopes = ("bot", "applications.commands") if commands_scope else ("bot",) perms_int = data["invite_perm"] permissions = discord.Permissions(perms_int) - return discord.utils.oauth_url(self._app_info.id, permissions, scopes=scopes) + return discord.utils.oauth_url(self._app_info.id, permissions=permissions, scopes=scopes) async def is_invite_url_public(self) -> bool: """ @@ -1336,9 +1384,8 @@ class Red( async def is_admin(self, member: discord.Member) -> bool: """Checks if a member is an admin of their guild.""" try: - member_snowflakes = member._roles # DEP-WARN for snowflake in await self._config.guild(member.guild).admin_role(): - if member_snowflakes.has(snowflake): # Dep-WARN + if member.get_role(snowflake): return True except AttributeError: # someone passed a webhook to this pass @@ -1347,12 +1394,11 @@ class Red( async def is_mod(self, member: discord.Member) -> bool: """Checks if a member is a mod or admin of their guild.""" try: - member_snowflakes = member._roles # DEP-WARN for snowflake in await self._config.guild(member.guild).admin_role(): - if member_snowflakes.has(snowflake): # DEP-WARN + if member.get_role(snowflake): return True for snowflake in await self._config.guild(member.guild).mod_role(): - if member_snowflakes.has(snowflake): # DEP-WARN + if member.get_role(snowflake): return True except AttributeError: # someone passed a webhook to this pass @@ -1495,10 +1541,10 @@ class Red( for service in service_names: self.dispatch("red_api_tokens_update", service, MappingProxyType({})) - async def get_context(self, message, *, cls=commands.Context): + async def get_context(self, message, /, *, cls=commands.Context): return await super().get_context(message, cls=cls) - async def process_commands(self, message: discord.Message): + async def process_commands(self, message: discord.Message, /): """ Same as base method, but dispatches an additional event for cogs which want to handle normal messages differently to command @@ -1507,7 +1553,14 @@ class Red( """ if not message.author.bot: ctx = await self.get_context(message) - await self.invoke(ctx) + if ctx.invoked_with and isinstance(message.channel, discord.PartialMessageable): + log.warning( + "Discarded a command message (ID: %s) with PartialMessageable channel: %r", + message.id, + message.channel, + ) + else: + await self.invoke(ctx) else: ctx = None @@ -1544,18 +1597,23 @@ class Red( raise discord.ClientException(f"extension {name} does not have a setup function") try: - if asyncio.iscoroutinefunction(lib.setup): - await lib.setup(self) - else: - lib.setup(self) + await lib.setup(self) except Exception as e: - self._remove_module_references(lib.__name__) - self._call_module_finalizers(lib, name) + await self._remove_module_references(lib.__name__) + await self._call_module_finalizers(lib, name) raise else: self._BotBase__extensions[name] = lib - def remove_cog(self, cogname: str): + async def remove_cog( + self, + cogname: str, + /, + *, + # DEP-WARN: MISSING is implementation detail + guild: Optional[discord.abc.Snowflake] = discord.utils.MISSING, + guilds: List[discord.abc.Snowflake] = discord.utils.MISSING, + ) -> Optional[commands.Cog]: cog = self.get_cog(cogname) if cog is None: return @@ -1568,13 +1626,15 @@ class Red( else: self.remove_permissions_hook(hook) - super().remove_cog(cogname) + await super().remove_cog(cogname, guild=guild, guilds=guilds) cog.requires.reset() for meth in self.rpc_handlers.pop(cogname.upper(), ()): self.unregister_rpc_handler(meth) + return cog + async def is_automod_immune( self, to_check: Union[discord.Message, commands.Context, discord.abc.User, discord.Role] ) -> bool: @@ -1656,15 +1716,28 @@ class Red( return await destination.send(content=content, **kwargs) - def add_cog(self, cog: commands.Cog): + async def add_cog( + self, + cog: commands.Cog, + /, + *, + override: bool = False, + # DEP-WARN: MISSING is implementation detail + guild: Optional[discord.abc.Snowflake] = discord.utils.MISSING, + guilds: List[discord.abc.Snowflake] = discord.utils.MISSING, + ) -> None: 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 cog.__cog_name__ in self.cogs: - raise RuntimeError(f"There is already a cog named {cog.__cog_name__} loaded.") + cog_name = cog.__cog_name__ + if cog_name in self.cogs: + if not override: + raise discord.ClientException(f"Cog named {cog_name!r} already loaded") + await self.remove_cog(cog_name, guild=guild, guilds=guilds) + if not hasattr(cog, "requires"): commands.Cog.__init__(cog) @@ -1680,7 +1753,7 @@ class Red( self.add_permissions_hook(hook) added_hooks.append(hook) - super().add_cog(cog) + await super().add_cog(cog, guild=guild, guilds=guilds) self.dispatch("cog_add", cog) if "permissions" not in self.extensions: cog.requires.ready_event.set() @@ -1697,7 +1770,7 @@ class Red( del cog raise - def add_command(self, command: commands.Command) -> None: + def add_command(self, command: commands.Command, /) -> None: if not isinstance(command, commands.Command): raise RuntimeError("Commands must be instances of `redbot.core.commands.Command`") @@ -1713,7 +1786,7 @@ class Red( if permissions_not_loaded: subcommand.requires.ready_event.set() - def remove_command(self, name: str) -> Optional[commands.Command]: + def remove_command(self, name: str, /) -> Optional[commands.Command]: command = super().remove_command(name) if command is None: return None @@ -1802,7 +1875,9 @@ class Red( ctx.permission_state = commands.PermState.DENIED_BY_HOOK return False - async def get_owner_notification_destinations(self) -> List[discord.abc.Messageable]: + async def get_owner_notification_destinations( + self, + ) -> List[Union[discord.TextChannel, discord.User]]: """ Gets the users and channels to send to """ diff --git a/redbot/core/commands/__init__.py b/redbot/core/commands/__init__.py index a60d590a4..187cbd513 100644 --- a/redbot/core/commands/__init__.py +++ b/redbot/core/commands/__init__.py @@ -29,13 +29,11 @@ from .converter import ( parse_timedelta as parse_timedelta, NoParseOptional as NoParseOptional, UserInputOptional as UserInputOptional, - Literal as Literal, RawUserIdConverter as RawUserIdConverter, CogConverter as CogConverter, CommandConverter as CommandConverter, ) from .errors import ( - ConversionFailure as ConversionFailure, BotMissingPermissions as BotMissingPermissions, UserFeedbackCheckFailure as UserFeedbackCheckFailure, ArgParserFailure as ArgParserFailure, @@ -57,33 +55,23 @@ from .requires import ( permissions_check as permissions_check, bot_has_permissions as bot_has_permissions, bot_in_a_guild as bot_in_a_guild, + bot_can_manage_channel as bot_can_manage_channel, + bot_can_react as bot_can_react, has_permissions as has_permissions, + can_manage_channel as can_manage_channel, has_guild_permissions as has_guild_permissions, is_owner as is_owner, guildowner as guildowner, + guildowner_or_can_manage_channel as guildowner_or_can_manage_channel, guildowner_or_permissions as guildowner_or_permissions, admin as admin, + admin_or_can_manage_channel as admin_or_can_manage_channel, admin_or_permissions as admin_or_permissions, mod as mod, + mod_or_can_manage_channel as mod_or_can_manage_channel, mod_or_permissions as mod_or_permissions, ) -from ._dpy_reimplements import ( - check as check, - guild_only as guild_only, - cooldown as cooldown, - dm_only as dm_only, - is_nsfw as is_nsfw, - has_role as has_role, - has_any_role as has_any_role, - bot_has_role as bot_has_role, - when_mentioned_or as when_mentioned_or, - when_mentioned as when_mentioned, - bot_has_any_role as bot_has_any_role, - before_invoke as before_invoke, - after_invoke as after_invoke, -) - ### DEP-WARN: Check this *every* discord.py update from discord.ext.commands import ( BadArgument as BadArgument, @@ -137,7 +125,6 @@ from discord.ext.commands import ( ColorConverter as ColorConverter, VoiceChannelConverter as VoiceChannelConverter, StageChannelConverter as StageChannelConverter, - StoreChannelConverter as StoreChannelConverter, NSFWChannelRequired as NSFWChannelRequired, IDConverter as IDConverter, MissingRequiredArgument as MissingRequiredArgument, @@ -167,4 +154,39 @@ from discord.ext.commands import ( EmojiNotFound as EmojiNotFound, PartialEmojiConversionFailure as PartialEmojiConversionFailure, BadBoolArgument as BadBoolArgument, + TooManyFlags as TooManyFlags, + MissingRequiredFlag as MissingRequiredFlag, + flag as flag, + FlagError as FlagError, + ObjectNotFound as ObjectNotFound, + GuildStickerNotFound as GuildStickerNotFound, + ThreadNotFound as ThreadNotFound, + GuildChannelConverter as GuildChannelConverter, + run_converters as run_converters, + Flag as Flag, + BadFlagArgument as BadFlagArgument, + BadColorArgument as BadColorArgument, + dynamic_cooldown as dynamic_cooldown, + BadLiteralArgument as BadLiteralArgument, + DynamicCooldownMapping as DynamicCooldownMapping, + ThreadConverter as ThreadConverter, + GuildStickerConverter as GuildStickerConverter, + ObjectConverter as ObjectConverter, + FlagConverter as FlagConverter, + MissingFlagArgument as MissingFlagArgument, + ScheduledEventConverter as ScheduledEventConverter, + ScheduledEventNotFound as ScheduledEventNotFound, + check as check, + guild_only as guild_only, + cooldown as cooldown, + dm_only as dm_only, + is_nsfw as is_nsfw, + has_role as has_role, + has_any_role as has_any_role, + bot_has_role as bot_has_role, + when_mentioned_or as when_mentioned_or, + when_mentioned as when_mentioned, + bot_has_any_role as bot_has_any_role, + before_invoke as before_invoke, + after_invoke as after_invoke, ) diff --git a/redbot/core/commands/_dpy_reimplements.py b/redbot/core/commands/_dpy_reimplements.py deleted file mode 100644 index 9e1031026..000000000 --- a/redbot/core/commands/_dpy_reimplements.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations -import inspect -import functools -from typing import ( - TypeVar, - Callable, - Awaitable, - Coroutine, - Union, - Type, - TYPE_CHECKING, - List, - Any, - Generator, - Protocol, - overload, -) - -import discord -from discord.ext import commands as dpy_commands - -# So much of this can be stripped right back out with proper stubs. -if not TYPE_CHECKING: - from discord.ext.commands import ( - check as check, - guild_only as guild_only, - dm_only as dm_only, - is_nsfw as is_nsfw, - has_role as has_role, - has_any_role as has_any_role, - bot_has_role as bot_has_role, - bot_has_any_role as bot_has_any_role, - cooldown as cooldown, - before_invoke as before_invoke, - after_invoke as after_invoke, - ) - -from ..i18n import Translator -from .context import Context -from .commands import Command - - -_ = Translator("nah", __file__) - - -""" -Anything here is either a reimplementation or re-export -of a discord.py function or class with more lies for mypy -""" - -__all__ = [ - "check", - # "check_any", # discord.py 1.3 - "guild_only", - "dm_only", - "is_nsfw", - "has_role", - "has_any_role", - "bot_has_role", - "bot_has_any_role", - "when_mentioned_or", - "cooldown", - "when_mentioned", - "before_invoke", - "after_invoke", -] - -_CT = TypeVar("_CT", bound=Context) -_T = TypeVar("_T") -_F = TypeVar("_F") -CheckType = Union[Callable[[_CT], bool], Callable[[_CT], Coroutine[Any, Any, bool]]] -CoroLike = Callable[..., Union[Awaitable[_T], Generator[Any, None, _T]]] -InvokeHook = Callable[[_CT], Coroutine[Any, Any, bool]] - - -class CheckDecorator(Protocol): - predicate: Coroutine[Any, Any, bool] - - @overload - def __call__(self, func: _CT) -> _CT: - ... - - @overload - def __call__(self, func: CoroLike) -> CoroLike: - ... - - -if TYPE_CHECKING: - - def check(predicate: CheckType) -> CheckDecorator: - ... - - def guild_only() -> CheckDecorator: - ... - - def dm_only() -> CheckDecorator: - ... - - def is_nsfw() -> CheckDecorator: - ... - - def has_role() -> CheckDecorator: - ... - - def has_any_role() -> CheckDecorator: - ... - - def bot_has_role() -> CheckDecorator: - ... - - def bot_has_any_role() -> CheckDecorator: - ... - - def cooldown(rate: int, per: float, type: dpy_commands.BucketType = ...) -> Callable[[_F], _F]: - ... - - def before_invoke(coro: InvokeHook) -> Callable[[_F], _F]: - ... - - def after_invoke(coro: InvokeHook) -> Callable[[_F], _F]: - ... - - -PrefixCallable = Callable[[dpy_commands.bot.BotBase, discord.Message], List[str]] - - -def when_mentioned(bot: dpy_commands.bot.BotBase, msg: discord.Message) -> List[str]: - return [f"<@{bot.user.id}> ", f"<@!{bot.user.id}> "] - - -def when_mentioned_or(*prefixes) -> PrefixCallable: - def inner(bot: dpy_commands.bot.BotBase, msg: discord.Message) -> List[str]: - r = list(prefixes) - r = when_mentioned(bot, msg) + r - return r - - return inner diff --git a/redbot/core/commands/commands.py b/redbot/core/commands/commands.py index f0457cdd1..ceed466f3 100644 --- a/redbot/core/commands/commands.py +++ b/redbot/core/commands/commands.py @@ -285,13 +285,6 @@ class Command(CogCommandMixin, DPYCommand): (type used will be of the inner type instead) """ - def __call__(self, *args, **kwargs): - if self.cog: - # We need to inject cog as self here - return self.callback(self.cog, *args, **kwargs) - else: - return self.callback(*args, **kwargs) - def __init__(self, *args, **kwargs): self.ignore_optional_for_conversion = kwargs.pop("ignore_optional_for_conversion", False) super().__init__(*args, **kwargs) @@ -323,60 +316,27 @@ class Command(CogCommandMixin, DPYCommand): @callback.setter def callback(self, function): - """ - Below should be mostly the same as discord.py + # Below should be mostly the same as discord.py + # + # Here's the list of cases where the behavior differs: + # - `typing.Optional` behavior is changed + # when `ignore_optional_for_conversion` option is used + super(Command, Command).callback.__set__(self, function) - Currently, we modify behavior for + if not self.ignore_optional_for_conversion: + return - - functools.partial support - - typing.Optional behavior change as an option - """ - self._callback = function - if isinstance(function, functools.partial): - self.module = function.func.__module__ - globals_ = function.func.__globals__ - else: - self.module = function.__module__ - globals_ = function.__globals__ - - signature = inspect.signature(function) - self.params = signature.parameters.copy() - - # PEP-563 allows postponing evaluation of annotations with a __future__ - # import. When postponed, Parameter.annotation will be a string and must - # be replaced with the real value for the converters to work later on + _NoneType = type(None) for key, value in self.params.items(): - if isinstance(value.annotation, str): - self.params[key] = value = value.replace( - annotation=eval(value.annotation, globals_) - ) - - # fail early for when someone passes an unparameterized Greedy type - if value.annotation is Greedy: - raise TypeError("Unparameterized Greedy[...] is disallowed in signature.") - - if not self.ignore_optional_for_conversion: - continue # reduces indentation compared to alternative - - try: - vtype = value.annotation.__origin__ - if vtype is Union: - _NoneType = type if TYPE_CHECKING else type(None) - args = value.annotation.__args__ - if _NoneType in args: - args = tuple(a for a in args if a is not _NoneType) - if len(args) == 1: - # can't have a union of 1 or 0 items - # 1 prevents this from becoming 0 - # we need to prevent 2 become 1 - # (Don't change that to becoming, it's intentional :musical_note:) - self.params[key] = value = value.replace(annotation=args[0]) - else: - # and mypy wretches at the correct Union[args] - temp_type = type if TYPE_CHECKING else Union[args] - self.params[key] = value = value.replace(annotation=temp_type) - except AttributeError: + origin = getattr(value.annotation, "__origin__", None) + if origin is not Union: continue + args = value.annotation.__args__ + if _NoneType in args: + args = tuple(a for a in args if a is not _NoneType) + # typing.Union is automatically deduplicated and flattened + # so we don't need to anything else here + self.params[key] = value = value.replace(annotation=Union[args]) @property def help(self): @@ -420,6 +380,7 @@ class Command(CogCommandMixin, DPYCommand): async def can_run( self, ctx: "Context", + /, *, check_all_parents: bool = False, change_permission_state: bool = False, @@ -476,7 +437,7 @@ class Command(CogCommandMixin, DPYCommand): if not change_permission_state: ctx.permission_state = original_state - async def prepare(self, ctx): + async def prepare(self, ctx, /): ctx.command = self if not self.enabled: @@ -502,39 +463,6 @@ class Command(CogCommandMixin, DPYCommand): await self._max_concurrency.release(ctx) raise - async def do_conversion( - self, ctx: "Context", converter, argument: str, param: inspect.Parameter - ): - """Convert an argument according to its type annotation. - - Raises - ------ - ConversionFailure - If doing the conversion failed. - - Returns - ------- - Any - The converted argument. - - """ - # Let's not worry about all of this junk if it's just a str converter - if converter is str: - return argument - - try: - return await super().do_conversion(ctx, converter, argument, param) - except BadArgument as exc: - raise ConversionFailure(converter, argument, param, *exc.args) from exc - except ValueError as exc: - # Some common converters need special treatment... - if converter in (int, float): - message = _('"{argument}" is not a number.').format(argument=argument) - raise ConversionFailure(converter, argument, param, message) from exc - - # We should expose anything which might be a bug in the converter - raise exc - async def can_see(self, ctx: "Context"): """Check if this command is visible in the given context. @@ -636,7 +564,7 @@ class Command(CogCommandMixin, DPYCommand): break return old_rule, new_rule - def error(self, coro): + def error(self, coro, /): """ A decorator that registers a coroutine as a local error handler. @@ -796,7 +724,7 @@ class Group(GroupMixin, Command, CogGroupMixin, DPYGroup): self.autohelp = kwargs.pop("autohelp", True) super().__init__(*args, **kwargs) - async def invoke(self, ctx: "Context"): + async def invoke(self, ctx: "Context", /): # we skip prepare in some cases to avoid some things # We still always want this part of the behavior though ctx.command = self @@ -971,7 +899,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin): """ raise RedUnhandledAPI() - async def can_run(self, ctx: "Context", **kwargs) -> bool: + async def can_run(self, ctx: "Context", /, **kwargs) -> bool: """ This really just exists to allow easy use with other methods using can_run on commands and groups such as help formatters. @@ -999,7 +927,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin): return can_run - async def can_see(self, ctx: "Context") -> bool: + async def can_see(self, ctx: "Context", /) -> bool: """Check if this cog is visible in the given context. In short, this will verify whether @@ -1112,7 +1040,7 @@ class _AlwaysAvailableMixin: This particular class is not supported for 3rd party use """ - async def can_run(self, ctx, *args, **kwargs) -> bool: + async def can_run(self, ctx, /, *args, **kwargs) -> bool: return not ctx.author.bot can_see = can_run @@ -1161,7 +1089,7 @@ class _ForgetMeSpecialCommand(_RuleDropper, Command): We need special can_run behavior here """ - async def can_run(self, ctx, *args, **kwargs) -> bool: + async def can_run(self, ctx, /, *args, **kwargs) -> bool: return await ctx.bot._config.datarequests.allow_user_requests() can_see = can_run diff --git a/redbot/core/commands/context.py b/redbot/core/commands/context.py index 1ebd5fdf4..d385eff74 100644 --- a/redbot/core/commands/context.py +++ b/redbot/core/commands/context.py @@ -11,7 +11,7 @@ from discord.ext.commands import Context as DPYContext from .requires import PermState from ..utils.chat_formatting import box from ..utils.predicates import MessagePredicate -from ..utils import common_filters +from ..utils import can_user_react_in, common_filters if TYPE_CHECKING: from .commands import Command @@ -139,7 +139,7 @@ class Context(DPYContext): :code:`True` if adding the reaction succeeded. """ try: - if not self.channel.permissions_for(self.me).add_reactions: + if not can_user_react_in(self.me, self.channel): raise RuntimeError await self.message.add_reaction(reaction) except (RuntimeError, discord.HTTPException): @@ -283,16 +283,6 @@ class Context(DPYContext): allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False), ) - @property - def clean_prefix(self) -> str: - """ - str: The command prefix, but with a sanitized version of the bot's mention if it was used as prefix. - This can be used in a context where discord user mentions might not render properly. - """ - me = self.me - pattern = re.compile(rf"<@!?{me.id}>") - return pattern.sub(f"@{me.display_name}".replace("\\", r"\\"), self.prefix) - @property def me(self) -> Union[discord.ClientUser, discord.Member]: """ @@ -349,7 +339,7 @@ if TYPE_CHECKING or os.getenv("BUILDING_DOCS", False): ... @property - def channel(self) -> discord.TextChannel: + def channel(self) -> Union[discord.TextChannel, discord.Thread]: ... @property diff --git a/redbot/core/commands/converter.py b/redbot/core/commands/converter.py index 79fd2b8a4..16f986d5b 100644 --- a/redbot/core/commands/converter.py +++ b/redbot/core/commands/converter.py @@ -19,7 +19,6 @@ from typing import ( Dict, Type, TypeVar, - Literal as Literal, Union as UserInputOptional, ) @@ -44,7 +43,6 @@ __all__ = [ "get_timedelta_converter", "parse_relativedelta", "parse_timedelta", - "Literal", "CommandConverter", "CogConverter", ] @@ -281,7 +279,7 @@ else: Returns a typechecking safe `DictConverter` suitable for use with discord.py """ - class PartialMeta(type): + class PartialMeta(type(DictConverter)): __call__ = functools.partialmethod( type(DictConverter).__call__, *expected_keys, delims=delims ) @@ -389,7 +387,7 @@ else: The converter class, which will be a subclass of `TimedeltaConverter` """ - class PartialMeta(type): + class PartialMeta(type(DictConverter)): __call__ = functools.partialmethod( type(DictConverter).__call__, allowed_units=allowed_units, @@ -475,44 +473,6 @@ if not TYPE_CHECKING: #: This converter class is still provisional. UserInputOptional = Optional - -if not TYPE_CHECKING: - - class Literal(dpy_commands.Converter): - """ - This can be used as a converter for `typing.Literal`. - - In a type checking context it is `typing.Literal`. - In a runtime context, it's a converter which only matches the literals it was given. - - - .. warning:: - This converter class is still provisional. - """ - - def __init__(self, valid_names: Tuple[str]): - self.valid_names = valid_names - - def __call__(self, ctx, arg): - # Callable's are treated as valid types: - # https://github.com/python/cpython/blob/3.8/Lib/typing.py#L148 - # Without this, ``typing.Union[Literal["clear"], bool]`` would fail - return self.convert(ctx, arg) - - async def convert(self, ctx, arg): - if arg in self.valid_names: - return arg - raise BadArgument(_("Expected one of: {}").format(humanize_list(self.valid_names))) - - def __class_getitem__(cls, k): - if not k: - raise ValueError("Need at least one value for Literal") - if isinstance(k, tuple): - return cls(k) - else: - return cls((k,)) - - if TYPE_CHECKING: CommandConverter = dpy_commands.Command CogConverter = dpy_commands.Cog diff --git a/redbot/core/commands/errors.py b/redbot/core/commands/errors.py index 63f7d0b5e..8c4752bba 100644 --- a/redbot/core/commands/errors.py +++ b/redbot/core/commands/errors.py @@ -3,12 +3,11 @@ import inspect import discord from discord.ext import commands -__all__ = [ - "ConversionFailure", +__all__ = ( "BotMissingPermissions", "UserFeedbackCheckFailure", "ArgParserFailure", -] +) class ConversionFailure(commands.BadArgument): diff --git a/redbot/core/commands/help.py b/redbot/core/commands/help.py index 9ee12a09c..745ea6606 100644 --- a/redbot/core/commands/help.py +++ b/redbot/core/commands/help.py @@ -39,7 +39,7 @@ from discord.ext import commands as dpy_commands from . import commands from .context import Context from ..i18n import Translator -from ..utils import menus +from ..utils import can_user_react_in, menus from ..utils.mod import mass_purge from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results from ..utils.chat_formatting import ( @@ -478,7 +478,7 @@ class RedHelpFormatter(HelpFormatterABC): author_info = { "name": _("{ctx.me.display_name} Help Menu").format(ctx=ctx), - "icon_url": ctx.me.avatar_url, + "icon_url": ctx.me.display_avatar, } # Offset calculation here is for total embed size limit @@ -733,7 +733,7 @@ class RedHelpFormatter(HelpFormatterABC): if use_embeds: ret.set_author( name=_("{ctx.me.display_name} Help Menu").format(ctx=ctx), - icon_url=ctx.me.avatar_url, + icon_url=ctx.me.display_avatar, ) tagline = help_settings.tagline or self.get_default_tagline(ctx) ret.set_footer(text=tagline) @@ -746,7 +746,7 @@ class RedHelpFormatter(HelpFormatterABC): ret = discord.Embed(color=(await ctx.embed_color()), description=ret) ret.set_author( name=_("{ctx.me.display_name} Help Menu").format(ctx=ctx), - icon_url=ctx.me.avatar_url, + icon_url=ctx.me.display_avatar, ) tagline = help_settings.tagline or self.get_default_tagline(ctx) ret.set_footer(text=tagline) @@ -765,7 +765,7 @@ class RedHelpFormatter(HelpFormatterABC): ret = discord.Embed(color=(await ctx.embed_color()), description=ret) ret.set_author( name=_("{ctx.me.display_name} Help Menu").format(ctx=ctx), - icon_url=ctx.me.avatar_url, + icon_url=ctx.me.display_avatar, ) tagline = help_settings.tagline or self.get_default_tagline(ctx) ret.set_footer(text=tagline) @@ -813,15 +813,7 @@ class RedHelpFormatter(HelpFormatterABC): """ Sends pages based on settings. """ - - # save on config calls - channel_permissions = ctx.channel.permissions_for(ctx.me) - - if not ( - channel_permissions.add_reactions - and channel_permissions.read_message_history - and help_settings.use_menus - ): + if not (can_user_react_in(ctx.me, ctx.channel) and help_settings.use_menus): max_pages_in_guild = help_settings.max_pages_in_guild use_DMs = len(pages) > max_pages_in_guild destination = ctx.author if use_DMs else ctx.channel @@ -846,17 +838,18 @@ class RedHelpFormatter(HelpFormatterABC): if use_DMs and help_settings.use_tick: await ctx.tick() # The if statement takes into account that 'destination' will be - # the context channel in non-DM context, reusing 'channel_permissions' to avoid - # computing the permissions twice. + # the context channel in non-DM context. if ( not use_DMs # we're not in DMs and delete_delay > 0 # delete delay is enabled - and channel_permissions.manage_messages # we can manage messages here + and ctx.channel.permissions_for(ctx.me).manage_messages # we can manage messages ): # We need to wrap this in a task to not block after-sending-help interactions. - # The channel has to be TextChannel as we can't bulk-delete from DMs + # The channel has to be TextChannel or Thread as we can't bulk-delete from DMs async def _delete_delay_help( - channel: discord.TextChannel, messages: List[discord.Message], delay: int + channel: Union[discord.TextChannel, discord.Thread], + messages: List[discord.Message], + delay: int, ): await asyncio.sleep(delay) await mass_purge(messages, channel) diff --git a/redbot/core/commands/requires.py b/redbot/core/commands/requires.py index f9e74d703..7a4759def 100644 --- a/redbot/core/commands/requires.py +++ b/redbot/core/commands/requires.py @@ -30,6 +30,8 @@ import discord from discord.ext.commands import check from .errors import BotMissingPermissions +from redbot.core import utils + if TYPE_CHECKING: from .commands import Command from .context import Context @@ -48,14 +50,20 @@ __all__ = [ "permissions_check", "bot_has_permissions", "bot_in_a_guild", + "bot_can_manage_channel", + "bot_can_react", "has_permissions", + "can_manage_channel", "has_guild_permissions", "is_owner", "guildowner", + "guildowner_or_can_manage_channel", "guildowner_or_permissions", "admin", + "admin_or_can_manage_channel", "admin_or_permissions", "mod", + "mod_or_can_manage_channel", "mod_or_permissions", "transition_permstate_to", "PermStateTransitions", @@ -135,12 +143,11 @@ class PrivilegeLevel(enum.IntEnum): # admin or mod role. guild_settings = ctx.bot._config.guild(ctx.guild) - member_snowflakes = ctx.author._roles # DEP-WARN for snowflake in await guild_settings.admin_role(): - if member_snowflakes.has(snowflake): # DEP-WARN + if ctx.author.get_role(snowflake): return cls.ADMIN for snowflake in await guild_settings.mod_role(): - if member_snowflakes.has(snowflake): # DEP-WARN + if ctx.author.get_role(snowflake): return cls.MOD return cls.NONE @@ -596,7 +603,10 @@ class Requires: channels = [] if author.voice is not None: channels.append(author.voice.channel) - channels.append(ctx.channel) + if isinstance(ctx.channel, discord.Thread): + channels.append(ctx.channel.parent) + else: + channels.append(ctx.channel) category = ctx.channel.category if category is not None: channels.append(category) @@ -731,6 +741,77 @@ def bot_in_a_guild(): return check(predicate) +def bot_can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]: + """ + Complain if the bot is missing permissions to manage channel. + + This check properly resolves the permissions for `discord.Thread` as well. + + Parameters + ---------- + allow_thread_owner: bool + If ``True``, the command will also be allowed to run if the bot is a thread owner. + This can, for example, be useful to check if the bot can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + """ + + def predicate(ctx: "Context") -> bool: + if ctx.guild is None: + return False + + if not utils.can_manage_channel_in( + ctx.channel, ctx.me, allow_thread_owner=allow_thread_owner + ): + if isinstance(ctx.channel, discord.Thread): + # This is a slight lie - thread owner *might* also be allowed + # but we just say that bot is missing the Manage Threads permission. + missing = discord.Permissions(manage_threads=True) + else: + missing = discord.Permissions(manage_channels=True) + raise BotMissingPermissions(missing=missing) + + return True + + return check(predicate) + + +def bot_can_react() -> Callable[[_T], _T]: + """ + Complain if the bot is missing permissions to react. + + This check properly resolves the permissions for `discord.Thread` as well. + """ + + async def predicate(ctx: "Context") -> bool: + return not (isinstance(ctx.channel, discord.Thread) and ctx.channel.archived) + + def decorator(func: _T) -> _T: + func = bot_has_permissions(read_message_history=True, add_reactions=True)(func) + func = check(predicate)(func) + return func + + return decorator + + +def _can_manage_channel_deco( + privilege_level: Optional[PrivilegeLevel] = None, allow_thread_owner: bool = False +) -> Callable[[_T], _T]: + async def predicate(ctx: "Context") -> bool: + if utils.can_manage_channel_in( + ctx.channel, ctx.author, allow_thread_owner=allow_thread_owner + ): + return True + + if privilege_level is not None: + if await PrivilegeLevel.from_ctx(ctx) >= privilege_level: + return True + + return False + + return permissions_check(predicate) + + def has_permissions(**perms: bool): """Restrict the command to users with these permissions. @@ -741,6 +822,24 @@ def has_permissions(**perms: bool): return Requires.get_decorator(None, perms) +def can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]: + """Restrict the command to users with permissions to manage channel. + + This check properly resolves the permissions for `discord.Thread` as well. + + This check can be overridden by rules. + + Parameters + ---------- + allow_thread_owner: bool + If ``True``, the command will also be allowed to run if the author is a thread owner. + This can, for example, be useful to check if the author can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + """ + return _can_manage_channel_deco(allow_thread_owner) + + def is_owner(): """Restrict the command to bot owners. @@ -757,6 +856,24 @@ def guildowner_or_permissions(**perms: bool): return Requires.get_decorator(PrivilegeLevel.GUILD_OWNER, perms) +def guildowner_or_can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]: + """Restrict the command to the guild owner or user with permissions to manage channel. + + This check properly resolves the permissions for `discord.Thread` as well. + + This check can be overridden by rules. + + Parameters + ---------- + allow_thread_owner: bool + If ``True``, the command will also be allowed to run if the author is a thread owner. + This can, for example, be useful to check if the author can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + """ + return _can_manage_channel_deco(PrivilegeLevel.GUILD_OWNER, allow_thread_owner) + + def guildowner(): """Restrict the command to the guild owner. @@ -773,6 +890,24 @@ def admin_or_permissions(**perms: bool): return Requires.get_decorator(PrivilegeLevel.ADMIN, perms) +def admin_or_can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]: + """Restrict the command to users with the admin role or permissions to manage channel. + + This check properly resolves the permissions for `discord.Thread` as well. + + This check can be overridden by rules. + + Parameters + ---------- + allow_thread_owner: bool + If ``True``, the command will also be allowed to run if the author is a thread owner. + This can, for example, be useful to check if the author can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + """ + return _can_manage_channel_deco(PrivilegeLevel.ADMIN, allow_thread_owner) + + def admin(): """Restrict the command to users with the admin role. @@ -789,6 +924,24 @@ def mod_or_permissions(**perms: bool): return Requires.get_decorator(PrivilegeLevel.MOD, perms) +def mod_or_can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]: + """Restrict the command to users with the mod role or permissions to manage channel. + + This check properly resolves the permissions for `discord.Thread` as well. + + This check can be overridden by rules. + + Parameters + ---------- + allow_thread_owner: bool + If ``True``, the command will also be allowed to run if the author is a thread owner. + This can, for example, be useful to check if the author can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + """ + return _can_manage_channel_deco(PrivilegeLevel.MOD, allow_thread_owner) + + def mod(): """Restrict the command to users with the mod role. diff --git a/redbot/core/config.py b/redbot/core/config.py index 748614e7f..a83962714 100644 --- a/redbot/core/config.py +++ b/redbot/core/config.py @@ -1009,14 +1009,14 @@ class Config(metaclass=ConfigMeta): ) return self._get_base_group(self.CHANNEL, str(channel_id)) - def channel(self, channel: discord.abc.GuildChannel) -> Group: + def channel(self, channel: Union[discord.abc.GuildChannel, discord.Thread]) -> Group: """Returns a `Group` for the given channel. This does not discriminate between text and voice channels. Parameters ---------- - channel : `discord.abc.GuildChannel` + channel : `discord.abc.GuildChannel` or `discord.Thread` A channel object. Returns diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 9678a5abb..6c58d37e8 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -39,7 +39,7 @@ from . import ( modlog, ) from ._diagnoser import IssueDiagnoser -from .utils import AsyncIter +from .utils import AsyncIter, can_user_send_messages_in from .utils._internal_utils import fetch_latest_red_version_info from .utils.predicates import MessagePredicate from .utils.chat_formatting import ( @@ -275,7 +275,7 @@ class CoreLogic: for name in pkg_names: if name in bot.extensions: - bot.unload_extension(name) + await bot.unload_extension(name) await bot.remove_loaded_package(name) unloaded_packages.append(name) else: @@ -318,7 +318,7 @@ class CoreLogic: The current (or new) username of the bot. """ if name is not None: - await self.bot.user.edit(username=name) + return (await self.bot.user.edit(username=name)).name return self.bot.user.name @@ -535,7 +535,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): uptime_str = humanize_timedelta(timedelta=delta) or _("Less than one second.") await ctx.send( _("I have been up for: **{time_quantity}** (since {timestamp})").format( - time_quantity=uptime_str, timestamp=f"" + time_quantity=uptime_str, timestamp=discord.utils.format_dt(uptime, "f") ) ) @@ -1366,6 +1366,15 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): **Arguments:** - `[enabled]` - Whether to use embeds in this channel. Leave blank to reset to default. """ + if isinstance(ctx.channel, discord.Thread): + await ctx.send( + _( + "This setting cannot be set for threads. If you want to set this for" + " the parent channel, send the command in that channel." + ) + ) + return + if enabled is None: await self.bot._config.channel(ctx.channel).embeds.clear() await ctx.send(_("Embeds will now fall back to the global setting.")) @@ -2403,7 +2412,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): "must be a valid image in either JPG or PNG format." ) ) - except discord.InvalidArgument: + except ValueError: await ctx.send(_("JPG / PNG format only.")) else: await ctx.send(_("Done.")) @@ -3211,7 +3220,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): @_set_ownernotifications.command(name="adddestination") async def _set_ownernotifications_adddestination( - self, ctx: commands.Context, *, channel: Union[discord.TextChannel, int] + self, ctx: commands.Context, *, channel: discord.TextChannel ): """ Adds a destination text channel to receive owner notifications. @@ -3223,15 +3232,9 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): **Arguments:** - `` - The channel to send owner notifications to. """ - - try: - channel_id = channel.id - except AttributeError: - channel_id = channel - async with ctx.bot._config.extra_owner_destinations() as extras: - if channel_id not in extras: - extras.append(channel_id) + if channel.id not in extras: + extras.append(channel.id) await ctx.tick() @@ -3982,12 +3985,8 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): color = await ctx.bot.get_embed_color(destination) e = discord.Embed(colour=color, description=message) - if author.avatar_url: - e.set_author(name=description, icon_url=author.avatar_url) - else: - e.set_author(name=description) - - e.set_footer(text="{}\n{}".format(footer, content)) + e.set_author(name=description, icon_url=author.display_avatar) + e.set_footer(text=f"{footer}\n{content}") try: await destination.send(embed=e) @@ -4057,10 +4056,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): e = discord.Embed(colour=discord.Colour.red(), description=message) e.set_footer(text=content) - if ctx.bot.user.avatar_url: - e.set_author(name=description, icon_url=ctx.bot.user.avatar_url) - else: - e.set_author(name=description) + e.set_author(name=description, icon_url=ctx.bot.user.display_avatar) try: await destination.send(embed=e) @@ -4206,7 +4202,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): async def diagnoseissues( self, ctx: commands.Context, - channel: Optional[discord.TextChannel], + channel: Optional[Union[discord.TextChannel, discord.Thread]], member: Union[discord.Member, discord.User], *, command_name: str, @@ -4227,8 +4223,13 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): """ if channel is None: channel = ctx.channel - if not isinstance(channel, discord.TextChannel): - await ctx.send(_("The channel needs to be passed when using this command in DMs.")) + if not isinstance(channel, (discord.TextChannel, discord.Thread)): + await ctx.send( + _( + "The text channel or thread needs to be passed" + " when using this command in DMs." + ) + ) return command = self.bot.get_command(command_name) @@ -4245,7 +4246,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): return member = maybe_member - if not channel.permissions_for(member).send_messages: + if not can_user_send_messages_in(member, channel): # Let's make Flame happy here await ctx.send( _( @@ -5156,7 +5157,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): async def rpc_unload(self, request): cog_name = request.params[0] - self.bot.unload_extension(cog_name) + await self.bot.unload_extension(cog_name) async def rpc_reload(self, request): await self.rpc_unload(request) @@ -5164,7 +5165,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): @commands.group() @commands.guild_only() - @checks.admin_or_permissions(manage_channels=True) + @commands.admin_or_can_manage_channel() async def ignore(self, ctx: commands.Context): """ Commands to add servers or channels to the ignore list. @@ -5189,12 +5190,14 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): async def ignore_channel( self, ctx: commands.Context, - channel: Optional[Union[discord.TextChannel, discord.CategoryChannel]] = None, + channel: Optional[ + Union[discord.TextChannel, discord.CategoryChannel, discord.Thread] + ] = None, ): """ - Ignore commands in the channel or category. + Ignore commands in the channel, thread, or category. - Defaults to the current channel. + Defaults to the current thread or channel. Note: Owners, Admins, and those with Manage Channel permissions override ignored channels. @@ -5205,7 +5208,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): - `[p]ignore channel 356236713347252226` - Also accepts IDs. **Arguments:** - - `` - The channel to ignore. Can be a category channel. + - `` - The channel to ignore. This can also be a thread or category channel. """ if not channel: channel = ctx.channel @@ -5235,7 +5238,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): @commands.group() @commands.guild_only() - @checks.admin_or_permissions(manage_channels=True) + @commands.admin_or_can_manage_channel() async def unignore(self, ctx: commands.Context): """Commands to remove servers or channels from the ignore list.""" @@ -5243,12 +5246,14 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): async def unignore_channel( self, ctx: commands.Context, - channel: Optional[Union[discord.TextChannel, discord.CategoryChannel]] = None, + channel: Optional[ + Union[discord.TextChannel, discord.CategoryChannel, discord.Thread] + ] = None, ): """ - Remove a channel or category from the ignore list. + Remove a channel, thread, or category from the ignore list. - Defaults to the current channel. + Defaults to the current thread or channel. **Examples:** - `[p]unignore channel #general` - Unignores commands in the #general channel. @@ -5257,7 +5262,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): - `[p]unignore channel 356236713347252226` - Also accepts IDs. Use this method to unignore categories. **Arguments:** - - `` - The channel to unignore. This can be a category channel. + - `` - The channel to unignore. This can also be a thread or category channel. """ if not channel: channel = ctx.channel @@ -5287,6 +5292,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): async def count_ignored(self, ctx: commands.Context): category_channels: List[discord.CategoryChannel] = [] text_channels: List[discord.TextChannel] = [] + threads: List[discord.Thread] = [] if await self.bot._ignored_cache.get_ignored_guild(ctx.guild): return _("This server is currently being ignored.") for channel in ctx.guild.text_channels: @@ -5295,14 +5301,22 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): category_channels.append(channel.category) if await self.bot._ignored_cache.get_ignored_channel(channel, check_category=False): text_channels.append(channel) + for thread in ctx.guild.threads: + if await self.bot_ignored_cache.get_ignored_channel(thread, check_category=False): + threads.append(thread) cat_str = ( - humanize_list([c.name for c in category_channels]) if category_channels else "None" + humanize_list([c.name for c in category_channels]) if category_channels else _("None") ) - chan_str = humanize_list([c.mention for c in text_channels]) if text_channels else "None" - msg = _("Currently ignored categories: {categories}\nChannels: {channels}").format( - categories=cat_str, channels=chan_str + chan_str = ( + humanize_list([c.mention for c in text_channels]) if text_channels else _("None") ) + thread_str = humanize_list([c.mention for c in threads]) if threads else _("None") + msg = _( + "Currently ignored categories: {categories}\n" + "Channels: {channels}\n" + "Threads (excluding archived):{threads}" + ).format(categories=cat_str, channels=chan_str, threads=thread_str) return msg # Removing this command from forks is a violation of the GPLv3 under which it is licensed. diff --git a/redbot/core/dev_commands.py b/redbot/core/dev_commands.py index ea45acf33..f3bdddf56 100644 --- a/redbot/core/dev_commands.py +++ b/redbot/core/dev_commands.py @@ -354,8 +354,7 @@ class Dev(commands.Cog): or anything else that makes the message non-empty. """ msg = ctx.message - if not content and not msg.embeds and not msg.attachments: - # DEP-WARN: add `msg.stickers` when adding d.py 2.0 + if not content and not msg.embeds and not msg.attachments and not msg.stickers: await ctx.send_help() return msg = copy(msg) diff --git a/redbot/core/events.py b/redbot/core/events.py index 44a2f4e3a..2db71d493 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -5,7 +5,7 @@ import sys import codecs import logging import traceback -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import aiohttp import discord @@ -30,6 +30,7 @@ from .utils._internal_utils import ( expected_version, fetch_latest_red_version_info, send_to_owners_with_prefix_replaced, + get_converter, ) from .utils.chat_formatting import inline, bordered, format_perms_list, humanize_timedelta @@ -70,7 +71,7 @@ def init_events(bot, cli_flags): guilds = len(bot.guilds) users = len(set([m for m in bot.get_all_members()])) - invite_url = discord.utils.oauth_url(bot._app_info.id) + invite_url = discord.utils.oauth_url(bot._app_info.id, scopes=("bot",)) prefixes = cli_flags.prefix or (await bot._config.prefix()) lang = await bot._config.locale() @@ -219,7 +220,16 @@ def init_events(bot, cli_flags): await ctx.send(msg) if error.send_cmd_help: await ctx.send_help() - elif isinstance(error, commands.ConversionFailure): + elif isinstance(error, commands.BadArgument): + if isinstance(error.__cause__, ValueError): + converter = get_converter(ctx.current_parameter) + argument = ctx.current_argument + if converter is int: + await ctx.send(_('"{argument}" is not an integer.').format(argument=argument)) + return + if converter is float: + await ctx.send(_('"{argument}" is not a number.').format(argument=argument)) + return if error.args: await ctx.send(error.args[0]) else: @@ -330,7 +340,7 @@ def init_events(bot, cli_flags): log.exception(type(error).__name__, exc_info=error) @bot.event - async def on_message(message): + async def on_message(message, /): await set_contextual_locales_from_guild(bot, message.guild) await bot.process_commands(message) @@ -339,7 +349,7 @@ def init_events(bot, cli_flags): not bot._checked_time_accuracy or (discord_now - timedelta(minutes=60)) > bot._checked_time_accuracy ): - system_now = datetime.utcnow() + system_now = datetime.now(timezone.utc) diff = abs((discord_now - system_now).total_seconds()) if diff > 60: log.warning( diff --git a/redbot/core/modlog.py b/redbot/core/modlog.py index f3217798a..1c55fbc96 100644 --- a/redbot/core/modlog.py +++ b/redbot/core/modlog.py @@ -106,7 +106,7 @@ async def _init(bot: Red): except RuntimeError: return # No modlog channel so no point in continuing - when = datetime.utcnow() + when = datetime.now(timezone.utc) before = when + timedelta(minutes=1) after = when - timedelta(minutes=1) await asyncio.sleep(10) # prevent small delays from causing a 5 minute delay on entry @@ -116,9 +116,12 @@ async def _init(bot: Red): while attempts < 12 and guild.me.guild_permissions.view_audit_log: attempts += 1 try: - entry = await guild.audit_logs( - action=discord.AuditLogAction.ban, before=before, after=after - ).find(lambda e: e.target.id == member.id and after < e.created_at < before) + entry = await discord.utils.find( + lambda e: e.target.id == member.id and after < e.created_at < before, + guild.audit_logs( + action=discord.AuditLogAction.ban, before=before, after=after + ), + ) except discord.Forbidden: break except discord.HTTPException: @@ -128,7 +131,7 @@ async def _init(bot: Red): if entry.user.id != guild.me.id: # Don't create modlog entires for the bot's own bans, cogs do this. mod, reason = entry.user, entry.reason - date = entry.created_at.replace(tzinfo=timezone.utc) + date = entry.created_at await create_case(_bot_ref, guild, date, "ban", member, mod, reason) return @@ -143,7 +146,7 @@ async def _init(bot: Red): except RuntimeError: return # No modlog channel so no point in continuing - when = datetime.utcnow() + when = datetime.now(timezone.utc) before = when + timedelta(minutes=1) after = when - timedelta(minutes=1) await asyncio.sleep(10) # prevent small delays from causing a 5 minute delay on entry @@ -153,9 +156,12 @@ async def _init(bot: Red): while attempts < 12 and guild.me.guild_permissions.view_audit_log: attempts += 1 try: - entry = await guild.audit_logs( - action=discord.AuditLogAction.unban, before=before, after=after - ).find(lambda e: e.target.id == user.id and after < e.created_at < before) + entry = await discord.utils.find( + lambda e: e.target.id == user.id and after < e.created_at < before, + guild.audit_logs( + action=discord.AuditLogAction.unban, before=before, after=after + ), + ) except discord.Forbidden: break except discord.HTTPException: @@ -165,7 +171,7 @@ async def _init(bot: Red): if entry.user.id != guild.me.id: # Don't create modlog entires for the bot's own unbans, cogs do this. mod, reason = entry.user, entry.reason - date = entry.created_at.replace(tzinfo=timezone.utc) + date = entry.created_at await create_case(_bot_ref, guild, date, "unban", user, mod, reason) return @@ -268,13 +274,16 @@ class Case: until: Optional[int] The UNIX time the action is in effect until. `None` if the action is permanent. - channel: Optional[Union[discord.abc.GuildChannel, int]] + channel: Optional[Union[discord.abc.GuildChannel, discord.Thread, int]] The channel the action was taken in. `None` if the action was not related to a channel. .. note:: This attribute will be of type `int` if the channel seems to no longer exist. + parent_channel_id: Optional[int] + The parent channel ID of the thread in ``channel``. + `None` if the action was not done in a thread. amended_by: Optional[Union[discord.abc.User, int]] The moderator who made the last change to the case. `None` if the case was never edited. @@ -310,7 +319,8 @@ class Case: case_number: int, reason: Optional[str] = None, until: Optional[int] = None, - channel: Optional[Union[discord.abc.GuildChannel, int]] = None, + channel: Optional[Union[discord.abc.GuildChannel, discord.Thread, int]] = None, + parent_channel_id: Optional[int] = None, amended_by: Optional[Union[discord.Object, discord.abc.User, int]] = None, modified_at: Optional[float] = None, message: Optional[Union[discord.PartialMessage, discord.Message]] = None, @@ -330,6 +340,7 @@ class Case: self.reason = reason self.until = until self.channel = channel + self.parent_channel_id = parent_channel_id self.amended_by = amended_by if isinstance(amended_by, discord.Object): self.amended_by = amended_by.id @@ -337,6 +348,18 @@ class Case: self.case_number = case_number self.message = message + @property + def parent_channel(self) -> Optional[discord.TextChannel]: + """ + The parent text channel of the thread in `channel`. + + This will be `None` if `channel` is not a thread + and when the parent text channel is not in cache (probably due to removal). + """ + if self.parent_channel_id is None: + return None + return self.guild.get_channel(self.parent_channel_id) + async def _set_message(self, message: discord.Message, /) -> None: # This should only be used for setting the message right after case creation # in order to avoid making an API request to "edit" the message with changes. @@ -359,6 +382,8 @@ class Case: # last username is set based on passed user object data.pop("last_known_username", None) for item, value in data.items(): + if item == "channel" and isinstance(value, discord.PartialMessageable): + raise TypeError("Can't use PartialMessageable as the channel for a modlog case.") if isinstance(value, discord.Object): # probably expensive to call but meh should capture all cases setattr(self, item, value.id) @@ -369,6 +394,9 @@ class Case: if not isinstance(self.user, int): self.last_known_username = f"{self.user.name}#{self.user.discriminator}" + if isinstance(self.channel, discord.Thread): + self.parent_channel_id = self.channel.parent_id + await _config.custom(_CASES, str(self.guild.id), str(self.case_number)).set(self.to_json()) self.bot.dispatch("modlog_case_edit", self) if not self.message: @@ -443,7 +471,7 @@ class Case: if self.until: start = datetime.fromtimestamp(self.created_at, tz=timezone.utc) end = datetime.fromtimestamp(self.until, tz=timezone.utc) - end_fmt = f"" + end_fmt = discord.utils.format_dt(end) duration = end - start dur_fmt = _strfdelta(duration) until = end_fmt @@ -463,7 +491,9 @@ class Case: last_modified = None if self.modified_at: - last_modified = f"" + last_modified = discord.utils.format_dt( + datetime.fromtimestamp(self.modified_at, tz=timezone.utc) + ) if isinstance(self.user, int): if self.user == 0xDE1: @@ -490,6 +520,31 @@ class Case: ) ) # Invites and spoilers get rendered even in embeds. + channel_value = None + if isinstance(self.channel, int): + if self.parent_channel_id is not None: + if (parent_channel := self.parent_channel) is not None: + channel_value = _( + "Deleted or archived thread ({thread_id}) in {channel_name}" + ).format(thread_id=self.channel, channel_name=parent_channel) + else: + channel_value = _("Thread {thread_id} in {channel_id} (deleted)").format( + thread_id=self.channel, channel_id=self.parent_channel_id + ) + else: + channel_value = _("{channel_id} (deleted)").format(channel_id=self.channel) + elif self.channel is not None: + channel_value = self.channel.name + if self.parent_channel_id is not None: + if (parent_channel := self.parent_channel) is not None: + channel_value = _("Thread {thread_name} in {channel_name}").format( + thread_name=self.channel, channel_name=parent_channel + ) + else: + channel_value = _("Thread {thread_name} in {channel_id} (deleted)").format( + thread_name=self.channel, channel_id=self.parent_channel_id + ) + if embed: if self.reason: reason = f"{bold(_('Reason:'))} {self.reason}" @@ -510,20 +565,13 @@ class Case: if until and duration: emb.add_field(name=_("Until"), value=until) emb.add_field(name=_("Duration"), value=duration) - - if isinstance(self.channel, int): - emb.add_field( - name=_("Channel"), - value=_("{channel} (deleted)").format(channel=self.channel), - inline=False, - ) - elif self.channel is not None: - emb.add_field(name=_("Channel"), value=self.channel.name, inline=False) + if channel_value: + emb.add_field(name=_("Channel"), value=channel_value, inline=False) if amended_by: emb.add_field(name=_("Amended by"), value=amended_by) if last_modified: emb.add_field(name=_("Last modified at"), value=last_modified) - emb.timestamp = datetime.utcfromtimestamp(self.created_at) + emb.timestamp = datetime.fromtimestamp(self.created_at, tz=timezone.utc) return emb else: if self.reason: @@ -549,9 +597,9 @@ class Case: case_text += f"{bold(_('Until:'))} {until}\n{bold(_('Duration:'))} {duration}\n" if self.channel: if isinstance(self.channel, int): - case_text += f"{bold(_('Channel:'))} {self.channel} {_('(Deleted)')}\n" + case_text += f"{bold(_('Channel:'))} {channel_value}\n" else: - case_text += f"{bold(_('Channel:'))} {self.channel.name}\n" + case_text += f"{bold(_('Channel:'))} {channel_value}\n" if amended_by: case_text += f"{bold(_('Amended by:'))} {amended_by}\n" if last_modified: @@ -590,6 +638,7 @@ class Case: "reason": self.reason, "until": self.until, "channel": self.channel.id if hasattr(self.channel, "id") else None, + "parent_channel": self.parent_channel_id, "amended_by": amended_by, "modified_at": self.modified_at, "message": self.message.id if hasattr(self.message, "id") else None, @@ -650,7 +699,11 @@ class Case: user_object = bot.get_user(user_id) or user_id user_objects[user_key] = user_object - channel = kwargs.get("channel") or guild.get_channel(data["channel"]) or data["channel"] + channel = ( + kwargs.get("channel") + or guild.get_channel_or_thread(data["channel"]) + or data["channel"] + ) case_guild = kwargs.get("guild") or bot.get_guild(data["guild"]) return cls( bot=bot, @@ -661,6 +714,7 @@ class Case: reason=data["reason"], until=data["until"], channel=channel, + parent_channel_id=data.get("parent_channel_id"), modified_at=data["modified_at"], message=message, last_known_username=data.get("last_known_username"), @@ -917,7 +971,7 @@ async def create_case( moderator: Optional[Union[discord.Object, discord.abc.User, int]] = None, reason: Optional[str] = None, until: Optional[datetime] = None, - channel: Optional[discord.abc.GuildChannel] = None, + channel: Optional[Union[discord.abc.GuildChannel, discord.Thread]] = None, last_known_username: Optional[str] = None, ) -> Optional[Case]: """ @@ -947,12 +1001,17 @@ async def create_case( The time the action is in effect until. If naive `datetime` object is passed, it's treated as a local time (similarly to how Python treats naive `datetime` objects). - channel: Optional[discord.abc.GuildChannel] + channel: Optional[Union[discord.abc.GuildChannel, discord.Thread]] The channel the action was taken in last_known_username: Optional[str] The last known username of the user Note: This is ignored if a Member or User object is provided in the user field + + Raises + ------ + TypeError + If ``channel`` is of type `discord.PartialMessageable`. """ case_type = await get_casetype(action_type, guild) if case_type is None: @@ -964,6 +1023,11 @@ async def create_case( if user == bot.user: return + if isinstance(channel, discord.PartialMessageable): + raise TypeError("Can't use PartialMessageable as the channel for a modlog case.") + + parent_channel_id = channel.parent_id if isinstance(channel, discord.Thread) else None + async with _config.guild(guild).latest_case_number.get_lock(): # We're getting the case number from config, incrementing it, awaiting something, then # setting it again. This warrants acquiring the lock. @@ -980,6 +1044,7 @@ async def create_case( reason, int(until.timestamp()) if until else None, channel, + parent_channel_id, amended_by=None, modified_at=None, message=None, diff --git a/redbot/core/settings_caches.py b/redbot/core/settings_caches.py index 2c690410e..ce819d379 100644 --- a/redbot/core/settings_caches.py +++ b/redbot/core/settings_caches.py @@ -150,7 +150,7 @@ class IgnoreManager: self._cached_guilds: Dict[int, bool] = {} async def get_ignored_channel( - self, channel: discord.TextChannel, check_category: bool = True + self, channel: Union[discord.TextChannel, discord.Thread], check_category: bool = True ) -> bool: ret: bool @@ -176,7 +176,9 @@ class IgnoreManager: return ret async def set_ignored_channel( - self, channel: Union[discord.TextChannel, discord.CategoryChannel], set_to: bool + self, + channel: Union[discord.TextChannel, discord.Thread, discord.CategoryChannel], + set_to: bool, ): cid: int = channel.id self._cached_channels[cid] = set_to diff --git a/redbot/core/utils/__init__.py b/redbot/core/utils/__init__.py index 05f119a0c..ad5faa41b 100644 --- a/redbot/core/utils/__init__.py +++ b/redbot/core/utils/__init__.py @@ -7,6 +7,7 @@ from asyncio.futures import isfuture from itertools import chain from pathlib import Path from typing import ( + TYPE_CHECKING, Any, AsyncIterator, AsyncIterable, @@ -15,16 +16,27 @@ from typing import ( Iterable, Iterator, List, + Literal, + NoReturn, Optional, Tuple, TypeVar, Union, Generator, Coroutine, + overload, ) +import discord +from discord.ext import commands as dpy_commands from discord.utils import maybe_coroutine +from redbot.core import commands + +if TYPE_CHECKING: + GuildMessageable = Union[commands.GuildContext, discord.abc.GuildChannel, discord.Thread] + DMMessageable = Union[commands.DMContext, discord.Member, discord.User, discord.DMChannel] + __all__ = ( "bounded_gather", "bounded_gather_iter", @@ -32,6 +44,9 @@ __all__ = ( "AsyncIter", "get_end_user_data_statement", "get_end_user_data_statement_or_raise", + "can_user_send_messages_in", + "can_user_manage_channel", + "can_user_react_in", ) log = logging.getLogger("red.core.utils") @@ -532,7 +547,7 @@ def get_end_user_data_statement(file: Union[Path, str]) -> Optional[str]: >>> # In cog's `__init__.py` >>> from redbot.core.utils import get_end_user_data_statement >>> __red_end_user_data_statement__ = get_end_user_data_statement(__file__) - >>> def setup(bot): + >>> async def setup(bot): ... ... """ try: @@ -590,3 +605,209 @@ def get_end_user_data_statement_or_raise(file: Union[Path, str]) -> str: info_json = file / "info.json" with info_json.open(encoding="utf-8") as fp: return json.load(fp)["end_user_data_statement"] + + +@overload +def can_user_send_messages_in( + obj: discord.abc.User, messageable: discord.PartialMessageable, / +) -> NoReturn: + ... + + +@overload +def can_user_send_messages_in(obj: discord.Member, messageable: GuildMessageable, /) -> bool: + ... + + +@overload +def can_user_send_messages_in(obj: discord.User, messageable: DMMessageable, /) -> Literal[True]: + ... + + +def can_user_send_messages_in( + obj: discord.abc.User, messageable: discord.abc.Messageable, / +) -> bool: + """ + Checks if a user/member can send messages in the given messageable. + + This function properly resolves the permissions for `discord.Thread` as well. + + .. note:: + + Without making an API request, it is not possible to reliably detect + whether a guild member (who is NOT current bot user) can send messages in a private thread. + + If it's essential for you to reliably detect this, you will need to + try fetching the thread member: + + .. code:: + + can_send_messages = can_user_send_messages_in(member, thread) + if thread.is_private() and not thread.permissions_for(member).manage_threads: + try: + await thread.fetch_member(member.id) + except discord.NotFound: + can_send_messages = False + + Parameters + ---------- + obj: discord.abc.User + The user or member to check permissions for. + If passed ``messageable`` resolves to a guild channel/thread, + this needs to be an instance of `discord.Member`. + messageable: discord.abc.Messageable + The messageable object to check permissions for. + If this resolves to a DM/group channel, this function will return ``True``. + + Returns + ------- + bool + Whether the user can send messages in the given messageable. + + Raises + ------ + TypeError + When the passed channel is of type `discord.PartialMessageable`. + """ + channel = messageable.channel if isinstance(messageable, dpy_commands.Context) else messageable + if isinstance(channel, discord.PartialMessageable): + # If we have a partial messageable, we sadly can't do much... + raise TypeError("Can't check permissions for PartialMessageable.") + + if isinstance(channel, discord.abc.User): + # Unlike DMChannel, abc.User subclasses do not have `permissions_for()`. + return True + + perms = channel.permissions_for(obj) + if isinstance(channel, discord.Thread): + return ( + perms.send_messages_in_threads + and (not channel.locked or perms.manage_threads) + # For private threads, the only way to know if user can send messages would be to check + # if they're a member of it which we cannot reliably do without an API request. + # + # and (not channel.is_private() or "obj is thread member" or perms.manage_threads) + ) + + return perms.send_messages + + +def can_user_manage_channel( + obj: discord.Member, + channel: Union[discord.abc.GuildChannel, discord.Thread], + /, + allow_thread_owner: bool = False, +) -> bool: + """ + Checks if a guild member can manage the given channel. + + This function properly resolves the permissions for `discord.Thread` as well. + + Parameters + ---------- + obj: discord.Member + The guild member to check permissions for. + If passed ``messageable`` resolves to a guild channel/thread, + this needs to be an instance of `discord.Member`. + channel: Union[discord.abc.GuildChannel, discord.Thread] + The messageable object to check permissions for. + If this resolves to a DM/group channel, this function will return ``True``. + allow_thread_owner: bool + If ``True``, the function will also return ``True`` if the given member is a thread owner. + This can, for example, be useful to check if the member can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + + Returns + ------- + bool + Whether the user can manage the given channel. + """ + perms = channel.permissions_for(obj) + if isinstance(channel, discord.Thread): + return perms.manage_threads or (allow_thread_owner and channel.owner_id == obj.id) + + return perms.manage_channels + + +@overload +def can_user_react_in( + obj: discord.abc.User, messageable: discord.PartialMessageable, / +) -> NoReturn: + ... + + +@overload +def can_user_react_in(obj: discord.Member, messageable: GuildMessageable, /) -> bool: + ... + + +@overload +def can_user_react_in(obj: discord.User, messageable: DMMessageable, /) -> Literal[True]: + ... + + +def can_user_react_in(obj: discord.abc.User, messageable: discord.abc.Messageable, /) -> bool: + """ + Checks if a user/guild member can react in the given messageable. + + This function properly resolves the permissions for `discord.Thread` as well. + + .. note:: + + Without making an API request, it is not possible to reliably detect + whether a guild member (who is NOT current bot user) can react in a private thread. + + If it's essential for you to reliably detect this, you will need to + try fetching the thread member: + + .. code:: + + can_react = can_user_react_in(member, thread) + if thread.is_private() and not thread.permissions_for(member).manage_threads: + try: + await thread.fetch_member(member.id) + except discord.NotFound: + can_react = False + + Parameters + ---------- + obj: discord.abc.User + The user or member to check permissions for. + If passed ``messageable`` resolves to a guild channel/thread, + this needs to be an instance of `discord.Member`. + messageable: discord.abc.Messageable + The messageable object to check permissions for. + If this resolves to a DM/group channel, this function will return ``True``. + + Returns + ------- + bool + Whether the user can send messages in the given messageable. + + Raises + ------ + TypeError + When the passed channel is of type `discord.PartialMessageable`. + """ + channel = messageable.channel if isinstance(messageable, dpy_commands.Context) else messageable + if isinstance(channel, discord.PartialMessageable): + # If we have a partial messageable, we sadly can't do much... + raise TypeError("Can't check permissions for PartialMessageable.") + + if isinstance(channel, discord.abc.User): + # Unlike DMChannel, abc.User subclasses do not have `permissions_for()`. + return True + + perms = channel.permissions_for(obj) + if isinstance(channel, discord.Thread): + return ( + (perms.read_message_history and perms.add_reactions) + and not channel.archived + # For private threads, the only way to know if user can send messages would be to check + # if they're a member of it which we cannot reliably do without an API request. + # + # and (not channel.is_private() or perms.manage_threads or "obj is thread member") + ) + + return perms.read_message_history and perms.add_reactions diff --git a/redbot/core/utils/_internal_utils.py b/redbot/core/utils/_internal_utils.py index d6c1ed381..30679e2ab 100644 --- a/redbot/core/utils/_internal_utils.py +++ b/redbot/core/utils/_internal_utils.py @@ -32,6 +32,7 @@ from typing import ( import aiohttp import discord import pkg_resources +from discord.ext.commands.converter import get_converter # DEP-WARN from fuzzywuzzy import fuzz, process from rich.progress import ProgressColumn from rich.progress_bar import ProgressBar @@ -59,6 +60,7 @@ __all__ = ( "deprecated_removed", "RichIndefiniteBarColumn", "cli_level_to_log_level", + "get_converter", ) _T = TypeVar("_T") diff --git a/redbot/core/utils/menus.py b/redbot/core/utils/menus.py index ce9c44643..6193f0610 100644 --- a/redbot/core/utils/menus.py +++ b/redbot/core/utils/menus.py @@ -106,7 +106,10 @@ async def menu( if not ctx.me: return try: - if message.channel.permissions_for(ctx.me).manage_messages: + if ( + isinstance(message.channel, discord.PartialMessageable) + or message.channel.permissions_for(ctx.me).manage_messages + ): await message.clear_reactions() else: raise RuntimeError diff --git a/redbot/core/utils/mod.py b/redbot/core/utils/mod.py index 701885131..a2a3e5756 100644 --- a/redbot/core/utils/mod.py +++ b/redbot/core/utils/mod.py @@ -9,7 +9,9 @@ if TYPE_CHECKING: from ..commands import Context -async def mass_purge(messages: List[discord.Message], channel: discord.TextChannel): +async def mass_purge( + messages: List[discord.Message], channel: Union[discord.TextChannel, discord.Thread] +): """Bulk delete messages from a channel. If more than 100 messages are supplied, the bot will delete 100 messages at @@ -24,7 +26,7 @@ async def mass_purge(messages: List[discord.Message], channel: discord.TextChann ---------- messages : `list` of `discord.Message` The messages to bulk delete. - channel : discord.TextChannel + channel : `discord.TextChannel` or `discord.Thread` The channel to delete messages from. Raises diff --git a/redbot/core/utils/predicates.py b/redbot/core/utils/predicates.py index e2520bc55..2a137d763 100644 --- a/redbot/core/utils/predicates.py +++ b/redbot/core/utils/predicates.py @@ -67,7 +67,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def same_context( cls, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the message fits the described context. @@ -76,7 +76,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] The current invocation context. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] The channel we expect a message in. If unspecified, defaults to ``ctx.channel``. If ``ctx`` is unspecified too, the message's channel will be ignored. @@ -104,7 +104,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def cancelled( cls, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the message is ``[p]cancel``. @@ -113,7 +113,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -133,7 +133,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def yes_or_no( cls, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the message is "yes"/"y" or "no"/"n". @@ -145,7 +145,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -176,7 +176,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def valid_int( cls, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is an integer. @@ -187,7 +187,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -216,7 +216,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def valid_float( cls, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is a float. @@ -227,7 +227,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -256,7 +256,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def positive( cls, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is a positive number. @@ -267,7 +267,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -300,7 +300,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def valid_role( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response refers to a role in the current guild. @@ -313,7 +313,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -344,7 +344,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def valid_member( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response refers to a member in the current guild. @@ -357,7 +357,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -392,7 +392,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def valid_text_channel( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response refers to a text channel in the current guild. @@ -405,7 +405,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -440,7 +440,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): def has_role( cls, ctx: Optional[commands.Context] = None, - channel: Optional[discord.TextChannel] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response refers to a role which the author has. @@ -454,7 +454,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): ---------- ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -492,7 +492,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, value: str, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is equal to the specified value. @@ -503,7 +503,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): The value to compare the response with. ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -522,7 +522,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, value: str, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response *as lowercase* is equal to the specified value. @@ -533,7 +533,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): The value to compare the response with. ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -552,7 +552,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, value: Union[int, float], ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is less than the specified value. @@ -563,7 +563,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): The value to compare the response with. ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -583,7 +583,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, value: Union[int, float], ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is greater than the specified value. @@ -594,7 +594,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): The value to compare the response with. ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -614,7 +614,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, length: int, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response's length is less than the specified length. @@ -625,7 +625,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): The value to compare the response's length with. ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -644,7 +644,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, length: int, ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response's length is greater than the specified length. @@ -655,7 +655,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): The value to compare the response's length with. ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -674,7 +674,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, collection: Sequence[str], ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response is contained in the specified collection. @@ -688,7 +688,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): The collection containing valid responses. ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -718,7 +718,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, collection: Sequence[str], ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Same as :meth:`contained_in`, but the response is set to lowercase before matching. @@ -729,7 +729,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): The collection containing valid lowercase responses. ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -759,7 +759,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): cls, pattern: Union[Pattern[str], str], ctx: Optional[commands.Context] = None, - channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None, + channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None, user: Optional[discord.abc.User] = None, ) -> "MessagePredicate": """Match if the response matches the specified regex pattern. @@ -774,7 +774,7 @@ class MessagePredicate(Callable[[discord.Message], bool]): The pattern to search for in the response. ctx : Optional[Context] Same as ``ctx`` in :meth:`same_context`. - channel : Optional[discord.TextChannel] + channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]] Same as ``channel`` in :meth:`same_context`. user : Optional[discord.abc.User] Same as ``user`` in :meth:`same_context`. @@ -815,7 +815,9 @@ class MessagePredicate(Callable[[discord.Message], bool]): @staticmethod def _get_guild( - ctx: commands.Context, channel: discord.TextChannel, user: discord.Member + ctx: Optional[commands.Context], + channel: Optional[Union[discord.TextChannel, discord.Thread]], + user: Optional[discord.Member], ) -> discord.Guild: if ctx is not None: return ctx.guild @@ -930,6 +932,7 @@ class ReactionPredicate(Callable[[discord.Reaction, discord.abc.User], bool]): """ # noinspection PyProtectedMember + # DEP-WARN me_id = message._state.self_id return cls( lambda self, r, u: u.id != me_id diff --git a/redbot/core/utils/tunnel.py b/redbot/core/utils/tunnel.py index 880a5682e..32748935d 100644 --- a/redbot/core/utils/tunnel.py +++ b/redbot/core/utils/tunnel.py @@ -4,7 +4,7 @@ from datetime import datetime from redbot.core.utils.chat_formatting import pagify import io import weakref -from typing import List, Optional +from typing import List, Optional, Union from .common_filters import filter_mass_mentions _instances = weakref.WeakValueDictionary({}) @@ -57,14 +57,18 @@ class Tunnel(metaclass=TunnelMeta): ---------- sender: `discord.Member` The person who opened the tunnel - origin: `discord.TextChannel` + origin: `discord.TextChannel` or `discord.Thread` The channel in which it was opened recipient: `discord.User` The user on the other end of the tunnel """ def __init__( - self, *, sender: discord.Member, origin: discord.TextChannel, recipient: discord.User + self, + *, + sender: discord.Member, + origin: Union[discord.TextChannel, discord.Thread], + recipient: discord.User, ): self.sender = sender self.origin = origin @@ -219,9 +223,9 @@ class Tunnel(metaclass=TunnelMeta): the bot can't upload at the origin channel or can't add reactions there. """ - if message.channel == self.origin and message.author == self.sender: + if message.channel.id == self.origin.id and message.author == self.sender: send_to = self.recipient - elif message.author == self.recipient and isinstance(message.channel, discord.DMChannel): + elif message.author == self.recipient and message.guild is None: send_to = self.origin else: return None diff --git a/redbot/vendored/discord/ext/menus/__init__.py b/redbot/vendored/discord/ext/menus/__init__.py index 0f6f16da1..282610a3f 100644 --- a/redbot/vendored/discord/ext/menus/__init__.py +++ b/redbot/vendored/discord/ext/menus/__init__.py @@ -514,7 +514,14 @@ class Menu(metaclass=_MenuMeta): return len(self.buttons) def _verify_permissions(self, ctx, channel, permissions): - if not permissions.send_messages: + is_thread = isinstance(channel, discord.Thread) + if is_thread: + if ( + not permissions.send_messages_in_threads + or (channel.locked and not permissions.manage_threads) + ): + raise CannotSendMessages() + elif not permissions.send_messages: raise CannotSendMessages() if self.check_embeds and not permissions.embed_links: @@ -522,7 +529,7 @@ class Menu(metaclass=_MenuMeta): self._can_remove_reactions = permissions.manage_messages if self.should_add_reactions(): - if not permissions.add_reactions: + if not permissions.add_reactions or (is_thread and channel.archived): raise CannotAddReactions() if not permissions.read_message_history: raise CannotReadMessageHistory() diff --git a/setup.cfg b/setup.cfg index 7016d0064..5e9870412 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,7 +52,7 @@ install_requires = colorama==0.4.4 commonmark==0.9.1 contextlib2==21.6.0 - discord.py==1.7.3 + discord.py @ git+https://github.com/Rapptz/discord.py@3d914e08e0c7df370987592affb3655d2a12f7d1#egg=discord.py distro==1.6.0; sys_platform == "linux" fuzzywuzzy==0.18.0 idna==3.2