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 <t:...> 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
This commit is contained in:
jack1142 2022-04-03 03:21:20 +02:00 committed by GitHub
parent c9a0971945
commit febca8ccbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
104 changed files with 1427 additions and 999 deletions

View File

@ -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 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 Context Parameters
@ -27,19 +27,19 @@ Context Parameters
You can enhance your custom command's response by leaving spaces for the bot to substitute. You can enhance your custom command's response by leaving spaces for the bot to substitute.
+-----------+----------------------------------------+ +-----------+--------------------------------------------------+
| Argument | Substitute | | Argument | Substitute |
+===========+========================================+ +===========+==================================================+
| {message} | The message the bot is responding to. | | {message} | The message the bot is responding to. |
+-----------+----------------------------------------+ +-----------+--------------------------------------------------+
| {author} | The user who called the command. | | {author} | The user who called the command. |
+-----------+----------------------------------------+ +-----------+--------------------------------------------------+
| {channel} | The channel the command was called in. | | {channel} | The channel or thread the command was called in. |
+-----------+----------------------------------------+ +-----------+--------------------------------------------------+
| {server} | The server the command was called in. | | {server} | The server the command was called in. |
+-----------+----------------------------------------+ +-----------+--------------------------------------------------+
| {guild} | Same as with {server}. | | {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. 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 .. code-block:: none
[p]customcom add simple avatar {author.avatar_url} [p]customcom add simple avatar {author.display_avatar}
[p]avatar [p]avatar
https://cdn.discordapp.com/avatars/133801473317404673/be4c4a4fe47cb3e74c31a0504e7a295e.webp?size=1024 https://cdn.discordapp.com/avatars/133801473317404673/be4c4a4fe47cb3e74c31a0504e7a295e.webp?size=1024

View File

@ -389,7 +389,7 @@ announceset channel
.. code-block:: none .. code-block:: none
[p]announceset channel [channel] [p]announceset channel <channel>
**Description** **Description**
@ -397,8 +397,8 @@ Sets the channel where the bot owner announcements will be sent.
**Arguments** **Arguments**
* ``[channel]``: The channel that will be used for bot announcements. * ``<channel>``: The channel that will be used for bot announcements.
|channel-input| Defaults to where you typed the command. |channel-input|
.. _admin-command-announceset-clearchannel: .. _admin-command-announceset-clearchannel:

View File

@ -1842,9 +1842,9 @@ ignore channel
**Description** **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. .. 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. - ``[p]ignore channel 356236713347252226`` - Also accepts IDs.
**Arguments:** **Arguments:**
- ``<channel>`` - The channel to ignore. Can be a category channel. - ``<channel>`` - The channel to ignore. This can also be a thread or category channel.
.. _core-command-ignore-list: .. _core-command-ignore-list:
@ -4045,9 +4045,9 @@ unignore channel
**Description** **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:** **Examples:**
- ``[p]unignore channel #general`` - Unignores commands in the #general channel. - ``[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. - ``[p]unignore channel 356236713347252226`` - Also accepts IDs. Use this method to unignore categories.
**Arguments:** **Arguments:**
- ``<channel>`` - The channel to unignore. This can be a category channel. - ``<channel>`` - The channel to unignore. This can also be a thread or category channel.
.. _core-command-unignore-server: .. _core-command-unignore-server:

View File

@ -68,7 +68,7 @@ customcom cooldown
Set, edit, or view the cooldown for a custom command. 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 cooldowns may be set. All cooldowns must be cooled to call the
custom command. custom command.

View File

@ -574,14 +574,14 @@ slowmode
**Description** **Description**
Changes channel's slowmode setting. Changes thread's or channel's slowmode setting.
Interval can be anything from 0 seconds to 6 hours. Interval can be anything from 0 seconds to 6 hours.
Use without parameters to disable. Use without parameters to disable.
**Arguments** **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:: .. note::
Interval can be anything from 0 seconds to 6 hours. Interval can be anything from 0 seconds to 6 hours.

View File

@ -91,7 +91,7 @@ mutechannel
**Description** **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: Examples:
@ -355,7 +355,7 @@ unmutechannel
**Description** **Description**
Unmute a user in this channel. Unmute a user in this channel (or in the parent of this thread).
**Arguments** **Arguments**

View File

@ -29,7 +29,7 @@ Global rules (set by the owner) are checked first, then rules set for servers. I
1. Rules about a user. 1. Rules about a user.
2. Rules about the voice channel a user is in. 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). 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). 5. Rules about the server a user is in (Global rules only).

View File

@ -73,7 +73,7 @@ report interact
Open a message tunnel. 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. to the ticket opener's direct messages.
Tunnels do not persist across bot restarts. Tunnels do not persist across bot restarts.

View File

@ -32,7 +32,7 @@ For each of those, the first rule pertaining to one of the following models will
1. User 1. User
2. Voice channel 2. Voice channel
3. Text channel 3. Text channel (parent text channel in case of invocations in threads)
4. Channel category 4. Channel category
5. Roles, highest to lowest 5. Roles, highest to lowest
6. Server (can only be in global rules) 6. Server (can only be in global rules)

View File

@ -70,7 +70,7 @@ author = "Cog Creators"
# built documents. # built documents.
# #
from redbot.core import __version__ 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. # The short X.Y version.
version = __version__ version = __version__
@ -225,10 +225,20 @@ linkcheck_retries = 3
# -- Options for extensions ----------------------------------------------- # -- 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
intersphinx_mapping = { intersphinx_mapping = {
"python": ("https://docs.python.org/3", None), "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), "motor": ("https://motor.readthedocs.io/en/stable/", None),
"babel": ("http://babel.pocoo.org/en/stable/", None), "babel": ("http://babel.pocoo.org/en/stable/", None),
"dateutil": ("https://dateutil.readthedocs.io/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 # This allows to create links to d.py docs with
# :dpy_docs:`link text <site_name.html>` # :dpy_docs:`link text <site_name.html>`
extlinks = { 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", "#"), "issue": ("https://github.com/Cog-Creators/Red-DiscordBot/issues/%s", "#"),
"ghuser": ("https://github.com/%s", "@"), "ghuser": ("https://github.com/%s", "@"),
} }

View File

@ -8,4 +8,4 @@ The following are all decorators for commands, which add restrictions to where a
run. run.
.. automodule:: redbot.core.commands .. 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

View File

@ -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.member` which takes :py:class:`discord.Member`.
* :py:meth:`Config.user` which takes :py:class:`discord.User`. * :py:meth:`Config.user` which takes :py:class:`discord.User`.
* :py:meth:`Config.role` which takes :py:class:`discord.Role`. * :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` 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`. 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): async def setup(bot):
cog = ExampleCog() cog = ExampleCog()
await cog.load_data() await cog.load_data()
bot.add_cog(cog) await bot.add_cog(cog)
************************************ ************************************
Best practices and performance notes Best practices and performance notes

View File

@ -35,8 +35,7 @@ Basic Usage
Registering Case types Registering Case types
********************** **********************
To register case types, use an asynchronous ``initialize()`` method and call To register case types, use a special ``cog_load()`` method which is called when you add a cog:
it from your setup function:
.. code-block:: python .. code-block:: python
@ -46,7 +45,7 @@ it from your setup function:
class MyCog(commands.Cog): class MyCog(commands.Cog):
async def initialize(self): async def cog_load(self):
await self.register_casetypes() await self.register_casetypes()
@staticmethod @staticmethod
@ -87,8 +86,7 @@ it from your setup function:
async def setup(bot): async def setup(bot):
cog = MyCog() cog = MyCog()
await cog.initialize() await bot.add_cog(cog)
bot.add_cog(cog)
.. important:: .. important::
Image should be the emoji you want to represent your case type with. Image should be the emoji you want to represent your case type with.

View File

@ -20,9 +20,9 @@ Examples
.. code-block:: Python .. code-block:: Python
def setup(bot): async def setup(bot):
c = Cog() c = Cog()
bot.add_cog(c) await bot.add_cog(c)
bot.register_rpc_handler(c.rpc_method) bot.register_rpc_handler(c.rpc_method)
******************************* *******************************

View File

@ -8,7 +8,7 @@ General Utility
=============== ===============
.. automodule:: redbot.core.utils .. 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 .. autoclass:: AsyncIter
:members: :members:

View File

@ -102,8 +102,8 @@ Open :code:`__init__.py`. In that file, place the following:
from .mycog import MyCog from .mycog import MyCog
def setup(bot): async def setup(bot):
bot.add_cog(MyCog(bot)) await bot.add_cog(MyCog(bot))
Make sure that both files are saved. Make sure that both files are saved.

View File

@ -378,10 +378,10 @@ async def run_bot(red: Red, cli_flags: Namespace) -> None:
sys.exit(1) sys.exit(1)
if cli_flags.dry_run: if cli_flags.dry_run:
await red.http.close()
sys.exit(0) sys.exit(0)
try: 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: except discord.LoginFailure:
log.critical("This token doesn't seem to be valid.") log.critical("This token doesn't seem to be valid.")
db_token = await red._config.token() 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 red._shutdown_mode = exit_code
try: try:
await red.close() if not red.is_closed():
await red.close()
finally: finally:
# Then cancels all outstanding tasks other than ourselves # Then cancels all outstanding tasks other than ourselves
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]

View File

@ -1,5 +1,7 @@
from redbot.core.bot import Red
from .admin import Admin from .admin import Admin
def setup(bot): async def setup(bot: Red) -> None:
bot.add_cog(Admin(bot)) await bot.add_cog(Admin(bot))

View File

@ -84,12 +84,9 @@ class Admin(commands.Cog):
) )
self.__current_announcer = None 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): async def cog_load(self) -> None:
await self._ready.wait() await self.handle_migrations()
async def red_delete_data_for_user(self, **kwargs): async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete""" """Nothing to delete"""
@ -106,9 +103,7 @@ class Admin(commands.Cog):
await self.migrate_config_from_0_to_1() await self.migrate_config_from_0_to_1()
await self.config.schema_version.set(1) await self.config.schema_version.set(1)
self._ready.set() async def migrate_config_from_0_to_1(self) -> None:
async def migrate_config_from_0_to_1(self):
all_guilds = await self.config.all_guilds() all_guilds = await self.config.all_guilds()
for guild_id, guild_data in all_guilds.items(): for guild_id, guild_data in all_guilds.items():
@ -354,14 +349,8 @@ class Admin(commands.Cog):
pass pass
@announceset.command(name="channel") @announceset.command(name="channel")
async def announceset_channel(self, ctx, *, channel: discord.TextChannel = None): async def announceset_channel(self, ctx, *, channel: discord.TextChannel):
""" """Change the channel where the bot will send announcements."""
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
await self.config.guild(ctx.guild).announce_channel.set(channel.id) await self.config.guild(ctx.guild).announce_channel.set(channel.id)
await ctx.send( await ctx.send(
_("The announcement channel has been set to {channel.mention}").format(channel=channel) _("The announcement channel has been set to {channel.mention}").format(channel=channel)

View File

@ -2,7 +2,5 @@ from .alias import Alias
from redbot.core.bot import Red from redbot.core.bot import Red
async def setup(bot: Red): async def setup(bot: Red) -> None:
cog = Alias(bot) await bot.add_cog(Alias(bot))
bot.add_cog(cog)
cog.sync_init()

View File

@ -50,7 +50,12 @@ class Alias(commands.Cog):
self.config.register_global(entries=[], handled_string_creator=False) self.config.register_global(entries=[], handled_string_creator=False)
self.config.register_guild(entries=[]) self.config.register_guild(entries=[])
self._aliases: AliasCache = AliasCache(config=self.config, cache_enabled=True) 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( async def red_delete_data_for_user(
self, self,
@ -61,12 +66,8 @@ class Alias(commands.Cog):
if requester != "discord_deleted_user": if requester != "discord_deleted_user":
return return
await self._ready_event.wait()
await self._aliases.anonymize_aliases(user_id) 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): async def _maybe_handle_string_keys(self):
# This isn't a normal schema migration because it's being added # This isn't a normal schema migration because it's being added
# after the fact for GH-3788 # after the fact for GH-3788
@ -119,28 +120,6 @@ class Alias(commands.Cog):
await self.config.handled_string_creator.set(True) 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: 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 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") @alias.command(name="list")
@commands.guild_only() @commands.guild_only()
@checks.bot_has_permissions(add_reactions=True) @commands.bot_can_react()
async def _list_alias(self, ctx: commands.Context): async def _list_alias(self, ctx: commands.Context):
"""List the available aliases on this server.""" """List the available aliases on this server."""
guild_aliases = await self._aliases.get_guild_aliases(ctx.guild) 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) await self.paginate_alias_list(ctx, guild_aliases)
@global_.command(name="list") @global_.command(name="list")
@checks.bot_has_permissions(add_reactions=True) @commands.bot_can_react()
async def _list_global_alias(self, ctx: commands.Context): async def _list_global_alias(self, ctx: commands.Context):
"""List the available global aliases on this bot.""" """List the available global aliases on this bot."""
global_aliases = await self._aliases.get_global_aliases() global_aliases = await self._aliases.get_global_aliases()
@ -480,8 +459,6 @@ class Alias(commands.Cog):
@commands.Cog.listener() @commands.Cog.listener()
async def on_message_without_command(self, message: discord.Message): async def on_message_without_command(self, message: discord.Message):
await self._ready_event.wait()
if message.guild is not None: if message.guild is not None:
if await self.bot.cog_disabled_in_guild(self, message.guild): if await self.bot.cog_disabled_in_guild(self, message.guild):
return return

View File

@ -2,7 +2,7 @@ from typing import Tuple, Dict, Optional, List, Union
from re import findall from re import findall
import discord 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 import commands, Config
from redbot.core.i18n import Translator from redbot.core.i18n import Translator
from redbot.core.utils import AsyncIter from redbot.core.utils import AsyncIter

View File

@ -3,7 +3,7 @@ from redbot.core.bot import Red
from .core import Audio from .core import Audio
def setup(bot: Red): async def setup(bot: Red) -> None:
cog = Audio(bot) cog = Audio(bot)
bot.add_cog(cog) await bot.add_cog(cog)
cog.start_up_task() cog.start_up_task()

View File

@ -973,7 +973,7 @@ class AudioAPIInterface:
and not query.local_track_path.exists() and not query.local_track_path.exists()
): ):
continue 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( if not await self.cog.is_query_allowed(
self.config, self.config,
notify_channel, notify_channel,

View File

@ -74,7 +74,6 @@ class Audio(
self.permission_cache = discord.Permissions( self.permission_cache = discord.Permissions(
embed_links=True, embed_links=True,
read_messages=True, read_messages=True,
send_messages=True,
read_message_history=True, read_message_history=True,
add_reactions=True, add_reactions=True,
) )

View File

@ -196,7 +196,7 @@ class MixinMeta(ABC):
async def is_query_allowed( async def is_query_allowed(
self, self,
config: Config, config: Config,
ctx_or_channel: Optional[Union[Context, discord.TextChannel]], ctx_or_channel: Optional[Union[Context, discord.TextChannel, discord.Thread]],
query: str, query: str,
query_obj: Query, query_obj: Query,
) -> bool: ) -> bool:
@ -250,7 +250,7 @@ class MixinMeta(ABC):
raise NotImplementedError() raise NotImplementedError()
@abstractmethod @abstractmethod
def _has_notify_perms(self, channel: discord.TextChannel) -> bool: def _has_notify_perms(self, channel: Union[discord.TextChannel, discord.Thread]) -> bool:
raise NotImplementedError() raise NotImplementedError()
@abstractmethod @abstractmethod

View File

@ -78,7 +78,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass):
) )
@command_audioset_perms_global_whitelist.command(name="list") @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): async def command_audioset_perms_global_whitelist_list(self, ctx: commands.Context):
"""List all keywords added to the whitelist.""" """List all keywords added to the whitelist."""
whitelist = await self.config.url_keyword_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") @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): async def command_audioset_perms_global_blacklist_list(self, ctx: commands.Context):
"""List all keywords added to the blacklist.""" """List all keywords added to the blacklist."""
blacklist = await self.config.url_keyword_blacklist() blacklist = await self.config.url_keyword_blacklist()
@ -268,7 +268,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass):
) )
@command_audioset_perms_whitelist.command(name="list") @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): async def command_audioset_perms_whitelist_list(self, ctx: commands.Context):
"""List all keywords added to the whitelist.""" """List all keywords added to the whitelist."""
whitelist = await self.config.guild(ctx.guild).url_keyword_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") @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): async def command_audioset_perms_blacklist_list(self, ctx: commands.Context):
"""List all keywords added to the blacklist.""" """List all keywords added to the blacklist."""
blacklist = await self.config.guild(ctx.guild).url_keyword_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) await self.set_player_settings(ctx)
@command_audioset_autoplay.command(name="playlist", usage="<playlist_name_OR_id> [args]") @command_audioset_autoplay.command(name="playlist", usage="<playlist_name_OR_id> [args]")
@commands.bot_has_permissions(add_reactions=True) @commands.bot_can_react()
async def command_audioset_autoplay_playlist( async def command_audioset_autoplay_playlist(
self, self,
ctx: commands.Context, ctx: commands.Context,
@ -496,9 +496,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -782,7 +780,7 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass):
@command_audioset.command(name="localpath") @command_audioset.command(name="localpath")
@commands.is_owner() @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): 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. """Set the localtracks path if the Lavalink.jar is not run from the Audio data folder.

View File

@ -81,7 +81,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.command(name="now") @commands.command(name="now")
@commands.guild_only() @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): async def command_now(self, ctx: commands.Context):
"""Now playing.""" """Now playing."""
if not self._player_check(ctx): if not self._player_check(ctx):

View File

@ -25,7 +25,8 @@ class EqualizerCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="eq", invoke_without_command=True) @commands.group(name="eq", invoke_without_command=True)
@commands.guild_only() @commands.guild_only()
@commands.cooldown(1, 15, commands.BucketType.guild) @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): async def command_equalizer(self, ctx: commands.Context):
"""Equalizer management. """Equalizer management.

View File

@ -21,7 +21,8 @@ _ = Translator("Audio", Path(__file__))
class LocalTrackCommands(MixinMeta, metaclass=CompositeMetaClass): class LocalTrackCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="local") @commands.group(name="local")
@commands.guild_only() @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): async def command_local(self, ctx: commands.Context):
"""Local playback commands.""" """Local playback commands."""

View File

@ -41,7 +41,8 @@ class MiscellaneousCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.command(name="audiostats") @commands.command(name="audiostats")
@commands.guild_only() @commands.guild_only()
@commands.is_owner() @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): async def command_audiostats(self, ctx: commands.Context):
"""Audio stats.""" """Audio stats."""
server_num = len(lavalink.active_players()) server_num = len(lavalink.active_players())

View File

@ -9,7 +9,6 @@ import discord
import lavalink import lavalink
from red_commons.logging import getLogger from red_commons.logging import getLogger
from discord.embeds import EmptyEmbed
from lavalink import NodeNotFound from lavalink import NodeNotFound
from redbot.core import commands from redbot.core import commands
@ -67,7 +66,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass):
if not self._player_check(ctx): if not self._player_check(ctx):
if self.lavalink_connection_aborted: if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink node has failed") msg = _("Connection to Lavalink node has failed")
desc = EmptyEmbed desc = None
if await self.bot.is_owner(ctx.author): if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.") desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc) 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 not self._player_check(ctx):
if self.lavalink_connection_aborted: if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink node has failed") msg = _("Connection to Lavalink node has failed")
desc = EmptyEmbed desc = None
if await self.bot.is_owner(ctx.author): if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.") desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc) 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 not self._player_check(ctx):
if self.lavalink_connection_aborted: if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink node has failed") msg = _("Connection to Lavalink node has failed")
desc = EmptyEmbed desc = None
if await self.bot.is_owner(ctx.author): if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.") desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc) 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 not self._player_check(ctx):
if self.lavalink_connection_aborted: if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink node has failed") msg = _("Connection to Lavalink node has failed")
desc = EmptyEmbed desc = None
if await self.bot.is_owner(ctx.author): if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.") desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc) return await self.send_embed_msg(ctx, title=msg, description=desc)
@ -610,7 +609,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass):
except DatabaseError: except DatabaseError:
notify_channel = player.fetch("notify_channel") notify_channel = player.fetch("notify_channel")
if 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.")) await self.send_embed_msg(notify_channel, title=_("Couldn't get a valid track."))
return return
except TrackEnqueueError: except TrackEnqueueError:
@ -636,7 +635,8 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.command(name="search") @commands.command(name="search")
@commands.guild_only() @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): async def command_search(self, ctx: commands.Context, *, query: str):
"""Pick a track with a search. """Pick a track with a search.
@ -678,7 +678,7 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass):
if not self._player_check(ctx): if not self._player_check(ctx):
if self.lavalink_connection_aborted: if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink has failed") msg = _("Connection to Lavalink has failed")
desc = EmptyEmbed desc = None
if await self.bot.is_owner(ctx.author): if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.") desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=desc) return await self.send_embed_msg(ctx, title=msg, description=desc)

View File

@ -38,7 +38,8 @@ _ = Translator("Audio", Path(__file__))
class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="playlist") @commands.group(name="playlist")
@commands.guild_only() @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): async def command_playlist(self, ctx: commands.Context):
"""Playlist configuration options. """Playlist configuration options.
@ -263,9 +264,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [ scope_data = [
@ -392,9 +391,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -471,9 +468,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -561,9 +556,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
async with ctx.typing(): async with ctx.typing():
if scope_data is None: if scope_data is None:
@ -696,9 +689,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -829,9 +820,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -952,9 +941,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -1107,9 +1094,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
async with ctx.typing(): async with ctx.typing():
if scope_data is None: if scope_data is None:
@ -1217,9 +1202,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -1335,9 +1318,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -1461,9 +1442,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -1639,9 +1618,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -1810,9 +1787,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]
@ -1976,9 +1951,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass):
ctx, ctx,
title=_("Playlists Are Not Available"), title=_("Playlists Are Not Available"),
description=_("The playlist section of Audio is currently unavailable"), description=_("The playlist section of Audio is currently unavailable"),
footer=discord.Embed.Empty footer=None if not await self.bot.is_owner(ctx.author) else _("Check your logs."),
if not await self.bot.is_owner(ctx.author)
else _("Check your logs."),
) )
if scope_data is None: if scope_data is None:
scope_data = [None, ctx.author, ctx.guild, False] scope_data = [None, ctx.author, ctx.guild, False]

View File

@ -33,7 +33,8 @@ _ = Translator("Audio", Path(__file__))
class QueueCommands(MixinMeta, metaclass=CompositeMetaClass): class QueueCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="queue", invoke_without_command=True) @commands.group(name="queue", invoke_without_command=True)
@commands.guild_only() @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): async def command_queue(self, ctx: commands.Context, *, page: int = 1):
"""List the songs in the queue.""" """List the songs in the queue."""

View File

@ -197,7 +197,7 @@ class AudioEvents(MixinMeta, metaclass=CompositeMetaClass):
): ):
if not guild: if not guild:
return 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) has_perms = self._has_notify_perms(notify_channel)
tries = 0 tries = 0
while not player._is_playing: while not player._is_playing:

View File

@ -18,6 +18,7 @@ from lavalink import NodeNotFound, PlayerNotFound
from redbot.core import commands from redbot.core import commands
from redbot.core.i18n import Translator 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.antispam import AntiSpam
from redbot.core.utils.chat_formatting import box, humanize_list, underline, bold from redbot.core.utils.chat_formatting import box, humanize_list, underline, bold
@ -61,6 +62,16 @@ HUMANIZED_PERM = {
"manage_roles": _("Manage Roles"), "manage_roles": _("Manage Roles"),
"manage_webhooks": _("Manage Webhooks"), "manage_webhooks": _("Manage Webhooks"),
"manage_emojis": _("Manage Emojis"), "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 = { 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." "Not running Audio command due to invalid machine architecture for the managed Lavalink node."
) )
current_perms = ctx.channel.permissions_for(ctx.me)
surpass_ignore = ( surpass_ignore = (
isinstance(ctx.channel, discord.abc.PrivateChannel) isinstance(ctx.channel, discord.abc.PrivateChannel)
or await ctx.bot.is_owner(ctx.author) or await ctx.bot.is_owner(ctx.author)
or await ctx.bot.is_admin(ctx.author) or await ctx.bot.is_admin(ctx.author)
) )
guild = ctx.guild 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): if guild and not current_perms.is_superset(self.permission_cache):
current_perms_set = set(iter(current_perms)) current_perms_set = set(iter(current_perms))
expected_perms_set = set(iter(self.permission_cache)) expected_perms_set = set(iter(self.permission_cache))
@ -207,14 +236,7 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
perm=_(HUMANIZED_PERM.get(perm, perm)), perm=_(HUMANIZED_PERM.get(perm, perm)),
) )
text = text.strip() text = text.strip()
if current_perms.send_messages and current_perms.read_messages: await ctx.send(box(text=text, lang="ini"))
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,
)
raise CheckFailure(message=text) raise CheckFailure(message=text)
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
@ -312,7 +334,7 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
) )
if error.send_cmd_help: if error.send_cmd_help:
await ctx.send_help() await ctx.send_help()
elif isinstance(error, commands.ConversionFailure): elif isinstance(error, commands.BadArgument):
handled = True handled = True
if error.args: if error.args:
if match := RE_CONVERSION.search(error.args[0]): if match := RE_CONVERSION.search(error.args[0]):
@ -390,10 +412,10 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
if not handled: if not handled:
await self.bot.on_command_error(ctx, error, unhandled_by_cog=True) 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: if not self.cog_cleaned_up:
self.bot.dispatch("red_audio_unload", self) self.bot.dispatch("red_audio_unload", self)
self.session.detach() await self.session.close()
if self.player_automated_timer_task: if self.player_automated_timer_task:
self.player_automated_timer_task.cancel() 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_event_listener(self.lavalink_event_handler)
lavalink.unregister_update_listener(self.lavalink_update_handler) lavalink.unregister_update_listener(self.lavalink_update_handler)
asyncio.create_task(lavalink.close(self.bot)) await lavalink.close(self.bot)
asyncio.create_task(self._close_database()) await self._close_database()
if self.managed_node_controller is not None: 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 self.cog_cleaned_up = True

View File

@ -165,14 +165,14 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass):
try: try:
await self.api_interface.autoplay(player, self.playlist_api) await self.api_interface.autoplay(player, self.playlist_api)
except DatabaseError: 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): if notify_channel and self._has_notify_perms(notify_channel):
await self.send_embed_msg( await self.send_embed_msg(
notify_channel, title=_("Couldn't get a valid track.") notify_channel, title=_("Couldn't get a valid track.")
) )
return return
except TrackEnqueueError: 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): if notify_channel and self._has_notify_perms(notify_channel):
await self.send_embed_msg( await self.send_embed_msg(
notify_channel, notify_channel,
@ -185,7 +185,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass):
return return
if event_type == lavalink.LavalinkEvents.TRACK_START and notify: if event_type == lavalink.LavalinkEvents.TRACK_START and notify:
notify_channel_id = player.fetch("notify_channel") 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 notify_channel and self._has_notify_perms(notify_channel):
if player.fetch("notify_message") is not None: if player.fetch("notify_message") is not None:
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(discord.HTTPException):
@ -226,7 +226,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass):
if event_type == lavalink.LavalinkEvents.QUEUE_END: if event_type == lavalink.LavalinkEvents.QUEUE_END:
if not autoplay: if not autoplay:
notify_channel_id = player.fetch("notify_channel") 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): if notify_channel and notify and self._has_notify_perms(notify_channel):
await self.send_embed_msg(notify_channel, title=_("Queue ended.")) await self.send_embed_msg(notify_channel, title=_("Queue ended."))
if disconnect: if disconnect:
@ -282,7 +282,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass):
self._ll_guild_updates.discard(guild_id) self._ll_guild_updates.discard(guild_id)
self.bot.dispatch("red_audio_audio_disconnect", guild) self.bot.dispatch("red_audio_audio_disconnect", guild)
if message_channel: if message_channel:
message_channel = guild.get_channel(message_channel) message_channel = guild.get_channel_or_thread(message_channel)
if early_exit: if early_exit:
log.warning( log.warning(
"Audio detected multiple continuous errors during playback " "Audio detected multiple continuous errors during playback "

View File

@ -267,14 +267,14 @@ class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass):
try: try:
await self.api_interface.autoplay(player, self.playlist_api) await self.api_interface.autoplay(player, self.playlist_api)
except DatabaseError: except DatabaseError:
notify_channel = guild.get_channel(notify_channel) notify_channel = guild.get_channel_or_thread(notify_channel)
if notify_channel: if notify_channel:
await self.send_embed_msg( await self.send_embed_msg(
notify_channel, title=_("Couldn't get a valid track.") notify_channel, title=_("Couldn't get a valid track.")
) )
return return
except TrackEnqueueError: except TrackEnqueueError:
notify_channel = guild.get_channel(notify_channel) notify_channel = guild.get_channel_or_thread(notify_channel)
if notify_channel: if notify_channel:
await self.send_embed_msg( await self.send_embed_msg(
notify_channel, notify_channel,

View File

@ -9,7 +9,6 @@ import discord
import lavalink import lavalink
from red_commons.logging import getLogger from red_commons.logging import getLogger
from discord.embeds import EmptyEmbed
from lavalink import NodeNotFound from lavalink import NodeNotFound
from redbot.core import commands from redbot.core import commands
@ -94,7 +93,7 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass):
if not self._player_check(ctx): if not self._player_check(ctx):
if self.lavalink_connection_aborted: if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink node has failed") msg = _("Connection to Lavalink node has failed")
description = EmptyEmbed description = None
if await self.bot.is_owner(ctx.author): if await self.bot.is_owner(ctx.author):
description = _("Please check your console or logs for details.") description = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=msg, description=description) return await self.send_embed_msg(ctx, title=msg, description=description)

View File

@ -10,13 +10,12 @@ from typing import Any, Final, Mapping, MutableMapping, Pattern, Union, cast
import discord import discord
import lavalink import lavalink
from discord.embeds import EmptyEmbed
from red_commons.logging import getLogger from red_commons.logging import getLogger
from redbot.core import bank, commands from redbot.core import bank, commands
from redbot.core.commands import Context from redbot.core.commands import Context
from redbot.core.i18n import Translator 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 redbot.core.utils.chat_formatting import humanize_number
from ...apis.playlist_interface import get_all_playlist_for_migration23 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 self, ctx: commands.Context, author: Mapping[str, str] = None, **kwargs
) -> discord.Message: ) -> discord.Message:
colour = kwargs.get("colour") or kwargs.get("color") or await self.bot.get_embed_color(ctx) 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" _type = kwargs.get("type", "rich") or "rich"
url = kwargs.get("url", EmptyEmbed) or EmptyEmbed url = kwargs.get("url") or None
description = kwargs.get("description", EmptyEmbed) or EmptyEmbed description = kwargs.get("description") or None
timestamp = kwargs.get("timestamp") timestamp = kwargs.get("timestamp")
footer = kwargs.get("footer") footer = kwargs.get("footer")
thumbnail = kwargs.get("thumbnail") thumbnail = kwargs.get("thumbnail")
@ -84,7 +83,6 @@ class MiscellaneousUtilities(MixinMeta, metaclass=CompositeMetaClass):
embed = discord.Embed.from_dict(contents) embed = discord.Embed.from_dict(contents)
embed.color = colour embed.color = colour
if timestamp and isinstance(timestamp, datetime.datetime): if timestamp and isinstance(timestamp, datetime.datetime):
timestamp = timestamp.replace(tzinfo=datetime.timezone.utc)
embed.timestamp = timestamp embed.timestamp = timestamp
else: else:
embed.timestamp = datetime.datetime.now(tz=datetime.timezone.utc) embed.timestamp = datetime.datetime.now(tz=datetime.timezone.utc)
@ -101,9 +99,9 @@ class MiscellaneousUtilities(MixinMeta, metaclass=CompositeMetaClass):
embed.set_author(name=name) embed.set_author(name=name)
return await ctx.send(embed=embed) 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) 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: async def maybe_run_pending_db_tasks(self, ctx: commands.Context) -> None:
if self.api_interface is not None: if self.api_interface is not None:

View File

@ -8,7 +8,6 @@ import discord
import lavalink import lavalink
from red_commons.logging import getLogger from red_commons.logging import getLogger
from discord.embeds import EmptyEmbed
from lavalink import NodeNotFound, PlayerNotFound from lavalink import NodeNotFound, PlayerNotFound
from redbot.core import commands from redbot.core import commands
@ -585,7 +584,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass):
except IndexError: except IndexError:
self.update_player_lock(ctx, False) self.update_player_lock(ctx, False)
title = _("Nothing found") title = _("Nothing found")
desc = EmptyEmbed desc = None
if await self.bot.is_owner(ctx.author): if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.") desc = _("Please check your console or logs for details.")
return await self.send_embed_msg(ctx, title=title, description=desc) return await self.send_embed_msg(ctx, title=title, description=desc)

View File

@ -12,7 +12,6 @@ from typing import List, MutableMapping, Optional, Tuple, Union
import aiohttp import aiohttp
import discord import discord
import lavalink import lavalink
from discord.embeds import EmptyEmbed
from lavalink import NodeNotFound from lavalink import NodeNotFound
from red_commons.logging import getLogger from red_commons.logging import getLogger
@ -525,7 +524,7 @@ class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass):
if not self._player_check(ctx): if not self._player_check(ctx):
if self.lavalink_connection_aborted: if self.lavalink_connection_aborted:
msg = _("Connection to Lavalink node has failed") msg = _("Connection to Lavalink node has failed")
desc = EmptyEmbed desc = None
if await self.bot.is_owner(ctx.author): if await self.bot.is_owner(ctx.author):
desc = _("Please check your console or logs for details.") desc = _("Please check your console or logs for details.")
await self.send_embed_msg(ctx, title=msg, description=desc) await self.send_embed_msg(ctx, title=msg, description=desc)

View File

@ -60,7 +60,7 @@ class ValidationUtilities(MixinMeta, metaclass=CompositeMetaClass):
async def is_query_allowed( async def is_query_allowed(
self, self,
config: Config, config: Config,
ctx_or_channel: Optional[Union[Context, discord.TextChannel]], ctx_or_channel: Optional[Union[Context, discord.TextChannel, discord.Thread]],
query: str, query: str,
query_obj: Query, query_obj: Query,
) -> bool: ) -> bool:

View File

@ -2,5 +2,5 @@ from .cleanup import Cleanup
from redbot.core.bot import Red from redbot.core.bot import Red
def setup(bot: Red): async def setup(bot: Red) -> None:
bot.add_cog(Cleanup(bot)) await bot.add_cog(Cleanup(bot))

View File

@ -1,6 +1,6 @@
import contextlib import contextlib
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Callable, List, Optional, Set, Union from typing import Callable, List, Optional, Set, Union
import discord import discord
@ -75,7 +75,7 @@ class Cleanup(commands.Cog):
@staticmethod @staticmethod
async def get_messages_for_deletion( async def get_messages_for_deletion(
*, *,
channel: Union[discord.TextChannel, discord.DMChannel], channel: Union[discord.TextChannel, discord.DMChannel, discord.Thread],
number: Optional[PositiveInt] = None, number: Optional[PositiveInt] = None,
check: Callable[[discord.Message], bool] = lambda x: True, check: Callable[[discord.Message], bool] = lambda x: True,
limit: Optional[PositiveInt] = None, 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 # 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): def message_filter(message):
return ( return (
@ -129,7 +129,7 @@ class Cleanup(commands.Cog):
async def send_optional_notification( async def send_optional_notification(
self, self,
num: int, num: int,
channel: Union[discord.TextChannel, discord.DMChannel], channel: Union[discord.TextChannel, discord.DMChannel, discord.Thread],
*, *,
subtract_invoking: bool = False, subtract_invoking: bool = False,
) -> None: ) -> None:
@ -149,7 +149,7 @@ class Cleanup(commands.Cog):
@staticmethod @staticmethod
async def get_message_from_reference( async def get_message_from_reference(
channel: discord.TextChannel, reference: discord.MessageReference channel: Union[discord.TextChannel, discord.Thread], reference: discord.MessageReference
) -> Optional[discord.Message]: ) -> Optional[discord.Message]:
message = None message = None
resolved = reference.resolved resolved = reference.resolved
@ -696,7 +696,12 @@ class Cleanup(commands.Cog):
def check(m): def check(m):
if m.attachments: if m.attachments:
return False 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: if c in msgs:
spam.append(m) spam.append(m)
return True return True

View File

@ -1,5 +1,7 @@
from redbot.core.bot import Red
from .customcom import CustomCommands from .customcom import CustomCommands
def setup(bot): async def setup(bot: Red) -> None:
bot.add_cog(CustomCommands(bot)) await bot.add_cog(CustomCommands(bot))

View File

@ -3,7 +3,6 @@ import re
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
from inspect import Parameter from inspect import Parameter
from collections import OrderedDict
from typing import Iterable, List, Mapping, Tuple, Dict, Set, Literal, Union from typing import Iterable, List, Mapping, Tuple, Dict, Set, Literal, Union
from urllib.parse import quote_plus from urllib.parse import quote_plus
@ -540,7 +539,7 @@ class CustomCommands(commands.Cog):
) )
@customcom.command(name="list") @customcom.command(name="list")
@checks.bot_has_permissions(add_reactions=True) @commands.bot_can_react()
async def cc_list(self, ctx: commands.Context): async def cc_list(self, ctx: commands.Context):
"""List all available custom commands. """List all available custom commands.
@ -636,12 +635,15 @@ class CustomCommands(commands.Cog):
@commands.Cog.listener() @commands.Cog.listener()
async def on_message_without_command(self, message): 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 # user_allowed check, will be replaced with self.bot.user_allowed or
# something similar once it's added # something similar once it's added
user_allowed = True 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: if len(message.content) < 2 or is_private or not user_allowed or message.author.bot:
return return
@ -705,9 +707,8 @@ class CustomCommands(commands.Cog):
@staticmethod @staticmethod
def prepare_args(raw_response) -> Mapping[str, Parameter]: def prepare_args(raw_response) -> Mapping[str, Parameter]:
args = re.findall(r"{(\d+)[^:}]*(:[^.}]*)?[^}]*\}", raw_response) args = re.findall(r"{(\d+)[^:}]*(:[^.}]*)?[^}]*\}", raw_response)
default = [("ctx", Parameter("ctx", Parameter.POSITIONAL_OR_KEYWORD))]
if not args: if not args:
return OrderedDict(default) return {}
allowed_builtins = { allowed_builtins = {
"bool": bool, "bool": bool,
"complex": complex, "complex": complex,
@ -775,9 +776,7 @@ class CustomCommands(commands.Cog):
i if i < high else "final", i if i < high else "final",
) )
fin[i] = fin[i].replace(name=name) fin[i] = fin[i].replace(name=name)
# insert ctx parameter for discord.py parsing return dict((p.name, p) for p in fin)
fin = default + [(p.name, p) for p in fin]
return OrderedDict(fin)
def test_cooldowns(self, ctx, command, cooldowns): def test_cooldowns(self, ctx, command, cooldowns):
now = datetime.utcnow() now = datetime.utcnow()

View File

@ -1,7 +1,9 @@
from redbot.core.bot import Red
from .downloader import Downloader from .downloader import Downloader
async def setup(bot): async def setup(bot: Red) -> None:
cog = Downloader(bot) cog = Downloader(bot)
bot.add_cog(cog) await bot.add_cog(cog)
cog.create_init_task() cog.create_init_task()

View File

@ -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.bot import Red
from redbot.core.data_manager import cog_data_path from redbot.core.data_manager import cog_data_path
from redbot.core.i18n import Translator, cog_i18n 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.chat_formatting import box, pagify, humanize_list, inline
from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.menus import start_adding_reactions
from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate 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 poss_installed_path = (await self.cog_install_path()) / real_name
if poss_installed_path.exists(): if poss_installed_path.exists():
with contextlib.suppress(commands.ExtensionNotLoaded): 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 ctx.bot.remove_loaded_package(real_name)
await self._delete_cog(poss_installed_path) await self._delete_cog(poss_installed_path)
uninstalled_cogs.append(inline(real_name)) uninstalled_cogs.append(inline(real_name))
@ -1665,7 +1666,7 @@ class Downloader(commands.Cog):
if len(updated_cognames) > 1 if len(updated_cognames) > 1
else _("Would you like to reload the updated cog?") 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: if not can_react:
message += " (yes/no)" message += " (yes/no)"
query: discord.Message = await ctx.send(message) query: discord.Message = await ctx.send(message)

View File

@ -2,5 +2,5 @@ from redbot.core.bot import Red
from .economy import Economy from .economy import Economy
def setup(bot: Red): async def setup(bot: Red) -> None:
bot.add_cog(Economy(bot)) await bot.add_cog(Economy(bot))

View File

@ -434,11 +434,11 @@ class Economy(commands.Cog):
if show_global and await bank.is_global(): if show_global and await bank.is_global():
# show_global is only applicable if bank is global # show_global is only applicable if bank is global
bank_sorted = await bank.get_leaderboard(positions=top, guild=None) 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: else:
bank_sorted = await bank.get_leaderboard(positions=top, guild=guild) bank_sorted = await bank.get_leaderboard(positions=top, guild=guild)
if 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: try:
bal_len = len(humanize_number(bank_sorted[0][1]["balance"])) bal_len = len(humanize_number(bank_sorted[0][1]["balance"]))

View File

@ -3,6 +3,4 @@ from redbot.core.bot import Red
async def setup(bot: Red) -> None: async def setup(bot: Red) -> None:
cog = Filter(bot) await bot.add_cog(Filter(bot))
await cog.initialize()
bot.add_cog(cog)

View File

@ -56,7 +56,7 @@ class Filter(commands.Cog):
if user_id in guild_data: if user_id in guild_data:
await self.config.member_from_ids(guild_id, user_id).clear() 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() await self.register_casetypes()
@staticmethod @staticmethod
@ -205,6 +205,14 @@ class Filter(commands.Cog):
async def _channel_clear(self, ctx: commands.Context): async def _channel_clear(self, ctx: commands.Context):
"""Clears this channel's filter list.""" """Clears this channel's filter list."""
channel = ctx.channel 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 author = ctx.author
filter_list = await self.config.channel(channel).filter() filter_list = await self.config.channel(channel).filter()
if not filter_list: if not filter_list:
@ -228,7 +236,7 @@ class Filter(commands.Cog):
@_filter_channel.command(name="list") @_filter_channel.command(name="list")
async def _channel_list(self, ctx: commands.Context): async def _channel_list(self, ctx: commands.Context):
"""Send a list of the channel's filtered words.""" """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 author = ctx.author
word_list = await self.config.channel(channel).filter() word_list = await self.config.channel(channel).filter()
if not word_list: if not word_list:
@ -257,6 +265,14 @@ class Filter(commands.Cog):
- `[words...]` The words or sentences to filter. - `[words...]` The words or sentences to filter.
""" """
channel = ctx.channel 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) added = await self.add_to_filter(channel, words)
if added: if added:
self.invalidate_cache(ctx.guild, ctx.channel) self.invalidate_cache(ctx.guild, ctx.channel)
@ -279,6 +295,14 @@ class Filter(commands.Cog):
- `[words...]` The words or sentences to no longer filter. - `[words...]` The words or sentences to no longer filter.
""" """
channel = ctx.channel 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) removed = await self.remove_from_filter(channel, words)
if removed: if removed:
await ctx.send(_("Words removed from filter.")) await ctx.send(_("Words removed from filter."))
@ -397,14 +421,19 @@ class Filter(commands.Cog):
return removed return removed
async def filter_hits( 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]: ) -> Set[str]:
try: if isinstance(server_or_channel, discord.Guild):
guild = server_or_channel.guild
channel = server_or_channel
except AttributeError:
guild = server_or_channel guild = server_or_channel
channel = None 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() hits: Set[str] = set()
@ -437,7 +466,7 @@ class Filter(commands.Cog):
filter_time = guild_data["filterban_time"] filter_time = guild_data["filterban_time"]
user_count = member_data["filter_count"] user_count = member_data["filter_count"]
next_reset_time = member_data["next_reset_time"] 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 filter_count > 0 and filter_time > 0:
if created_at.timestamp() >= next_reset_time: if created_at.timestamp() >= next_reset_time:
@ -451,10 +480,16 @@ class Filter(commands.Cog):
hits = await self.filter_hits(message.content, message.channel) hits = await self.filter_hits(message.content, message.channel)
if hits: if hits:
# modlog doesn't accept PartialMessageable
channel = (
None
if isinstance(message.channel, discord.PartialMessageable)
else message.channel
)
await modlog.create_case( await modlog.create_case(
bot=self.bot, bot=self.bot,
guild=guild, guild=guild,
created_at=message.created_at.replace(tzinfo=timezone.utc), created_at=created_at,
action_type="filterhit", action_type="filterhit",
user=author, user=author,
moderator=guild.me, moderator=guild.me,
@ -463,7 +498,7 @@ class Filter(commands.Cog):
if len(hits) > 1 if len(hits) > 1
else _("Filtered word used: {word}").format(word=list(hits)[0]) else _("Filtered word used: {word}").format(word=list(hits)[0])
), ),
channel=message.channel, channel=channel,
) )
try: try:
await message.delete() await message.delete()
@ -484,7 +519,7 @@ class Filter(commands.Cog):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
message.created_at.replace(tzinfo=timezone.utc), message.created_at,
"filterban", "filterban",
author, author,
guild.me, guild.me,

View File

@ -1,5 +1,7 @@
from redbot.core.bot import Red
from .general import General from .general import General
def setup(bot): async def setup(bot: Red) -> None:
bot.add_cog(General()) await bot.add_cog(General(bot))

View File

@ -7,6 +7,7 @@ import urllib.parse
import aiohttp import aiohttp
import discord import discord
from redbot.core import commands from redbot.core import commands
from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
from redbot.core.utils.chat_formatting import ( from redbot.core.utils.chat_formatting import (
@ -72,8 +73,9 @@ class General(commands.Cog):
] ]
_ = T_ _ = T_
def __init__(self): def __init__(self, bot: Red) -> None:
super().__init__() super().__init__()
self.bot = bot
self.stopwatches = {} self.stopwatches = {}
async def red_delete_data_for_user(self, **kwargs): async def red_delete_data_for_user(self, **kwargs):
@ -254,18 +256,22 @@ class General(commands.Cog):
Default to False. Default to False.
""" """
guild = ctx.guild guild = ctx.guild
created_at = _("Created on <t:{0}>. That's <t:{0}:R>!").format( created_at = _("Created on {date_and_time}. That's {relative_time}!").format(
int(guild.created_at.replace(tzinfo=datetime.timezone.utc).timestamp()), date_and_time=discord.utils.format_dt(guild.created_at),
relative_time=discord.utils.format_dt(guild.created_at, "R"),
) )
online = humanize_number( online = humanize_number(
len([m.status for m in guild.members if m.status != discord.Status.offline]) 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)) text_channels = humanize_number(len(guild.text_channels))
voice_channels = humanize_number(len(guild.voice_channels)) voice_channels = humanize_number(len(guild.voice_channels))
if not details: if not details:
data = discord.Embed(description=created_at, colour=await ctx.embed_colour()) 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=_("Text Channels"), value=text_channels)
data.add_field(name=_("Voice Channels"), value=voice_channels) data.add_field(name=_("Voice Channels"), value=voice_channels)
data.add_field(name=_("Roles"), value=humanize_number(len(guild.roles))) 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" command=f"{ctx.clean_prefix}serverinfo 1"
) )
) )
if guild.icon_url: if guild.icon:
data.set_author(name=guild.name, url=guild.icon_url) data.set_author(name=guild.name, url=guild.icon)
data.set_thumbnail(url=guild.icon_url) data.set_thumbnail(url=guild.icon)
else: else:
data.set_author(name=guild.name) data.set_author(name=guild.name)
else: else:
@ -342,7 +348,7 @@ class General(commands.Cog):
"low": _("1 - Low"), "low": _("1 - Low"),
"medium": _("2 - Medium"), "medium": _("2 - Medium"),
"high": _("3 - High"), "high": _("3 - High"),
"extreme": _("4 - Extreme"), "highest": _("4 - Highest"),
} }
features = { features = {
@ -389,10 +395,10 @@ class General(commands.Cog):
if "VERIFIED" in guild.features if "VERIFIED" in guild.features
else "https://cdn.discordapp.com/emojis/508929941610430464.png" else "https://cdn.discordapp.com/emojis/508929941610430464.png"
if "PARTNERED" in guild.features if "PARTNERED" in guild.features
else discord.Embed.Empty, else None,
) )
if guild.icon_url: if guild.icon:
data.set_thumbnail(url=guild.icon_url) data.set_thumbnail(url=guild.icon)
data.add_field(name=_("Members:"), value=member_msg) data.add_field(name=_("Members:"), value=member_msg)
data.add_field( data.add_field(
name=_("Channels:"), name=_("Channels:"),
@ -444,7 +450,7 @@ class General(commands.Cog):
) )
data.add_field(name=_("Nitro Boost:"), value=nitro_boost) data.add_field(name=_("Nitro Boost:"), value=nitro_boost)
if guild.splash: 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) data.set_footer(text=joined_on)
await ctx.send(embed=data) await ctx.send(embed=data)

View File

@ -1,7 +1,7 @@
from redbot.core.bot import Red
from .image import Image from .image import Image
async def setup(bot): async def setup(bot: Red) -> None:
cog = Image(bot) await bot.add_cog(Image(bot))
await cog.initialize()
bot.add_cog(cog)

View File

@ -24,14 +24,7 @@ class Image(commands.Cog):
self.session = aiohttp.ClientSession() self.session = aiohttp.ClientSession()
self.imgur_base_url = "https://api.imgur.com/3/" self.imgur_base_url = "https://api.imgur.com/3/"
def cog_unload(self): async def cog_load(self) -> None:
self.session.detach()
async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete"""
return
async def initialize(self) -> None:
"""Move the API keys from cog stored config to core bot config if they exist.""" """Move the API keys from cog stored config to core bot config if they exist."""
imgur_token = await self.config.imgur_client_id() imgur_token = await self.config.imgur_client_id()
if imgur_token is not None: 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.bot.set_shared_api_tokens("imgur", client_id=imgur_token)
await self.config.imgur_client_id.clear() 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") @commands.group(name="imgur")
async def _imgur(self, ctx): async def _imgur(self, ctx):
"""Retrieve pictures from Imgur. """Retrieve pictures from Imgur.

View File

@ -2,7 +2,5 @@ from redbot.core.bot import Red
from .mod import Mod from .mod import Mod
async def setup(bot: Red): async def setup(bot: Red) -> None:
cog = Mod(bot) await bot.add_cog(Mod(bot))
bot.add_cog(cog)
await cog.initialize()

View File

@ -64,7 +64,7 @@ class Events(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
message.created_at.replace(tzinfo=timezone.utc), message.created_at,
"ban", "ban",
author, author,
guild.me, guild.me,
@ -88,7 +88,7 @@ class Events(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
message.created_at.replace(tzinfo=timezone.utc), message.created_at,
"kick", "kick",
author, author,
guild.me, guild.me,
@ -120,7 +120,7 @@ class Events(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
message.created_at.replace(tzinfo=timezone.utc), message.created_at,
"warning", "warning",
author, author,
guild.me, guild.me,

View File

@ -29,23 +29,13 @@ class KickBanMixin(MixinMeta):
""" """
@staticmethod @staticmethod
async def get_invite_for_reinvite(ctx: commands.Context, max_age: int = 86400): async def get_invite_for_reinvite(ctx: commands.Context, max_age: int = 86400) -> str:
"""Handles the reinvite logic for getting an invite """Handles the reinvite logic for getting an invite to send the newly unbanned user"""
to send the newly unbanned user
:returns: :class:`Invite`"""
guild = ctx.guild guild = ctx.guild
my_perms: discord.Permissions = guild.me.guild_permissions my_perms: discord.Permissions = guild.me.guild_permissions
if my_perms.manage_guild or my_perms.administrator: if my_perms.manage_guild or my_perms.administrator:
if "VANITY_URL" in guild.features: if guild.vanity_url is not None:
# guild has a vanity url so use it as the one to send return guild.vanity_url
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
invites = await guild.invites() invites = await guild.invites()
else: else:
invites = [] invites = []
@ -55,22 +45,22 @@ class KickBanMixin(MixinMeta):
# has unlimited uses, doesn't expire, and # has unlimited uses, doesn't expire, and
# doesn't grant temporary membership # doesn't grant temporary membership
# (i.e. they won't be kicked on disconnect) # (i.e. they won't be kicked on disconnect)
return inv return inv.url
else: # No existing invite found that is valid else: # No existing invite found that is valid
channels_and_perms = zip( channels_and_perms = (
guild.text_channels, map(guild.me.permissions_in, guild.text_channels) (channel, channel.permissions_for(guild.me)) for channel in guild.text_channels
) )
channel = next( channel = next(
(channel for channel, perms in channels_and_perms if perms.create_instant_invite), (channel for channel, perms in channels_and_perms if perms.create_instant_invite),
None, None,
) )
if channel is None: if channel is None:
return return ""
try: try:
# Create invite that expires after max_age # 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: except discord.HTTPException:
return return ""
@staticmethod @staticmethod
async def _voice_perm_check( async def _voice_perm_check(
@ -220,7 +210,7 @@ class KickBanMixin(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
ban_type, ban_type,
user, user,
author, author,
@ -356,7 +346,7 @@ class KickBanMixin(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"kick", "kick",
member, member,
author, author,
@ -566,7 +556,7 @@ class KickBanMixin(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"hackban", "hackban",
user_id, user_id,
author, author,
@ -636,8 +626,6 @@ class KickBanMixin(MixinMeta):
await ctx.send(_("Invalid days. Must be between 0 and 7.")) await ctx.send(_("Invalid days. Must be between 0 and 7."))
return return
invite = await self.get_invite_for_reinvite(ctx, int(duration.total_seconds() + 86400)) 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()) await self.config.member(member).banned_until.set(unban_time.timestamp())
async with self.config.guild(guild).current_tempbans() as current_tempbans: async with self.config.guild(guild).current_tempbans() as current_tempbans:
@ -646,7 +634,7 @@ class KickBanMixin(MixinMeta):
with contextlib.suppress(discord.HTTPException): with contextlib.suppress(discord.HTTPException):
# We don't want blocked DMs preventing us from banning # We don't want blocked DMs preventing us from banning
msg = _("You have been temporarily banned from {server_name} until {date}.").format( msg = _("You have been temporarily banned from {server_name} until {date}.").format(
server_name=guild.name, date=f"<t:{int(unban_time.timestamp())}>" server_name=guild.name, date=discord.utils.format_dt(unban_time)
) )
if guild_data["dm_on_kickban"] and reason: if guild_data["dm_on_kickban"] and reason:
msg += _("\n\n**Reason:** {reason}").format(reason=reason) msg += _("\n\n**Reason:** {reason}").format(reason=reason)
@ -668,7 +656,7 @@ class KickBanMixin(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"tempban", "tempban",
member, member,
author, author,
@ -706,8 +694,6 @@ class KickBanMixin(MixinMeta):
audit_reason = get_audit_reason(author, reason, shorten=True) audit_reason = get_audit_reason(author, reason, shorten=True)
invite = await self.get_invite_for_reinvite(ctx) invite = await self.get_invite_for_reinvite(ctx)
if invite is None:
invite = ""
try: # We don't want blocked DMs preventing us from banning try: # We don't want blocked DMs preventing us from banning
msg = await member.send( msg = await member.send(
@ -750,7 +736,7 @@ class KickBanMixin(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"softban", "softban",
member, member,
author, author,
@ -797,7 +783,7 @@ class KickBanMixin(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"vkick", "vkick",
member, member,
author, author,
@ -840,7 +826,7 @@ class KickBanMixin(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"voiceunban", "voiceunban",
member, member,
author, author,
@ -881,7 +867,7 @@ class KickBanMixin(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"voiceban", "voiceban",
member, member,
author, author,
@ -921,7 +907,7 @@ class KickBanMixin(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"unban", "unban",
ban_entry.user, ban_entry.user,
author, author,
@ -946,7 +932,7 @@ class KickBanMixin(MixinMeta):
_( _(
"You've been unbanned from {server}.\n" "You've been unbanned from {server}.\n"
"Here is an invite for that server: {invite_link}" "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: except discord.Forbidden:
await ctx.send( await ctx.send(
@ -954,12 +940,12 @@ class KickBanMixin(MixinMeta):
"I failed to send an invite to that user. " "I failed to send an invite to that user. "
"Perhaps you may be able to send it for me?\n" "Perhaps you may be able to send it for me?\n"
"Here's the invite link: {invite_link}" "Here's the invite link: {invite_link}"
).format(invite_link=invite.url) ).format(invite_link=invite)
) )
except discord.HTTPException: except discord.HTTPException:
await ctx.send( await ctx.send(
_( _(
"Something went wrong when attempting to send that user " "Something went wrong when attempting to send that user "
"an invite. Here's the link so you can try: {invite_link}" "an invite. Here's the link so you can try: {invite_link}"
).format(invite_link=invite.url) ).format(invite_link=invite)
) )

View File

@ -84,8 +84,6 @@ class Mod(
self.tban_expiry_task = asyncio.create_task(self.tempban_expirations_task()) self.tban_expiry_task = asyncio.create_task(self.tempban_expirations_task())
self.last_case: dict = defaultdict(dict) self.last_case: dict = defaultdict(dict)
self._ready = asyncio.Event()
async def red_delete_data_for_user( async def red_delete_data_for_user(
self, self,
*, *,
@ -114,12 +112,8 @@ class Mod(
pass pass
# possible with a context switch between here and getting all guilds # 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() 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): def cog_unload(self):
self.tban_expiry_task.cancel() self.tban_expiry_task.cancel()

View File

@ -195,9 +195,8 @@ class ModInfo(MixinMeta):
if is_special: if is_special:
joined_at = special_date joined_at = special_date
elif joined_at := member.joined_at: else:
joined_at = joined_at.replace(tzinfo=datetime.timezone.utc) joined_at = member.joined_at
user_created = int(member.created_at.replace(tzinfo=datetime.timezone.utc).timestamp())
voice_state = member.voice voice_state = member.voice
member_number = ( member_number = (
sorted(guild.members, key=lambda m: m.joined_at or ctx.message.created_at).index( sorted(guild.members, key=lambda m: m.joined_at or ctx.message.created_at).index(
@ -206,9 +205,15 @@ class ModInfo(MixinMeta):
+ 1 + 1
) )
created_on = "<t:{0}>\n(<t:{0}:R>)".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: if joined_at is not None:
joined_on = "<t:{0}>\n(<t:{0}:R>)".format(int(joined_at.timestamp())) joined_on = (
f"{discord.utils.format_dt(joined_at)}\n"
f"{discord.utils.format_dt(joined_at, 'R')}"
)
else: else:
joined_on = _("Unknown") joined_on = _("Unknown")
@ -296,7 +301,7 @@ class ModInfo(MixinMeta):
name = " ~ ".join((name, member.nick)) if member.nick else name name = " ~ ".join((name, member.nick)) if member.nick else name
name = filter_invites(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_author(name=f"{statusemoji} {name}", url=avatar)
data.set_thumbnail(url=avatar) data.set_thumbnail(url=avatar)

View File

@ -14,8 +14,8 @@ class Slowmode(MixinMeta):
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
@commands.bot_has_permissions(manage_channels=True) @commands.bot_can_manage_channel()
@checks.admin_or_permissions(manage_channels=True) @commands.admin_or_can_manage_channel()
async def slowmode( async def slowmode(
self, self,
ctx, ctx,
@ -24,7 +24,7 @@ class Slowmode(MixinMeta):
minimum=timedelta(seconds=0), maximum=timedelta(hours=6), default_unit="seconds" minimum=timedelta(seconds=0), maximum=timedelta(hours=6), default_unit="seconds"
) = timedelta(seconds=0), ) = 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. Interval can be anything from 0 seconds to 6 hours.
Use without parameters to disable. Use without parameters to disable.

View File

@ -2,5 +2,5 @@ from redbot.core.bot import Red
from .modlog import ModLog from .modlog import ModLog
def setup(bot: Red): async def setup(bot: Red) -> None:
bot.add_cog(ModLog(bot)) await bot.add_cog(ModLog(bot))

View File

@ -40,7 +40,11 @@ class ModLog(commands.Cog):
if await ctx.embed_requested(): if await ctx.embed_requested():
await ctx.send(embed=await case.message_content(embed=True)) await ctx.send(embed=await case.message_content(embed=True))
else: else:
message = f"{await case.message_content(embed=False)}\n{bold(_('Timestamp:'))} <t:{int(case.created_at)}>" 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) await ctx.send(message)
@commands.command() @commands.command()
@ -73,7 +77,11 @@ class ModLog(commands.Cog):
else: else:
rendered_cases = [] rendered_cases = []
for case in cases: for case in cases:
message = f"{await case.message_content(embed=False)}\n{bold(_('Timestamp:'))} <t:{int(case.created_at)}>" 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) rendered_cases.append(message)
await menu(ctx, rendered_cases, DEFAULT_CONTROLS) await menu(ctx, rendered_cases, DEFAULT_CONTROLS)
@ -104,7 +112,11 @@ class ModLog(commands.Cog):
rendered_cases = [] rendered_cases = []
message = "" message = ""
for case in cases: for case in cases:
message += f"{await case.message_content(embed=False)}\n{bold(_('Timestamp:'))} <t:{int(case.created_at)}>" 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): for page in pagify(message, ["\n\n", "\n"], priority=True):
rendered_cases.append(page) rendered_cases.append(page)
await menu(ctx, rendered_cases, DEFAULT_CONTROLS) await menu(ctx, rendered_cases, DEFAULT_CONTROLS)
@ -143,7 +155,7 @@ class ModLog(commands.Cog):
to_modify = {"reason": reason} to_modify = {"reason": reason}
if case_obj.moderator != author: if case_obj.moderator != author:
to_modify["amended_by"] = 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 case_obj.edit(to_modify)
await ctx.send( await ctx.send(
_("Reason for case #{num} has been updated.").format(num=case_obj.case_number) _("Reason for case #{num} has been updated.").format(num=case_obj.case_number)

View File

@ -2,6 +2,7 @@ from redbot.core.bot import Red
from .mutes import Mutes from .mutes import Mutes
def setup(bot: Red): async def setup(bot: Red) -> None:
cog = Mutes(bot) cog = Mutes(bot)
bot.add_cog(cog) await bot.add_cog(cog)
cog.create_init_task()

View File

@ -12,7 +12,7 @@ from .voicemutes import VoiceMutes
from redbot.core.bot import Red from redbot.core.bot import Red
from redbot.core import commands, checks, i18n, modlog, Config 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 ( from redbot.core.utils.chat_formatting import (
bold, bold,
humanize_timedelta, humanize_timedelta,
@ -87,28 +87,42 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
"dm": False, "dm": False,
"show_mod": 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. # Tbh I would rather force everyone to use role mutes.
# I also honestly think everyone would agree they're the # I also honestly think everyone would agree they're the
# way to go. If for whatever reason someone wants to # way to go. If for whatever reason someone wants to
# enable channel overwrite mutes for their bot they can. # enable channel overwrite mutes for their bot they can.
# Channel overwrite logic still needs to be in place # Channel overwrite logic still needs to be in place
# for channel mutes methods. # for channel mutes methods.
self.config.register_global(force_role_mutes=True, schema_version=0)
self.config.register_guild(**default_guild) self.config.register_guild(**default_guild)
self.config.register_member(perms_cache={}) self.config.register_member(perms_cache={})
self.config.register_channel(muted_users={}) self.config.register_channel(muted_users={})
self._server_mutes: Dict[int, Dict[int, dict]] = {} self._server_mutes: Dict[int, Dict[int, dict]] = {}
self._channel_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_tasks: Dict[str, asyncio.Task] = {}
self._unmute_task = None self._unmute_task: Optional[asyncio.Task] = None
self.mute_role_cache: Dict[int, int] = {} 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 # this is a dict of guild ID's and asyncio.Events
# to wait for a guild to finish channel unmutes before # to wait for a guild to finish channel unmutes before
# checking for manual overwrites # 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( async def red_delete_data_for_user(
self, self,
@ -125,13 +139,17 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
return return
await self._ready.wait() 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() all_members = await self.config.all_members()
for g_id, data in all_members.items(): for g_id, data in all_members.items():
for m_id, mutes in data.items(): for m_id, mutes in data.items():
if m_id == user_id: if m_id == user_id:
await self.config.member_from_ids(g_id, m_id).clear() 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.bot.wait_until_red_ready()
await self._maybe_update_config() 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): 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): def cog_unload(self):
self._init_task.cancel() if self._init_task is not None:
self._unmute_task.cancel() self._init_task.cancel()
if self._unmute_task is not None:
self._unmute_task.cancel()
for task in self._unmute_tasks.values(): for task in self._unmute_tasks.values():
task.cancel() task.cancel()
@ -208,6 +236,8 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
""" """
await self.bot.wait_until_red_ready() await self.bot.wait_until_red_ready()
await self._ready.wait() await self._ready.wait()
if self._ready_raised:
raise RuntimeError("Mutes cog is in a bad state, cancelling automatic unmute task.")
while True: while True:
await self._clean_tasks() await self._clean_tasks()
try: try:
@ -533,7 +563,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
if duration: if duration:
duration_str = humanize_timedelta(timedelta=duration) duration_str = humanize_timedelta(timedelta=duration)
until = datetime.now(timezone.utc) + duration until = datetime.now(timezone.utc) + duration
until_str = f"<t:{int(until.timestamp())}>" until_str = discord.utils.format_dt(until)
if moderator is None: if moderator is None:
moderator_str = _("Unknown") moderator_str = _("Unknown")
@ -549,7 +579,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
description=reason, description=reason,
color=await self.bot.get_embed_color(user), color=await self.bot.get_embed_color(user),
) )
em.timestamp = datetime.utcnow() em.timestamp = datetime.now(timezone.utc)
if duration: if duration:
em.add_field(name=_("Until"), value=until_str) em.add_field(name=_("Until"), value=until_str)
em.add_field(name=_("Duration"), value=duration_str) em.add_field(name=_("Duration"), value=duration_str)
@ -663,21 +693,18 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
} }
to_del: List[int] = [] to_del: List[int] = []
for user_id in self._channel_mutes[after.id].keys(): for user_id in self._channel_mutes[after.id].keys():
send_messages = False unmuted = False
speak = False
if user_id in after_perms: if user_id in after_perms:
send_messages = ( for perm_name in (
after_perms[user_id]["send_messages"] is None "send_messages",
or after_perms[user_id]["send_messages"] is True "send_messages_in_threads",
) "create_public_threads",
speak = ( "create_private_threads",
after_perms[user_id]["speak"] is None "speak",
or after_perms[user_id]["speak"] is True ):
) unmuted = unmuted or after_perms[user_id][perm_name] is not False
# explicit is better than implicit :thinkies: # explicit is better than implicit :thinkies:
if user_id in before_perms and ( if user_id in before_perms and (user_id not in after_perms or unmuted):
user_id not in after_perms or any((send_messages, speak))
):
user = after.guild.get_member(user_id) user = after.guild.get_member(user_id)
send_dm_notification = True send_dm_notification = True
if not user: if not user:
@ -900,7 +927,14 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
) )
async with ctx.typing(): async with ctx.typing():
perms = discord.Permissions() 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: try:
role = await ctx.guild.create_role( role = await ctx.guild.create_role(
name=name, permissions=perms, reason=_("Mute role setup") name=name, permissions=perms, reason=_("Mute role setup")
@ -942,6 +976,9 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
return channel.mention return channel.mention
overs = discord.PermissionOverwrite() overs = discord.PermissionOverwrite()
overs.send_messages = False 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.add_reactions = False
overs.speak = False overs.speak = False
try: try:
@ -1007,7 +1044,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
"Role mutes do not have this issue.\n\n" "Role mutes do not have this issue.\n\n"
"Are you sure you want to continue with channel overwrites? " "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: if can_react:
msg += _( msg += _(
"Reacting with \N{WHITE HEAVY CHECK MARK} will continue " "Reacting with \N{WHITE HEAVY CHECK MARK} will continue "
@ -1178,7 +1215,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"smute", "smute",
user, user,
author, 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?" "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: if not can_react:
message += " (y/n)" message += " (y/n)"
query: discord.Message = await ctx.send(message) query: discord.Message = await ctx.send(message)
@ -1279,7 +1316,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
*, *,
time_and_reason: MuteTime = {}, 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).
`<users...>` is a space separated list of usernames, ID's, or mentions. `<users...>` is a space separated list of usernames, ID's, or mentions.
`[time_and_reason]` is the time to mute for and reason. Time is `[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 author = ctx.message.author
channel = ctx.message.channel channel = ctx.message.channel
if isinstance(channel, discord.Thread):
channel = channel.parent
guild = ctx.guild guild = ctx.guild
audit_reason = get_audit_reason(author, reason, shorten=True) audit_reason = get_audit_reason(author, reason, shorten=True)
issue_list = [] issue_list = []
@ -1327,7 +1366,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"cmute", "cmute",
user, user,
author, author,
@ -1347,7 +1386,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
msg = _("{users} has been muted in this channel{time}.") msg = _("{users} has been muted in this channel{time}.")
if len(success_list) > 1: if len(success_list) > 1:
msg = _("{users} have been muted in this channel{time}.") 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) msg.format(users=humanize_list([f"{u}" for u in success_list]), time=time)
) )
if issue_list: if issue_list:
@ -1397,7 +1436,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"sunmute", "sunmute",
user, user,
author, author,
@ -1435,7 +1474,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
*, *,
reason: Optional[str] = None, reason: Optional[str] = None,
): ):
"""Unmute a user in this channel. """Unmute a user in this channel (or in the parent of this thread).
`<users...>` is a space separated list of usernames, ID's, or mentions. `<users...>` is a space separated list of usernames, ID's, or mentions.
`[reason]` is the reason for the unmute. `[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.")) return await ctx.send(_("You cannot unmute yourself."))
async with ctx.typing(): async with ctx.typing():
channel = ctx.channel channel = ctx.channel
if isinstance(channel, discord.Thread):
channel = channel.parent
author = ctx.author author = ctx.author
guild = ctx.guild guild = ctx.guild
audit_reason = get_audit_reason(author, reason, shorten=True) audit_reason = get_audit_reason(author, reason, shorten=True)
@ -1463,7 +1504,7 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"cunmute", "cunmute",
user, user,
author, author,
@ -1654,7 +1695,14 @@ class Mutes(VoiceMutes, commands.Cog, metaclass=CompositeMetaClass):
new_overs: dict = {} new_overs: dict = {}
move_channel = False 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 send_reason = None
if user.voice and user.voice.channel: if user.voice and user.voice.channel:
if channel.permissions_for(guild.me).move_members: 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: if channel.id in perms_cache:
old_values = perms_cache[channel.id] old_values = perms_cache[channel.id]
else: 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 user.voice and user.voice.channel:
if channel.permissions_for(guild.me).move_members: if channel.permissions_for(guild.me).move_members:

View File

@ -135,7 +135,7 @@ class VoiceMutes(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"vmute", "vmute",
user, user,
author, author,
@ -211,7 +211,7 @@ class VoiceMutes(MixinMeta):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
guild, guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"vunmute", "vunmute",
user, user,
author, author,

View File

@ -1,7 +1,9 @@
from redbot.core.bot import Red
from .permissions import Permissions from .permissions import Permissions
async def setup(bot): async def setup(bot: Red) -> None:
cog = Permissions(bot) cog = Permissions(bot)
await cog.initialize() await cog.initialize()
# We should add the rules for the Permissions cog and its own commands *before* adding the cog. # 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) await cog._on_cog_add(cog)
for command in cog.__cog_commands__: for command in cog.__cog_commands__:
await cog._on_command_add(command) await cog._on_command_add(command)
bot.add_cog(cog) await bot.add_cog(cog)

View File

@ -31,7 +31,7 @@ class GlobalUniqueObjectFinder(commands.Converter):
if guild is not None: if guild is not None:
return guild return guild
channel: discord.abc.GuildChannel = bot.get_channel(_id) 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 return channel
user: discord.User = bot.get_user(_id) user: discord.User = bot.get_user(_id)

View File

@ -10,6 +10,7 @@ from schema import And, Or, Schema, SchemaError, Optional as UseOptional
from redbot.core import checks, commands, config from redbot.core import checks, commands, config
from redbot.core.bot import Red from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n 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.chat_formatting import box
from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.menus import start_adding_reactions
from redbot.core.utils.predicates import ReactionPredicate, MessagePredicate 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" "multiple global or server rules apply to the case, the order they are checked in is:\n"
" 1. Rules about a user.\n" " 1. Rules about a user.\n"
" 2. Rules about the voice channel a user is in.\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 " " 4. Rules about a role the user has (The highest role they have with a rule will be "
"used).\n" "used).\n"
" 5. Rules about the server a user is in (Global rules only).\n\n" " 5. Rules about the server a user is in (Global rules only).\n\n"
@ -686,7 +687,7 @@ class Permissions(commands.Cog):
@staticmethod @staticmethod
async def _confirm(ctx: commands.Context) -> bool: async def _confirm(ctx: commands.Context) -> bool:
"""Ask "Are you sure?" and get the response as a 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?")) msg = await ctx.send(_("Are you sure?"))
# noinspection PyAsyncCall # noinspection PyAsyncCall
task = start_adding_reactions(msg, ReactionPredicate.YES_OR_NO_EMOJIS) task = start_adding_reactions(msg, ReactionPredicate.YES_OR_NO_EMOJIS)
@ -815,8 +816,8 @@ class Permissions(commands.Cog):
elif rule is False: elif rule is False:
cog_or_command.deny_to(model_id, guild_id=guild_id) cog_or_command.deny_to(model_id, guild_id=guild_id)
def cog_unload(self) -> None: async def cog_unload(self) -> None:
asyncio.create_task(self._unload_all_rules()) await self._unload_all_rules()
async def _unload_all_rules(self) -> None: async def _unload_all_rules(self) -> None:
"""Unload all rules set by this cog. """Unload all rules set by this cog.

View File

@ -2,5 +2,5 @@ from redbot.core.bot import Red
from .reports import Reports from .reports import Reports
def setup(bot: Red): async def setup(bot: Red) -> None:
bot.add_cog(Reports(bot)) await bot.add_cog(Reports(bot))

View File

@ -212,7 +212,7 @@ class Reports(commands.Cog):
name=_("Report from {author}{maybe_nick}").format( name=_("Report from {author}{maybe_nick}").format(
author=author, maybe_nick=(f" ({author.nick})" if author.nick else "") 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)) em.set_footer(text=_("Report #{}").format(ticket_number))
send_content = None send_content = None
@ -391,7 +391,7 @@ class Reports(commands.Cog):
async def response(self, ctx, ticket_number: int): async def response(self, ctx, ticket_number: int):
"""Open a message tunnel. """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. to the ticket opener's direct messages.
Tunnels do not persist across bot restarts. Tunnels do not persist across bot restarts.

View File

@ -1,6 +1,7 @@
from redbot.core.bot import Red
from .streams import Streams from .streams import Streams
def setup(bot): async def setup(bot: Red) -> None:
cog = Streams(bot) await bot.add_cog(Streams(bot))
bot.add_cog(cog)

View File

@ -79,9 +79,6 @@ class Streams(commands.Cog):
self.yt_cid_pattern = re.compile("^UC[-_A-Za-z0-9]{21}[AQgw]$") 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): async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete""" """Nothing to delete"""
return return
@ -92,10 +89,8 @@ class Streams(commands.Cog):
return True return True
return False return False
async def initialize(self) -> None: async def cog_load(self) -> None:
"""Should be called straight after cog instantiation.""" """Should be called straight after cog instantiation."""
await self.bot.wait_until_ready()
try: try:
await self.move_api_keys() await self.move_api_keys()
await self.get_twitch_bearer_token() await self.get_twitch_bearer_token()
@ -104,16 +99,11 @@ class Streams(commands.Cog):
except Exception as error: except Exception as error:
log.exception("Failed to initialize Streams cog:", exc_info=error) log.exception("Failed to initialize Streams cog:", exc_info=error)
self._ready_event.set()
@commands.Cog.listener() @commands.Cog.listener()
async def on_red_api_tokens_update(self, service_name, api_tokens): async def on_red_api_tokens_update(self, service_name, api_tokens):
if service_name == "twitch": if service_name == "twitch":
await self.get_twitch_bearer_token(api_tokens) 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: async def move_api_keys(self) -> None:
"""Move the API keys from cog stored config to core bot config if they exist.""" """Move the API keys from cog stored config to core bot config if they exist."""
tokens = await self.config.tokens() 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.bot.set_shared_api_tokens("twitch", client_id=token)
await self.config.tokens.clear() 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(
_("<your_client_id_here>"), _("<your_client_secret_here>")
)
),
)
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: async def get_twitch_bearer_token(self, api_tokens: Optional[Dict] = None) -> None:
tokens = ( tokens = (
await self.bot.get_shared_api_tokens("twitch") if api_tokens is None else api_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: if notified_owner_missing_twitch_secret is True:
await self.config.notified_owner_missing_twitch_secret.set(False) await self.config.notified_owner_missing_twitch_secret.set(False)
except KeyError: 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(
_("<your_client_id_here>"), _("<your_client_secret_here>")
)
),
)
if notified_owner_missing_twitch_secret is False: if notified_owner_missing_twitch_secret is False:
await send_to_owners_with_prefix_replaced(self.bot, message) asyncio.create_task(self._notify_owner_about_missing_twitch_secret())
await self.config.notified_owner_missing_twitch_secret.set(True)
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post( async with session.post(
"https://id.twitch.tv/oauth2/token", "https://id.twitch.tv/oauth2/token",
@ -391,6 +384,9 @@ class Streams(commands.Cog):
await ctx.send(page) await ctx.send(page)
async def stream_alert(self, ctx: commands.Context, _class, channel_name): 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) stream = self.get_stream(_class, channel_name)
if not stream: if not stream:
token = await self.bot.get_shared_api_tokens(_class.token_name) token = await self.bot.get_shared_api_tokens(_class.token_name)

View File

@ -1,10 +1,11 @@
"""Package for Trivia cog.""" """Package for Trivia cog."""
from redbot.core.bot import Red
from .trivia import * from .trivia import *
from .session import * from .session import *
from .log import * from .log import *
def setup(bot): async def setup(bot: Red) -> None:
"""Load Trivia.""" """Load Trivia."""
cog = Trivia() await bot.add_cog(Trivia(bot))
bot.add_cog(cog)

View File

@ -249,7 +249,9 @@ class TriviaSession:
answers = tuple(s.lower() for s in answers) answers = tuple(s.lower() for s in answers)
def _pred(message: discord.Message): 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: if early_exit:
return False return False

View File

@ -3,7 +3,7 @@ import asyncio
import math import math
import pathlib import pathlib
from collections import Counter 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 from schema import Schema, Optional, Or, SchemaError
import io import io
@ -11,9 +11,10 @@ import yaml
import discord import discord
from redbot.core import Config, commands, checks, bank 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.data_manager import cog_data_path
from redbot.core.i18n import Translator, cog_i18n 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.chat_formatting import box, pagify, bold
from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.menus import start_adding_reactions
from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate
@ -54,8 +55,9 @@ class InvalidListError(Exception):
class Trivia(commands.Cog): class Trivia(commands.Cog):
"""Play trivia with friends!""" """Play trivia with friends!"""
def __init__(self): def __init__(self, bot: Red) -> None:
super().__init__() super().__init__()
self.bot = bot
self.trivia_sessions = [] self.trivia_sessions = []
self.config = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True) self.config = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True)
@ -672,7 +674,7 @@ class Trivia(commands.Cog):
filename=filename 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: if not can_react:
overwrite_message += " (yes/no)" overwrite_message += " (yes/no)"
@ -706,7 +708,9 @@ class Trivia(commands.Cog):
fp.write(buffer.read()) fp.write(buffer.read())
await ctx.send(_("Saved Trivia list as {filename}.").format(filename=filename)) 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( return next(
(session for session in self.trivia_sessions if session.ctx.channel == channel), None (session for session in self.trivia_sessions if session.ctx.channel == channel), None
) )

View File

@ -1,5 +1,7 @@
from redbot.core.bot import Red
from .warnings import Warnings from .warnings import Warnings
def setup(bot): async def setup(bot: Red) -> None:
bot.add_cog(Warnings(bot)) await bot.add_cog(Warnings(bot))

View File

@ -47,7 +47,9 @@ class Warnings(commands.Cog):
self.config.register_guild(**self.default_guild) self.config.register_guild(**self.default_guild)
self.config.register_member(**self.default_member) self.config.register_member(**self.default_member)
self.bot = bot 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( async def red_delete_data_for_user(
self, self,
@ -508,7 +510,7 @@ class Warnings(commands.Cog):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
ctx.guild, ctx.guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"warning", "warning",
member, member,
ctx.message.author, ctx.message.author,
@ -632,7 +634,7 @@ class Warnings(commands.Cog):
await modlog.create_case( await modlog.create_case(
self.bot, self.bot,
ctx.guild, ctx.guild,
ctx.message.created_at.replace(tzinfo=timezone.utc), ctx.message.created_at,
"unwarned", "unwarned",
member, member,
ctx.message.author, ctx.message.author,

View File

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Awaitable, Callable, Iterable, List, Optional,
import discord import discord
from redbot.core import commands from redbot.core import commands
from redbot.core.i18n import Translator from redbot.core.i18n import Translator
from redbot.core.utils import can_user_send_messages_in
from redbot.core.utils.chat_formatting import ( from redbot.core.utils.chat_formatting import (
bold, bold,
escape, escape,
@ -37,7 +38,7 @@ class IssueDiagnoserBase:
self, self,
bot: Red, bot: Red,
original_ctx: commands.Context, original_ctx: commands.Context,
channel: discord.TextChannel, channel: Union[discord.TextChannel, discord.Thread],
author: discord.Member, author: discord.Member,
command: commands.Command, command: commands.Command,
) -> None: ) -> None:
@ -59,6 +60,7 @@ class IssueDiagnoserBase:
self.message.channel = self.channel self.message.channel = self.channel
self.message.content = self._original_ctx.prefix + self.command.qualified_name self.message.content = self._original_ctx.prefix + self.command.qualified_name
# clear the cached properties # clear the cached properties
# DEP-WARN
for attr in self.message._CACHED_SLOTS: # type: ignore[attr-defined] for attr in self.message._CACHED_SLOTS: # type: ignore[attr-defined]
try: try:
delattr(self.message, attr) delattr(self.message, attr)
@ -117,18 +119,27 @@ class DetailedGlobalCallOnceChecksMixin(IssueDiagnoserBase):
async def _check_can_bot_send_messages(self) -> CheckResult: async def _check_can_bot_send_messages(self) -> CheckResult:
label = _("Check if the bot can send messages in the given channel") label = _("Check if the bot can send messages in the given channel")
if self.channel.permissions_for(self.guild.me).send_messages: # This is checked by send messages check but this allows us to
return CheckResult(True, label) # give more detailed information.
return CheckResult( if not self.guild.me.guild_permissions.administrator and self.guild.me.is_timed_out():
False, return CheckResult(
label, False,
_("Bot doesn't have permission to send messages in the given channel."), label,
_( _("Bot is timed out in the given channel."),
"To fix this issue, ensure that the permissions setup allows the bot" _("To fix this issue, remove timeout from the bot."),
" to send messages per Discord's role hierarchy:\n" )
"https://support.discord.com/hc/en-us/articles/206141927" 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, # 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 # it would require a usage of private attribute rather than the public API
@ -139,24 +150,47 @@ class DetailedGlobalCallOnceChecksMixin(IssueDiagnoserBase):
return CheckResult(True, label) return CheckResult(True, label)
if self.channel.category is None: if self.channel.category is None:
resolution = _( if isinstance(self.channel, discord.Thread):
"To fix this issue, check the list returned by the {command} command" resolution = _(
" and ensure that the {channel} channel and the server aren't a part of that list." "To fix this issue, check the list returned by the {command} command"
).format( " and ensure that the {thread} thread, its parent channel,"
command=self._format_command_name("ignore list"), " and the server aren't a part of that list."
channel=self.channel.mention, ).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: else:
resolution = _( if isinstance(self.channel, discord.Thread):
"To fix this issue, check the list returned by the {command} command" resolution = _(
" and ensure that the {channel} channel," "To fix this issue, check the list returned by the {command} command"
" the channel category it belongs to ({channel_category})," " and ensure that the {thread} thread, its parent channel,"
" and the server aren't a part of that list." " the channel category it belongs to ({channel_category}),"
).format( " and the server aren't a part of that list."
command=self._format_command_name("ignore list"), ).format(
channel=self.channel.mention, command=self._format_command_name("ignore list"),
channel_category=self.channel.category.mention, 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( return CheckResult(
False, False,

View File

@ -51,7 +51,7 @@ from .settings_caches import (
I18nManager, I18nManager,
) )
from .rpc import RPCMixin 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 from .utils._internal_utils import send_to_owners_with_prefix_replaced
CUSTOM_GROUPS = "CUSTOM_GROUPS" CUSTOM_GROUPS = "CUSTOM_GROUPS"
@ -375,12 +375,12 @@ class Red(
return return
del dev.env_extensions[name] 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) com = super().get_command(name)
assert com is None or isinstance(com, commands.Command) assert com is None or isinstance(com, commands.Command)
return com 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) cog = super().get_cog(name)
assert cog is None or isinstance(cog, commands.Cog) assert cog is None or isinstance(cog, commands.Cog)
return cog return cog
@ -444,7 +444,7 @@ class Red(
""" """
self._red_before_invoke_objs.discard(coro) 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. Overridden decorator method for Red's ``before_invoke`` behavior.
@ -809,9 +809,14 @@ class Red(
if message.author.bot: if message.author.bot:
return False 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: if guild:
assert isinstance(channel, discord.abc.GuildChannel) # nosec assert isinstance(channel, (discord.abc.GuildChannel, discord.Thread))
if not channel.permissions_for(guild.me).send_messages: if not can_user_send_messages_in(guild.me, channel):
return False return False
if not (await self.ignored_channel_or_guild(message)): if not (await self.ignored_channel_or_guild(message)):
return False return False
@ -838,7 +843,14 @@ class Red(
------- -------
bool bool
`True` if commands are allowed in the channel, `False` otherwise `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) perms = ctx.channel.permissions_for(ctx.author)
surpass_ignore = ( surpass_ignore = (
isinstance(ctx.channel, discord.abc.PrivateChannel) isinstance(ctx.channel, discord.abc.PrivateChannel)
@ -846,11 +858,38 @@ class Red(
or await self.is_owner(ctx.author) or await self.is_owner(ctx.author)
or await self.is_admin(ctx.author) or await self.is_admin(ctx.author)
) )
# guild-wide checks
if surpass_ignore: if surpass_ignore:
return True return True
guild_ignored = await self._ignored_cache.get_ignored_guild(ctx.guild) guild_ignored = await self._ignored_cache.get_ignored_guild(ctx.guild)
chann_ignored = await self._ignored_cache.get_ignored_channel(ctx.channel) if guild_ignored:
return not (guild_ignored or chann_ignored and not perms.manage_channels) 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]: 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. This should only be run once, prior to connecting to Discord gateway.
""" """
self.add_cog(Core(self)) await self.add_cog(Core(self))
self.add_cog(CogManagerUI()) await self.add_cog(CogManagerUI())
if self._cli_flags.dev: if self._cli_flags.dev:
self.add_cog(Dev()) await self.add_cog(Dev())
await modlog._init(self) await modlog._init(self)
await bank._init() await bank._init()
@ -1179,15 +1218,16 @@ class Red(
if not self.owner_ids: if not self.owner_ids:
raise _NoOwnerSet("Bot doesn't have any owner set!") raise _NoOwnerSet("Bot doesn't have any owner set!")
async def start(self, *args, **kwargs): async def start(self, token: str) -> None:
""" # Overriding start to call _pre_login() before login()
Overridden start which ensures that cog load and other pre-connection tasks are handled.
"""
await self._pre_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_fetch_owners()
await self._pre_connect() await self._pre_connect()
await self.connect()
async def send_help_for( async def send_help_for(
self, self,
@ -1205,7 +1245,9 @@ class Red(
async def embed_requested( async def embed_requested(
self, 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, command: Optional[commands.Command] = None,
check_permissions: bool = True, check_permissions: bool = True,
@ -1215,7 +1257,7 @@ class Red(
Arguments 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. The target messageable object to check embed settings for.
Keyword Arguments Keyword Arguments
@ -1236,9 +1278,8 @@ class Red(
Raises Raises
------ ------
TypeError TypeError
When the passed channel is of type `discord.GroupChannel` When the passed channel is of type `discord.GroupChannel`,
or `discord.DMChannel` `discord.DMChannel`, or `discord.PartialMessageable`.
""" """
async def get_command_setting(guild_id: int) -> Optional[bool]: 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) scope = self._config.custom(COMMAND_SCOPE, command.qualified_name, guild_id)
return await scope.embeds() 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 # using dpy_commands.Context to keep the Messageable contract in full
if isinstance(channel, dpy_commands.Context): if isinstance(channel, dpy_commands.Context):
command = command or channel.command command = command or channel.command
@ -1259,11 +1297,21 @@ class Red(
else channel.channel 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: if check_permissions and not channel.permissions_for(channel.guild.me).embed_links:
return False 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 return channel_setting
if (command_setting := await get_command_setting(channel.guild.id)) is not None: 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() global_setting = await self._config.embeds()
return global_setting 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. Determines if the user should be considered a bot owner.
@ -1317,10 +1365,10 @@ class Red(
""" """
data = await self._config.all() data = await self._config.all()
commands_scope = data["invite_commands_scope"] 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"] perms_int = data["invite_perm"]
permissions = discord.Permissions(perms_int) 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: async def is_invite_url_public(self) -> bool:
""" """
@ -1336,9 +1384,8 @@ class Red(
async def is_admin(self, member: discord.Member) -> bool: async def is_admin(self, member: discord.Member) -> bool:
"""Checks if a member is an admin of their guild.""" """Checks if a member is an admin of their guild."""
try: try:
member_snowflakes = member._roles # DEP-WARN
for snowflake in await self._config.guild(member.guild).admin_role(): 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 return True
except AttributeError: # someone passed a webhook to this except AttributeError: # someone passed a webhook to this
pass pass
@ -1347,12 +1394,11 @@ class Red(
async def is_mod(self, member: discord.Member) -> bool: async def is_mod(self, member: discord.Member) -> bool:
"""Checks if a member is a mod or admin of their guild.""" """Checks if a member is a mod or admin of their guild."""
try: try:
member_snowflakes = member._roles # DEP-WARN
for snowflake in await self._config.guild(member.guild).admin_role(): 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 return True
for snowflake in await self._config.guild(member.guild).mod_role(): 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 return True
except AttributeError: # someone passed a webhook to this except AttributeError: # someone passed a webhook to this
pass pass
@ -1495,10 +1541,10 @@ class Red(
for service in service_names: for service in service_names:
self.dispatch("red_api_tokens_update", service, MappingProxyType({})) 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) 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 Same as base method, but dispatches an additional event for cogs
which want to handle normal messages differently to command which want to handle normal messages differently to command
@ -1507,7 +1553,14 @@ class Red(
""" """
if not message.author.bot: if not message.author.bot:
ctx = await self.get_context(message) 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: else:
ctx = None ctx = None
@ -1544,18 +1597,23 @@ class Red(
raise discord.ClientException(f"extension {name} does not have a setup function") raise discord.ClientException(f"extension {name} does not have a setup function")
try: try:
if asyncio.iscoroutinefunction(lib.setup): await lib.setup(self)
await lib.setup(self)
else:
lib.setup(self)
except Exception as e: except Exception as e:
self._remove_module_references(lib.__name__) await self._remove_module_references(lib.__name__)
self._call_module_finalizers(lib, name) await self._call_module_finalizers(lib, name)
raise raise
else: else:
self._BotBase__extensions[name] = lib 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) cog = self.get_cog(cogname)
if cog is None: if cog is None:
return return
@ -1568,13 +1626,15 @@ class Red(
else: else:
self.remove_permissions_hook(hook) self.remove_permissions_hook(hook)
super().remove_cog(cogname) await super().remove_cog(cogname, guild=guild, guilds=guilds)
cog.requires.reset() cog.requires.reset()
for meth in self.rpc_handlers.pop(cogname.upper(), ()): for meth in self.rpc_handlers.pop(cogname.upper(), ()):
self.unregister_rpc_handler(meth) self.unregister_rpc_handler(meth)
return cog
async def is_automod_immune( async def is_automod_immune(
self, to_check: Union[discord.Message, commands.Context, discord.abc.User, discord.Role] self, to_check: Union[discord.Message, commands.Context, discord.abc.User, discord.Role]
) -> bool: ) -> bool:
@ -1656,15 +1716,28 @@ class Red(
return await destination.send(content=content, **kwargs) 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): if not isinstance(cog, commands.Cog):
raise RuntimeError( raise RuntimeError(
f"The {cog.__class__.__name__} cog in the {cog.__module__} package does " 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"not inherit from the commands.Cog base class. The cog author must update "
f"the cog to adhere to this requirement." f"the cog to adhere to this requirement."
) )
if cog.__cog_name__ in self.cogs: cog_name = cog.__cog_name__
raise RuntimeError(f"There is already a cog named {cog.__cog_name__} loaded.") 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"): if not hasattr(cog, "requires"):
commands.Cog.__init__(cog) commands.Cog.__init__(cog)
@ -1680,7 +1753,7 @@ class Red(
self.add_permissions_hook(hook) self.add_permissions_hook(hook)
added_hooks.append(hook) added_hooks.append(hook)
super().add_cog(cog) await super().add_cog(cog, guild=guild, guilds=guilds)
self.dispatch("cog_add", cog) self.dispatch("cog_add", cog)
if "permissions" not in self.extensions: if "permissions" not in self.extensions:
cog.requires.ready_event.set() cog.requires.ready_event.set()
@ -1697,7 +1770,7 @@ class Red(
del cog del cog
raise raise
def add_command(self, command: commands.Command) -> None: def add_command(self, command: commands.Command, /) -> None:
if not isinstance(command, commands.Command): if not isinstance(command, commands.Command):
raise RuntimeError("Commands must be instances of `redbot.core.commands.Command`") raise RuntimeError("Commands must be instances of `redbot.core.commands.Command`")
@ -1713,7 +1786,7 @@ class Red(
if permissions_not_loaded: if permissions_not_loaded:
subcommand.requires.ready_event.set() 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) command = super().remove_command(name)
if command is None: if command is None:
return None return None
@ -1802,7 +1875,9 @@ class Red(
ctx.permission_state = commands.PermState.DENIED_BY_HOOK ctx.permission_state = commands.PermState.DENIED_BY_HOOK
return False 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 Gets the users and channels to send to
""" """

View File

@ -29,13 +29,11 @@ from .converter import (
parse_timedelta as parse_timedelta, parse_timedelta as parse_timedelta,
NoParseOptional as NoParseOptional, NoParseOptional as NoParseOptional,
UserInputOptional as UserInputOptional, UserInputOptional as UserInputOptional,
Literal as Literal,
RawUserIdConverter as RawUserIdConverter, RawUserIdConverter as RawUserIdConverter,
CogConverter as CogConverter, CogConverter as CogConverter,
CommandConverter as CommandConverter, CommandConverter as CommandConverter,
) )
from .errors import ( from .errors import (
ConversionFailure as ConversionFailure,
BotMissingPermissions as BotMissingPermissions, BotMissingPermissions as BotMissingPermissions,
UserFeedbackCheckFailure as UserFeedbackCheckFailure, UserFeedbackCheckFailure as UserFeedbackCheckFailure,
ArgParserFailure as ArgParserFailure, ArgParserFailure as ArgParserFailure,
@ -57,33 +55,23 @@ from .requires import (
permissions_check as permissions_check, permissions_check as permissions_check,
bot_has_permissions as bot_has_permissions, bot_has_permissions as bot_has_permissions,
bot_in_a_guild as bot_in_a_guild, 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, has_permissions as has_permissions,
can_manage_channel as can_manage_channel,
has_guild_permissions as has_guild_permissions, has_guild_permissions as has_guild_permissions,
is_owner as is_owner, is_owner as is_owner,
guildowner as guildowner, guildowner as guildowner,
guildowner_or_can_manage_channel as guildowner_or_can_manage_channel,
guildowner_or_permissions as guildowner_or_permissions, guildowner_or_permissions as guildowner_or_permissions,
admin as admin, admin as admin,
admin_or_can_manage_channel as admin_or_can_manage_channel,
admin_or_permissions as admin_or_permissions, admin_or_permissions as admin_or_permissions,
mod as mod, mod as mod,
mod_or_can_manage_channel as mod_or_can_manage_channel,
mod_or_permissions as mod_or_permissions, 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 ### DEP-WARN: Check this *every* discord.py update
from discord.ext.commands import ( from discord.ext.commands import (
BadArgument as BadArgument, BadArgument as BadArgument,
@ -137,7 +125,6 @@ from discord.ext.commands import (
ColorConverter as ColorConverter, ColorConverter as ColorConverter,
VoiceChannelConverter as VoiceChannelConverter, VoiceChannelConverter as VoiceChannelConverter,
StageChannelConverter as StageChannelConverter, StageChannelConverter as StageChannelConverter,
StoreChannelConverter as StoreChannelConverter,
NSFWChannelRequired as NSFWChannelRequired, NSFWChannelRequired as NSFWChannelRequired,
IDConverter as IDConverter, IDConverter as IDConverter,
MissingRequiredArgument as MissingRequiredArgument, MissingRequiredArgument as MissingRequiredArgument,
@ -167,4 +154,39 @@ from discord.ext.commands import (
EmojiNotFound as EmojiNotFound, EmojiNotFound as EmojiNotFound,
PartialEmojiConversionFailure as PartialEmojiConversionFailure, PartialEmojiConversionFailure as PartialEmojiConversionFailure,
BadBoolArgument as BadBoolArgument, 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,
) )

View File

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

View File

@ -285,13 +285,6 @@ class Command(CogCommandMixin, DPYCommand):
(type used will be of the inner type instead) (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): def __init__(self, *args, **kwargs):
self.ignore_optional_for_conversion = kwargs.pop("ignore_optional_for_conversion", False) self.ignore_optional_for_conversion = kwargs.pop("ignore_optional_for_conversion", False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -323,60 +316,27 @@ class Command(CogCommandMixin, DPYCommand):
@callback.setter @callback.setter
def callback(self, function): 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 _NoneType = type(None)
- 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
for key, value in self.params.items(): for key, value in self.params.items():
if isinstance(value.annotation, str): origin = getattr(value.annotation, "__origin__", None)
self.params[key] = value = value.replace( if origin is not Union:
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:
continue 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 @property
def help(self): def help(self):
@ -420,6 +380,7 @@ class Command(CogCommandMixin, DPYCommand):
async def can_run( async def can_run(
self, self,
ctx: "Context", ctx: "Context",
/,
*, *,
check_all_parents: bool = False, check_all_parents: bool = False,
change_permission_state: bool = False, change_permission_state: bool = False,
@ -476,7 +437,7 @@ class Command(CogCommandMixin, DPYCommand):
if not change_permission_state: if not change_permission_state:
ctx.permission_state = original_state ctx.permission_state = original_state
async def prepare(self, ctx): async def prepare(self, ctx, /):
ctx.command = self ctx.command = self
if not self.enabled: if not self.enabled:
@ -502,39 +463,6 @@ class Command(CogCommandMixin, DPYCommand):
await self._max_concurrency.release(ctx) await self._max_concurrency.release(ctx)
raise 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"): async def can_see(self, ctx: "Context"):
"""Check if this command is visible in the given context. """Check if this command is visible in the given context.
@ -636,7 +564,7 @@ class Command(CogCommandMixin, DPYCommand):
break break
return old_rule, new_rule return old_rule, new_rule
def error(self, coro): def error(self, coro, /):
""" """
A decorator that registers a coroutine as a local error handler. 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) self.autohelp = kwargs.pop("autohelp", True)
super().__init__(*args, **kwargs) 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 skip prepare in some cases to avoid some things
# We still always want this part of the behavior though # We still always want this part of the behavior though
ctx.command = self ctx.command = self
@ -971,7 +899,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
""" """
raise RedUnhandledAPI() 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 This really just exists to allow easy use with other methods using can_run
on commands and groups such as help formatters. on commands and groups such as help formatters.
@ -999,7 +927,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
return can_run 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. """Check if this cog is visible in the given context.
In short, this will verify whether In short, this will verify whether
@ -1112,7 +1040,7 @@ class _AlwaysAvailableMixin:
This particular class is not supported for 3rd party use 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 return not ctx.author.bot
can_see = can_run can_see = can_run
@ -1161,7 +1089,7 @@ class _ForgetMeSpecialCommand(_RuleDropper, Command):
We need special can_run behavior here 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() return await ctx.bot._config.datarequests.allow_user_requests()
can_see = can_run can_see = can_run

View File

@ -11,7 +11,7 @@ from discord.ext.commands import Context as DPYContext
from .requires import PermState from .requires import PermState
from ..utils.chat_formatting import box from ..utils.chat_formatting import box
from ..utils.predicates import MessagePredicate from ..utils.predicates import MessagePredicate
from ..utils import common_filters from ..utils import can_user_react_in, common_filters
if TYPE_CHECKING: if TYPE_CHECKING:
from .commands import Command from .commands import Command
@ -139,7 +139,7 @@ class Context(DPYContext):
:code:`True` if adding the reaction succeeded. :code:`True` if adding the reaction succeeded.
""" """
try: try:
if not self.channel.permissions_for(self.me).add_reactions: if not can_user_react_in(self.me, self.channel):
raise RuntimeError raise RuntimeError
await self.message.add_reaction(reaction) await self.message.add_reaction(reaction)
except (RuntimeError, discord.HTTPException): except (RuntimeError, discord.HTTPException):
@ -283,16 +283,6 @@ class Context(DPYContext):
allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False), 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 @property
def me(self) -> Union[discord.ClientUser, discord.Member]: def me(self) -> Union[discord.ClientUser, discord.Member]:
""" """
@ -349,7 +339,7 @@ if TYPE_CHECKING or os.getenv("BUILDING_DOCS", False):
... ...
@property @property
def channel(self) -> discord.TextChannel: def channel(self) -> Union[discord.TextChannel, discord.Thread]:
... ...
@property @property

View File

@ -19,7 +19,6 @@ from typing import (
Dict, Dict,
Type, Type,
TypeVar, TypeVar,
Literal as Literal,
Union as UserInputOptional, Union as UserInputOptional,
) )
@ -44,7 +43,6 @@ __all__ = [
"get_timedelta_converter", "get_timedelta_converter",
"parse_relativedelta", "parse_relativedelta",
"parse_timedelta", "parse_timedelta",
"Literal",
"CommandConverter", "CommandConverter",
"CogConverter", "CogConverter",
] ]
@ -281,7 +279,7 @@ else:
Returns a typechecking safe `DictConverter` suitable for use with discord.py Returns a typechecking safe `DictConverter` suitable for use with discord.py
""" """
class PartialMeta(type): class PartialMeta(type(DictConverter)):
__call__ = functools.partialmethod( __call__ = functools.partialmethod(
type(DictConverter).__call__, *expected_keys, delims=delims type(DictConverter).__call__, *expected_keys, delims=delims
) )
@ -389,7 +387,7 @@ else:
The converter class, which will be a subclass of `TimedeltaConverter` The converter class, which will be a subclass of `TimedeltaConverter`
""" """
class PartialMeta(type): class PartialMeta(type(DictConverter)):
__call__ = functools.partialmethod( __call__ = functools.partialmethod(
type(DictConverter).__call__, type(DictConverter).__call__,
allowed_units=allowed_units, allowed_units=allowed_units,
@ -475,44 +473,6 @@ if not TYPE_CHECKING:
#: This converter class is still provisional. #: This converter class is still provisional.
UserInputOptional = Optional 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: if TYPE_CHECKING:
CommandConverter = dpy_commands.Command CommandConverter = dpy_commands.Command
CogConverter = dpy_commands.Cog CogConverter = dpy_commands.Cog

View File

@ -3,12 +3,11 @@ import inspect
import discord import discord
from discord.ext import commands from discord.ext import commands
__all__ = [ __all__ = (
"ConversionFailure",
"BotMissingPermissions", "BotMissingPermissions",
"UserFeedbackCheckFailure", "UserFeedbackCheckFailure",
"ArgParserFailure", "ArgParserFailure",
] )
class ConversionFailure(commands.BadArgument): class ConversionFailure(commands.BadArgument):

View File

@ -39,7 +39,7 @@ from discord.ext import commands as dpy_commands
from . import commands from . import commands
from .context import Context from .context import Context
from ..i18n import Translator from ..i18n import Translator
from ..utils import menus from ..utils import can_user_react_in, menus
from ..utils.mod import mass_purge from ..utils.mod import mass_purge
from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results
from ..utils.chat_formatting import ( from ..utils.chat_formatting import (
@ -478,7 +478,7 @@ class RedHelpFormatter(HelpFormatterABC):
author_info = { author_info = {
"name": _("{ctx.me.display_name} Help Menu").format(ctx=ctx), "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 # Offset calculation here is for total embed size limit
@ -733,7 +733,7 @@ class RedHelpFormatter(HelpFormatterABC):
if use_embeds: if use_embeds:
ret.set_author( ret.set_author(
name=_("{ctx.me.display_name} Help Menu").format(ctx=ctx), 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) tagline = help_settings.tagline or self.get_default_tagline(ctx)
ret.set_footer(text=tagline) ret.set_footer(text=tagline)
@ -746,7 +746,7 @@ class RedHelpFormatter(HelpFormatterABC):
ret = discord.Embed(color=(await ctx.embed_color()), description=ret) ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
ret.set_author( ret.set_author(
name=_("{ctx.me.display_name} Help Menu").format(ctx=ctx), 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) tagline = help_settings.tagline or self.get_default_tagline(ctx)
ret.set_footer(text=tagline) ret.set_footer(text=tagline)
@ -765,7 +765,7 @@ class RedHelpFormatter(HelpFormatterABC):
ret = discord.Embed(color=(await ctx.embed_color()), description=ret) ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
ret.set_author( ret.set_author(
name=_("{ctx.me.display_name} Help Menu").format(ctx=ctx), 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) tagline = help_settings.tagline or self.get_default_tagline(ctx)
ret.set_footer(text=tagline) ret.set_footer(text=tagline)
@ -813,15 +813,7 @@ class RedHelpFormatter(HelpFormatterABC):
""" """
Sends pages based on settings. Sends pages based on settings.
""" """
if not (can_user_react_in(ctx.me, ctx.channel) and help_settings.use_menus):
# 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
):
max_pages_in_guild = help_settings.max_pages_in_guild max_pages_in_guild = help_settings.max_pages_in_guild
use_DMs = len(pages) > max_pages_in_guild use_DMs = len(pages) > max_pages_in_guild
destination = ctx.author if use_DMs else ctx.channel destination = ctx.author if use_DMs else ctx.channel
@ -846,17 +838,18 @@ class RedHelpFormatter(HelpFormatterABC):
if use_DMs and help_settings.use_tick: if use_DMs and help_settings.use_tick:
await ctx.tick() await ctx.tick()
# The if statement takes into account that 'destination' will be # The if statement takes into account that 'destination' will be
# the context channel in non-DM context, reusing 'channel_permissions' to avoid # the context channel in non-DM context.
# computing the permissions twice.
if ( if (
not use_DMs # we're not in DMs not use_DMs # we're not in DMs
and delete_delay > 0 # delete delay is enabled 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. # 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( 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 asyncio.sleep(delay)
await mass_purge(messages, channel) await mass_purge(messages, channel)

View File

@ -30,6 +30,8 @@ import discord
from discord.ext.commands import check from discord.ext.commands import check
from .errors import BotMissingPermissions from .errors import BotMissingPermissions
from redbot.core import utils
if TYPE_CHECKING: if TYPE_CHECKING:
from .commands import Command from .commands import Command
from .context import Context from .context import Context
@ -48,14 +50,20 @@ __all__ = [
"permissions_check", "permissions_check",
"bot_has_permissions", "bot_has_permissions",
"bot_in_a_guild", "bot_in_a_guild",
"bot_can_manage_channel",
"bot_can_react",
"has_permissions", "has_permissions",
"can_manage_channel",
"has_guild_permissions", "has_guild_permissions",
"is_owner", "is_owner",
"guildowner", "guildowner",
"guildowner_or_can_manage_channel",
"guildowner_or_permissions", "guildowner_or_permissions",
"admin", "admin",
"admin_or_can_manage_channel",
"admin_or_permissions", "admin_or_permissions",
"mod", "mod",
"mod_or_can_manage_channel",
"mod_or_permissions", "mod_or_permissions",
"transition_permstate_to", "transition_permstate_to",
"PermStateTransitions", "PermStateTransitions",
@ -135,12 +143,11 @@ class PrivilegeLevel(enum.IntEnum):
# admin or mod role. # admin or mod role.
guild_settings = ctx.bot._config.guild(ctx.guild) guild_settings = ctx.bot._config.guild(ctx.guild)
member_snowflakes = ctx.author._roles # DEP-WARN
for snowflake in await guild_settings.admin_role(): for snowflake in await guild_settings.admin_role():
if member_snowflakes.has(snowflake): # DEP-WARN if ctx.author.get_role(snowflake):
return cls.ADMIN return cls.ADMIN
for snowflake in await guild_settings.mod_role(): 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.MOD
return cls.NONE return cls.NONE
@ -596,7 +603,10 @@ class Requires:
channels = [] channels = []
if author.voice is not None: if author.voice is not None:
channels.append(author.voice.channel) 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 category = ctx.channel.category
if category is not None: if category is not None:
channels.append(category) channels.append(category)
@ -731,6 +741,77 @@ def bot_in_a_guild():
return check(predicate) 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): def has_permissions(**perms: bool):
"""Restrict the command to users with these permissions. """Restrict the command to users with these permissions.
@ -741,6 +822,24 @@ def has_permissions(**perms: bool):
return Requires.get_decorator(None, perms) 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(): def is_owner():
"""Restrict the command to bot owners. """Restrict the command to bot owners.
@ -757,6 +856,24 @@ def guildowner_or_permissions(**perms: bool):
return Requires.get_decorator(PrivilegeLevel.GUILD_OWNER, perms) 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(): def guildowner():
"""Restrict the command to the guild owner. """Restrict the command to the guild owner.
@ -773,6 +890,24 @@ def admin_or_permissions(**perms: bool):
return Requires.get_decorator(PrivilegeLevel.ADMIN, perms) 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(): def admin():
"""Restrict the command to users with the admin role. """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) 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(): def mod():
"""Restrict the command to users with the mod role. """Restrict the command to users with the mod role.

View File

@ -1009,14 +1009,14 @@ class Config(metaclass=ConfigMeta):
) )
return self._get_base_group(self.CHANNEL, str(channel_id)) 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. """Returns a `Group` for the given channel.
This does not discriminate between text and voice channels. This does not discriminate between text and voice channels.
Parameters Parameters
---------- ----------
channel : `discord.abc.GuildChannel` channel : `discord.abc.GuildChannel` or `discord.Thread`
A channel object. A channel object.
Returns Returns

View File

@ -39,7 +39,7 @@ from . import (
modlog, modlog,
) )
from ._diagnoser import IssueDiagnoser 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._internal_utils import fetch_latest_red_version_info
from .utils.predicates import MessagePredicate from .utils.predicates import MessagePredicate
from .utils.chat_formatting import ( from .utils.chat_formatting import (
@ -275,7 +275,7 @@ class CoreLogic:
for name in pkg_names: for name in pkg_names:
if name in bot.extensions: if name in bot.extensions:
bot.unload_extension(name) await bot.unload_extension(name)
await bot.remove_loaded_package(name) await bot.remove_loaded_package(name)
unloaded_packages.append(name) unloaded_packages.append(name)
else: else:
@ -318,7 +318,7 @@ class CoreLogic:
The current (or new) username of the bot. The current (or new) username of the bot.
""" """
if name is not None: 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 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.") uptime_str = humanize_timedelta(timedelta=delta) or _("Less than one second.")
await ctx.send( await ctx.send(
_("I have been up for: **{time_quantity}** (since {timestamp})").format( _("I have been up for: **{time_quantity}** (since {timestamp})").format(
time_quantity=uptime_str, timestamp=f"<t:{int(uptime.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:** **Arguments:**
- `[enabled]` - Whether to use embeds in this channel. Leave blank to reset to default. - `[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: if enabled is None:
await self.bot._config.channel(ctx.channel).embeds.clear() await self.bot._config.channel(ctx.channel).embeds.clear()
await ctx.send(_("Embeds will now fall back to the global setting.")) 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." "must be a valid image in either JPG or PNG format."
) )
) )
except discord.InvalidArgument: except ValueError:
await ctx.send(_("JPG / PNG format only.")) await ctx.send(_("JPG / PNG format only."))
else: else:
await ctx.send(_("Done.")) await ctx.send(_("Done."))
@ -3211,7 +3220,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
@_set_ownernotifications.command(name="adddestination") @_set_ownernotifications.command(name="adddestination")
async def _set_ownernotifications_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. Adds a destination text channel to receive owner notifications.
@ -3223,15 +3232,9 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
**Arguments:** **Arguments:**
- `<channel>` - The channel to send owner notifications to. - `<channel>` - 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: async with ctx.bot._config.extra_owner_destinations() as extras:
if channel_id not in extras: if channel.id not in extras:
extras.append(channel_id) extras.append(channel.id)
await ctx.tick() await ctx.tick()
@ -3982,12 +3985,8 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
color = await ctx.bot.get_embed_color(destination) color = await ctx.bot.get_embed_color(destination)
e = discord.Embed(colour=color, description=message) e = discord.Embed(colour=color, description=message)
if author.avatar_url: e.set_author(name=description, icon_url=author.display_avatar)
e.set_author(name=description, icon_url=author.avatar_url) e.set_footer(text=f"{footer}\n{content}")
else:
e.set_author(name=description)
e.set_footer(text="{}\n{}".format(footer, content))
try: try:
await destination.send(embed=e) 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 = discord.Embed(colour=discord.Colour.red(), description=message)
e.set_footer(text=content) e.set_footer(text=content)
if ctx.bot.user.avatar_url: e.set_author(name=description, icon_url=ctx.bot.user.display_avatar)
e.set_author(name=description, icon_url=ctx.bot.user.avatar_url)
else:
e.set_author(name=description)
try: try:
await destination.send(embed=e) await destination.send(embed=e)
@ -4206,7 +4202,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
async def diagnoseissues( async def diagnoseissues(
self, self,
ctx: commands.Context, ctx: commands.Context,
channel: Optional[discord.TextChannel], channel: Optional[Union[discord.TextChannel, discord.Thread]],
member: Union[discord.Member, discord.User], member: Union[discord.Member, discord.User],
*, *,
command_name: str, command_name: str,
@ -4227,8 +4223,13 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
""" """
if channel is None: if channel is None:
channel = ctx.channel channel = ctx.channel
if not isinstance(channel, discord.TextChannel): if not isinstance(channel, (discord.TextChannel, discord.Thread)):
await ctx.send(_("The channel needs to be passed when using this command in DMs.")) await ctx.send(
_(
"The text channel or thread needs to be passed"
" when using this command in DMs."
)
)
return return
command = self.bot.get_command(command_name) command = self.bot.get_command(command_name)
@ -4245,7 +4246,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
return return
member = maybe_member 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 # Let's make Flame happy here
await ctx.send( await ctx.send(
_( _(
@ -5156,7 +5157,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
async def rpc_unload(self, request): async def rpc_unload(self, request):
cog_name = request.params[0] cog_name = request.params[0]
self.bot.unload_extension(cog_name) await self.bot.unload_extension(cog_name)
async def rpc_reload(self, request): async def rpc_reload(self, request):
await self.rpc_unload(request) await self.rpc_unload(request)
@ -5164,7 +5165,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
@commands.group() @commands.group()
@commands.guild_only() @commands.guild_only()
@checks.admin_or_permissions(manage_channels=True) @commands.admin_or_can_manage_channel()
async def ignore(self, ctx: commands.Context): async def ignore(self, ctx: commands.Context):
""" """
Commands to add servers or channels to the ignore list. 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( async def ignore_channel(
self, self,
ctx: commands.Context, 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. 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. - `[p]ignore channel 356236713347252226` - Also accepts IDs.
**Arguments:** **Arguments:**
- `<channel>` - The channel to ignore. Can be a category channel. - `<channel>` - The channel to ignore. This can also be a thread or category channel.
""" """
if not channel: if not channel:
channel = ctx.channel channel = ctx.channel
@ -5235,7 +5238,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
@commands.group() @commands.group()
@commands.guild_only() @commands.guild_only()
@checks.admin_or_permissions(manage_channels=True) @commands.admin_or_can_manage_channel()
async def unignore(self, ctx: commands.Context): async def unignore(self, ctx: commands.Context):
"""Commands to remove servers or channels from the ignore list.""" """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( async def unignore_channel(
self, self,
ctx: commands.Context, 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:** **Examples:**
- `[p]unignore channel #general` - Unignores commands in the #general channel. - `[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. - `[p]unignore channel 356236713347252226` - Also accepts IDs. Use this method to unignore categories.
**Arguments:** **Arguments:**
- `<channel>` - The channel to unignore. This can be a category channel. - `<channel>` - The channel to unignore. This can also be a thread or category channel.
""" """
if not channel: if not channel:
channel = ctx.channel channel = ctx.channel
@ -5287,6 +5292,7 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
async def count_ignored(self, ctx: commands.Context): async def count_ignored(self, ctx: commands.Context):
category_channels: List[discord.CategoryChannel] = [] category_channels: List[discord.CategoryChannel] = []
text_channels: List[discord.TextChannel] = [] text_channels: List[discord.TextChannel] = []
threads: List[discord.Thread] = []
if await self.bot._ignored_cache.get_ignored_guild(ctx.guild): if await self.bot._ignored_cache.get_ignored_guild(ctx.guild):
return _("This server is currently being ignored.") return _("This server is currently being ignored.")
for channel in ctx.guild.text_channels: for channel in ctx.guild.text_channels:
@ -5295,14 +5301,22 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
category_channels.append(channel.category) category_channels.append(channel.category)
if await self.bot._ignored_cache.get_ignored_channel(channel, check_category=False): if await self.bot._ignored_cache.get_ignored_channel(channel, check_category=False):
text_channels.append(channel) 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 = ( 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" chan_str = (
msg = _("Currently ignored categories: {categories}\nChannels: {channels}").format( humanize_list([c.mention for c in text_channels]) if text_channels else _("None")
categories=cat_str, channels=chan_str
) )
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 return msg
# Removing this command from forks is a violation of the GPLv3 under which it is licensed. # Removing this command from forks is a violation of the GPLv3 under which it is licensed.

View File

@ -354,8 +354,7 @@ class Dev(commands.Cog):
or anything else that makes the message non-empty. or anything else that makes the message non-empty.
""" """
msg = ctx.message msg = ctx.message
if not content and not msg.embeds and not msg.attachments: if not content and not msg.embeds and not msg.attachments and not msg.stickers:
# DEP-WARN: add `msg.stickers` when adding d.py 2.0
await ctx.send_help() await ctx.send_help()
return return
msg = copy(msg) msg = copy(msg)

View File

@ -5,7 +5,7 @@ import sys
import codecs import codecs
import logging import logging
import traceback import traceback
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
import aiohttp import aiohttp
import discord import discord
@ -30,6 +30,7 @@ from .utils._internal_utils import (
expected_version, expected_version,
fetch_latest_red_version_info, fetch_latest_red_version_info,
send_to_owners_with_prefix_replaced, send_to_owners_with_prefix_replaced,
get_converter,
) )
from .utils.chat_formatting import inline, bordered, format_perms_list, humanize_timedelta 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) guilds = len(bot.guilds)
users = len(set([m for m in bot.get_all_members()])) 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()) prefixes = cli_flags.prefix or (await bot._config.prefix())
lang = await bot._config.locale() lang = await bot._config.locale()
@ -219,7 +220,16 @@ def init_events(bot, cli_flags):
await ctx.send(msg) await ctx.send(msg)
if error.send_cmd_help: if error.send_cmd_help:
await ctx.send_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: if error.args:
await ctx.send(error.args[0]) await ctx.send(error.args[0])
else: else:
@ -330,7 +340,7 @@ def init_events(bot, cli_flags):
log.exception(type(error).__name__, exc_info=error) log.exception(type(error).__name__, exc_info=error)
@bot.event @bot.event
async def on_message(message): async def on_message(message, /):
await set_contextual_locales_from_guild(bot, message.guild) await set_contextual_locales_from_guild(bot, message.guild)
await bot.process_commands(message) await bot.process_commands(message)
@ -339,7 +349,7 @@ def init_events(bot, cli_flags):
not bot._checked_time_accuracy not bot._checked_time_accuracy
or (discord_now - timedelta(minutes=60)) > 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()) diff = abs((discord_now - system_now).total_seconds())
if diff > 60: if diff > 60:
log.warning( log.warning(

View File

@ -106,7 +106,7 @@ async def _init(bot: Red):
except RuntimeError: except RuntimeError:
return # No modlog channel so no point in continuing return # No modlog channel so no point in continuing
when = datetime.utcnow() when = datetime.now(timezone.utc)
before = when + timedelta(minutes=1) before = when + timedelta(minutes=1)
after = when - timedelta(minutes=1) after = when - timedelta(minutes=1)
await asyncio.sleep(10) # prevent small delays from causing a 5 minute delay on entry 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: while attempts < 12 and guild.me.guild_permissions.view_audit_log:
attempts += 1 attempts += 1
try: try:
entry = await guild.audit_logs( entry = await discord.utils.find(
action=discord.AuditLogAction.ban, before=before, after=after lambda e: e.target.id == member.id and after < e.created_at < before,
).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: except discord.Forbidden:
break break
except discord.HTTPException: except discord.HTTPException:
@ -128,7 +131,7 @@ async def _init(bot: Red):
if entry.user.id != guild.me.id: if entry.user.id != guild.me.id:
# Don't create modlog entires for the bot's own bans, cogs do this. # Don't create modlog entires for the bot's own bans, cogs do this.
mod, reason = entry.user, entry.reason 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) await create_case(_bot_ref, guild, date, "ban", member, mod, reason)
return return
@ -143,7 +146,7 @@ async def _init(bot: Red):
except RuntimeError: except RuntimeError:
return # No modlog channel so no point in continuing return # No modlog channel so no point in continuing
when = datetime.utcnow() when = datetime.now(timezone.utc)
before = when + timedelta(minutes=1) before = when + timedelta(minutes=1)
after = when - timedelta(minutes=1) after = when - timedelta(minutes=1)
await asyncio.sleep(10) # prevent small delays from causing a 5 minute delay on entry 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: while attempts < 12 and guild.me.guild_permissions.view_audit_log:
attempts += 1 attempts += 1
try: try:
entry = await guild.audit_logs( entry = await discord.utils.find(
action=discord.AuditLogAction.unban, before=before, after=after lambda e: e.target.id == user.id and after < e.created_at < before,
).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: except discord.Forbidden:
break break
except discord.HTTPException: except discord.HTTPException:
@ -165,7 +171,7 @@ async def _init(bot: Red):
if entry.user.id != guild.me.id: if entry.user.id != guild.me.id:
# Don't create modlog entires for the bot's own unbans, cogs do this. # Don't create modlog entires for the bot's own unbans, cogs do this.
mod, reason = entry.user, entry.reason 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) await create_case(_bot_ref, guild, date, "unban", user, mod, reason)
return return
@ -268,13 +274,16 @@ class Case:
until: Optional[int] until: Optional[int]
The UNIX time the action is in effect until. The UNIX time the action is in effect until.
`None` if the action is permanent. `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. The channel the action was taken in.
`None` if the action was not related to a channel. `None` if the action was not related to a channel.
.. note:: .. note::
This attribute will be of type `int` This attribute will be of type `int`
if the channel seems to no longer exist. 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]] amended_by: Optional[Union[discord.abc.User, int]]
The moderator who made the last change to the case. The moderator who made the last change to the case.
`None` if the case was never edited. `None` if the case was never edited.
@ -310,7 +319,8 @@ class Case:
case_number: int, case_number: int,
reason: Optional[str] = None, reason: Optional[str] = None,
until: Optional[int] = 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, amended_by: Optional[Union[discord.Object, discord.abc.User, int]] = None,
modified_at: Optional[float] = None, modified_at: Optional[float] = None,
message: Optional[Union[discord.PartialMessage, discord.Message]] = None, message: Optional[Union[discord.PartialMessage, discord.Message]] = None,
@ -330,6 +340,7 @@ class Case:
self.reason = reason self.reason = reason
self.until = until self.until = until
self.channel = channel self.channel = channel
self.parent_channel_id = parent_channel_id
self.amended_by = amended_by self.amended_by = amended_by
if isinstance(amended_by, discord.Object): if isinstance(amended_by, discord.Object):
self.amended_by = amended_by.id self.amended_by = amended_by.id
@ -337,6 +348,18 @@ class Case:
self.case_number = case_number self.case_number = case_number
self.message = message 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: async def _set_message(self, message: discord.Message, /) -> None:
# This should only be used for setting the message right after case creation # 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. # 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 # last username is set based on passed user object
data.pop("last_known_username", None) data.pop("last_known_username", None)
for item, value in data.items(): 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): if isinstance(value, discord.Object):
# probably expensive to call but meh should capture all cases # probably expensive to call but meh should capture all cases
setattr(self, item, value.id) setattr(self, item, value.id)
@ -369,6 +394,9 @@ class Case:
if not isinstance(self.user, int): if not isinstance(self.user, int):
self.last_known_username = f"{self.user.name}#{self.user.discriminator}" 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()) await _config.custom(_CASES, str(self.guild.id), str(self.case_number)).set(self.to_json())
self.bot.dispatch("modlog_case_edit", self) self.bot.dispatch("modlog_case_edit", self)
if not self.message: if not self.message:
@ -443,7 +471,7 @@ class Case:
if self.until: if self.until:
start = datetime.fromtimestamp(self.created_at, tz=timezone.utc) start = datetime.fromtimestamp(self.created_at, tz=timezone.utc)
end = datetime.fromtimestamp(self.until, tz=timezone.utc) end = datetime.fromtimestamp(self.until, tz=timezone.utc)
end_fmt = f"<t:{int(end.timestamp())}>" end_fmt = discord.utils.format_dt(end)
duration = end - start duration = end - start
dur_fmt = _strfdelta(duration) dur_fmt = _strfdelta(duration)
until = end_fmt until = end_fmt
@ -463,7 +491,9 @@ class Case:
last_modified = None last_modified = None
if self.modified_at: if self.modified_at:
last_modified = f"<t:{int(self.modified_at)}>" last_modified = discord.utils.format_dt(
datetime.fromtimestamp(self.modified_at, tz=timezone.utc)
)
if isinstance(self.user, int): if isinstance(self.user, int):
if self.user == 0xDE1: if self.user == 0xDE1:
@ -490,6 +520,31 @@ class Case:
) )
) # Invites and spoilers get rendered even in embeds. ) # 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 embed:
if self.reason: if self.reason:
reason = f"{bold(_('Reason:'))} {self.reason}" reason = f"{bold(_('Reason:'))} {self.reason}"
@ -510,20 +565,13 @@ class Case:
if until and duration: if until and duration:
emb.add_field(name=_("Until"), value=until) emb.add_field(name=_("Until"), value=until)
emb.add_field(name=_("Duration"), value=duration) emb.add_field(name=_("Duration"), value=duration)
if channel_value:
if isinstance(self.channel, int): emb.add_field(name=_("Channel"), value=channel_value, inline=False)
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 amended_by: if amended_by:
emb.add_field(name=_("Amended by"), value=amended_by) emb.add_field(name=_("Amended by"), value=amended_by)
if last_modified: if last_modified:
emb.add_field(name=_("Last modified at"), value=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 return emb
else: else:
if self.reason: if self.reason:
@ -549,9 +597,9 @@ class Case:
case_text += f"{bold(_('Until:'))} {until}\n{bold(_('Duration:'))} {duration}\n" case_text += f"{bold(_('Until:'))} {until}\n{bold(_('Duration:'))} {duration}\n"
if self.channel: if self.channel:
if isinstance(self.channel, int): if isinstance(self.channel, int):
case_text += f"{bold(_('Channel:'))} {self.channel} {_('(Deleted)')}\n" case_text += f"{bold(_('Channel:'))} {channel_value}\n"
else: else:
case_text += f"{bold(_('Channel:'))} {self.channel.name}\n" case_text += f"{bold(_('Channel:'))} {channel_value}\n"
if amended_by: if amended_by:
case_text += f"{bold(_('Amended by:'))} {amended_by}\n" case_text += f"{bold(_('Amended by:'))} {amended_by}\n"
if last_modified: if last_modified:
@ -590,6 +638,7 @@ class Case:
"reason": self.reason, "reason": self.reason,
"until": self.until, "until": self.until,
"channel": self.channel.id if hasattr(self.channel, "id") else None, "channel": self.channel.id if hasattr(self.channel, "id") else None,
"parent_channel": self.parent_channel_id,
"amended_by": amended_by, "amended_by": amended_by,
"modified_at": self.modified_at, "modified_at": self.modified_at,
"message": self.message.id if hasattr(self.message, "id") else None, "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_object = bot.get_user(user_id) or user_id
user_objects[user_key] = user_object 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"]) case_guild = kwargs.get("guild") or bot.get_guild(data["guild"])
return cls( return cls(
bot=bot, bot=bot,
@ -661,6 +714,7 @@ class Case:
reason=data["reason"], reason=data["reason"],
until=data["until"], until=data["until"],
channel=channel, channel=channel,
parent_channel_id=data.get("parent_channel_id"),
modified_at=data["modified_at"], modified_at=data["modified_at"],
message=message, message=message,
last_known_username=data.get("last_known_username"), 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, moderator: Optional[Union[discord.Object, discord.abc.User, int]] = None,
reason: Optional[str] = None, reason: Optional[str] = None,
until: Optional[datetime] = 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, last_known_username: Optional[str] = None,
) -> Optional[Case]: ) -> Optional[Case]:
""" """
@ -947,12 +1001,17 @@ async def create_case(
The time the action is in effect until. The time the action is in effect until.
If naive `datetime` object is passed, it's treated as a local time If naive `datetime` object is passed, it's treated as a local time
(similarly to how Python treats naive `datetime` objects). (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 The channel the action was taken in
last_known_username: Optional[str] last_known_username: Optional[str]
The last known username of the user The last known username of the user
Note: This is ignored if a Member or User object is provided Note: This is ignored if a Member or User object is provided
in the user field in the user field
Raises
------
TypeError
If ``channel`` is of type `discord.PartialMessageable`.
""" """
case_type = await get_casetype(action_type, guild) case_type = await get_casetype(action_type, guild)
if case_type is None: if case_type is None:
@ -964,6 +1023,11 @@ async def create_case(
if user == bot.user: if user == bot.user:
return 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(): async with _config.guild(guild).latest_case_number.get_lock():
# We're getting the case number from config, incrementing it, awaiting something, then # We're getting the case number from config, incrementing it, awaiting something, then
# setting it again. This warrants acquiring the lock. # setting it again. This warrants acquiring the lock.
@ -980,6 +1044,7 @@ async def create_case(
reason, reason,
int(until.timestamp()) if until else None, int(until.timestamp()) if until else None,
channel, channel,
parent_channel_id,
amended_by=None, amended_by=None,
modified_at=None, modified_at=None,
message=None, message=None,

View File

@ -150,7 +150,7 @@ class IgnoreManager:
self._cached_guilds: Dict[int, bool] = {} self._cached_guilds: Dict[int, bool] = {}
async def get_ignored_channel( 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: ) -> bool:
ret: bool ret: bool
@ -176,7 +176,9 @@ class IgnoreManager:
return ret return ret
async def set_ignored_channel( 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 cid: int = channel.id
self._cached_channels[cid] = set_to self._cached_channels[cid] = set_to

View File

@ -7,6 +7,7 @@ from asyncio.futures import isfuture
from itertools import chain from itertools import chain
from pathlib import Path from pathlib import Path
from typing import ( from typing import (
TYPE_CHECKING,
Any, Any,
AsyncIterator, AsyncIterator,
AsyncIterable, AsyncIterable,
@ -15,16 +16,27 @@ from typing import (
Iterable, Iterable,
Iterator, Iterator,
List, List,
Literal,
NoReturn,
Optional, Optional,
Tuple, Tuple,
TypeVar, TypeVar,
Union, Union,
Generator, Generator,
Coroutine, Coroutine,
overload,
) )
import discord
from discord.ext import commands as dpy_commands
from discord.utils import maybe_coroutine 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__ = ( __all__ = (
"bounded_gather", "bounded_gather",
"bounded_gather_iter", "bounded_gather_iter",
@ -32,6 +44,9 @@ __all__ = (
"AsyncIter", "AsyncIter",
"get_end_user_data_statement", "get_end_user_data_statement",
"get_end_user_data_statement_or_raise", "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") 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` >>> # In cog's `__init__.py`
>>> from redbot.core.utils import get_end_user_data_statement >>> from redbot.core.utils import get_end_user_data_statement
>>> __red_end_user_data_statement__ = get_end_user_data_statement(__file__) >>> __red_end_user_data_statement__ = get_end_user_data_statement(__file__)
>>> def setup(bot): >>> async def setup(bot):
... ... ... ...
""" """
try: try:
@ -590,3 +605,209 @@ def get_end_user_data_statement_or_raise(file: Union[Path, str]) -> str:
info_json = file / "info.json" info_json = file / "info.json"
with info_json.open(encoding="utf-8") as fp: with info_json.open(encoding="utf-8") as fp:
return json.load(fp)["end_user_data_statement"] 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

View File

@ -32,6 +32,7 @@ from typing import (
import aiohttp import aiohttp
import discord import discord
import pkg_resources import pkg_resources
from discord.ext.commands.converter import get_converter # DEP-WARN
from fuzzywuzzy import fuzz, process from fuzzywuzzy import fuzz, process
from rich.progress import ProgressColumn from rich.progress import ProgressColumn
from rich.progress_bar import ProgressBar from rich.progress_bar import ProgressBar
@ -59,6 +60,7 @@ __all__ = (
"deprecated_removed", "deprecated_removed",
"RichIndefiniteBarColumn", "RichIndefiniteBarColumn",
"cli_level_to_log_level", "cli_level_to_log_level",
"get_converter",
) )
_T = TypeVar("_T") _T = TypeVar("_T")

View File

@ -106,7 +106,10 @@ async def menu(
if not ctx.me: if not ctx.me:
return return
try: 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() await message.clear_reactions()
else: else:
raise RuntimeError raise RuntimeError

View File

@ -9,7 +9,9 @@ if TYPE_CHECKING:
from ..commands import Context 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. """Bulk delete messages from a channel.
If more than 100 messages are supplied, the bot will delete 100 messages at 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` messages : `list` of `discord.Message`
The messages to bulk delete. The messages to bulk delete.
channel : discord.TextChannel channel : `discord.TextChannel` or `discord.Thread`
The channel to delete messages from. The channel to delete messages from.
Raises Raises

Some files were not shown because too many files have changed in this diff Show More