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 per member or per channel, or set a cooldown guild-wide. You can also set multiple types of cooldown on a single custom command. All cooldowns must pass before the command will trigger.
You can set cooldowns per member or per thread/channel, or set a cooldown guild-wide. You can also set multiple types of cooldown on a single custom command. All cooldowns must pass before the command will trigger.
------------------
Context Parameters
@ -27,19 +27,19 @@ Context Parameters
You can enhance your custom command's response by leaving spaces for the bot to substitute.
+-----------+----------------------------------------+
| Argument | Substitute |
+===========+========================================+
| {message} | The message the bot is responding to. |
+-----------+----------------------------------------+
| {author} | The user who called the command. |
+-----------+----------------------------------------+
| {channel} | The channel the command was called in. |
+-----------+----------------------------------------+
| {server} | The server the command was called in. |
+-----------+----------------------------------------+
| {guild} | Same as with {server}. |
+-----------+----------------------------------------+
+-----------+--------------------------------------------------+
| Argument | Substitute |
+===========+==================================================+
| {message} | The message the bot is responding to. |
+-----------+--------------------------------------------------+
| {author} | The user who called the command. |
+-----------+--------------------------------------------------+
| {channel} | The channel or thread the command was called in. |
+-----------+--------------------------------------------------+
| {server} | The server the command was called in. |
+-----------+--------------------------------------------------+
| {guild} | Same as with {server}. |
+-----------+--------------------------------------------------+
You can further refine the response with dot notation. For example, {author.mention} will mention the user who called the command.
@ -81,7 +81,7 @@ Showing your own avatar
.. code-block:: none
[p]customcom add simple avatar {author.avatar_url}
[p]customcom add simple avatar {author.display_avatar}
[p]avatar
https://cdn.discordapp.com/avatars/133801473317404673/be4c4a4fe47cb3e74c31a0504e7a295e.webp?size=1024

View File

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

View File

@ -1842,9 +1842,9 @@ ignore channel
**Description**
Ignore commands in the channel or category.
Ignore commands in the channel, thread, or category.
Defaults to the current channel.
Defaults to the current thread or channel.
.. Note:: Owners, Admins, and those with Manage Channel permissions override ignored channels.
@ -1856,7 +1856,7 @@ Defaults to the current channel.
- ``[p]ignore channel 356236713347252226`` - Also accepts IDs.
**Arguments:**
- ``<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:
@ -4045,9 +4045,9 @@ unignore channel
**Description**
Remove a channel or category from the ignore list.
Remove a channel, thread, or category from the ignore list.
Defaults to the current channel.
Defaults to the current thread or channel.
**Examples:**
- ``[p]unignore channel #general`` - Unignores commands in the #general channel.
@ -4056,7 +4056,7 @@ Defaults to the current channel.
- ``[p]unignore channel 356236713347252226`` - Also accepts IDs. Use this method to unignore categories.
**Arguments:**
- ``<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:

View File

@ -68,7 +68,7 @@ customcom cooldown
Set, edit, or view the cooldown for a custom command.
You may set cooldowns per member, channel, or guild. Multiple
You may set cooldowns per member, thread/channel, or guild. Multiple
cooldowns may be set. All cooldowns must be cooled to call the
custom command.

View File

@ -574,14 +574,14 @@ slowmode
**Description**
Changes channel's slowmode setting.
Changes thread's or channel's slowmode setting.
Interval can be anything from 0 seconds to 6 hours.
Use without parameters to disable.
**Arguments**
* ``[interval=0:00:00]``: The time for the channel's slowmode settings.
* ``[interval=0:00:00]``: The time for the thread's/channel's slowmode settings.
.. note::
Interval can be anything from 0 seconds to 6 hours.

View File

@ -91,7 +91,7 @@ mutechannel
**Description**
Mute a user in the current text channel.
Mute a user in the current text channel (or in the parent of the current thread).
Examples:
@ -355,7 +355,7 @@ unmutechannel
**Description**
Unmute a user in this channel.
Unmute a user in this channel (or in the parent of this thread).
**Arguments**

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.
2. Rules about the voice channel a user is in.
3. Rules about the text channel a command was issued in.
3. Rules about the text channel or a parent of the thread a command was issued in.
4. Rules about a role the user has (The highest role they have with a rule will be used).
5. Rules about the server a user is in (Global rules only).

View File

@ -73,7 +73,7 @@ report interact
Open a message tunnel.
This tunnel will forward things you say in this channel
This tunnel will forward things you say in this channel or thread
to the ticket opener's direct messages.
Tunnels do not persist across bot restarts.

View File

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

View File

@ -70,7 +70,7 @@ author = "Cog Creators"
# built documents.
#
from redbot.core import __version__
from discord import __version__ as dpy_version
from discord import __version__ as dpy_version, version_info as dpy_version_info
# The short X.Y version.
version = __version__
@ -225,10 +225,20 @@ linkcheck_retries = 3
# -- Options for extensions -----------------------------------------------
if dpy_version_info.releaselevel == "final":
# final release - versioned docs should be available
dpy_docs_url = f"https://discordpy.readthedocs.io/en/v{dpy_version}/"
elif dpy_version_info.minor == dpy_version_info.micro == 0:
# alpha release of a new major version - `master` version of docs should be used
dpy_docs_url = "https://discordpy.readthedocs.io/en/master/"
else:
# alpha release of a new minor or micro version - `latest` version of docs should be used
dpy_docs_url = "https://discordpy.readthedocs.io/en/latest/"
# Intersphinx
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"dpy": (f"https://discordpy.readthedocs.io/en/v{dpy_version}/", None),
"dpy": (dpy_docs_url, None),
"motor": ("https://motor.readthedocs.io/en/stable/", None),
"babel": ("http://babel.pocoo.org/en/stable/", None),
"dateutil": ("https://dateutil.readthedocs.io/en/stable/", None),
@ -238,7 +248,7 @@ intersphinx_mapping = {
# This allows to create links to d.py docs with
# :dpy_docs:`link text <site_name.html>`
extlinks = {
"dpy_docs": (f"https://discordpy.readthedocs.io/en/v{dpy_version}/%s", None),
"dpy_docs": (f"{dpy_docs_url}/%s", None),
"issue": ("https://github.com/Cog-Creators/Red-DiscordBot/issues/%s", "#"),
"ghuser": ("https://github.com/%s", "@"),
}

View File

@ -8,4 +8,4 @@ The following are all decorators for commands, which add restrictions to where a
run.
.. automodule:: redbot.core.commands
:members: permissions_check, bot_has_permissions, bot_in_a_guild, has_permissions, has_guild_permissions, is_owner, guildowner, guildowner_or_permissions, admin, admin_or_permissions, mod, mod_or_permissions
:members: permissions_check, bot_has_permissions, bot_in_a_guild, bot_can_manage_channel, bot_can_react, has_permissions, can_manage_channel, has_guild_permissions, is_owner, guildowner, guildowner_or_can_manage_channel, guildowner_or_permissions, admin, admin_or_can_manage_channel, admin_or_permissions, mod, mod_or_can_manage_channel, mod_or_permissions

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.user` which takes :py:class:`discord.User`.
* :py:meth:`Config.role` which takes :py:class:`discord.Role`.
* :py:meth:`Config.channel` which takes :py:class:`discord.TextChannel`.
* :py:meth:`Config.channel` which takes :py:class:`discord.abc.GuildChannel` or :py:class:`discord.Thread`.
If you need to wipe data from the config, you want to look at :py:meth:`Group.clear`, or :py:meth:`Config.clear_all`
and similar methods, such as :py:meth:`Config.clear_all_guilds`.
@ -467,7 +467,7 @@ much the same way they would in V2. The following examples will demonstrate how
async def setup(bot):
cog = ExampleCog()
await cog.load_data()
bot.add_cog(cog)
await bot.add_cog(cog)
************************************
Best practices and performance notes

View File

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

View File

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

View File

@ -8,7 +8,7 @@ General Utility
===============
.. automodule:: redbot.core.utils
:members: deduplicate_iterables, bounded_gather, bounded_gather_iter, get_end_user_data_statement, get_end_user_data_statement_or_raise
:members: deduplicate_iterables, bounded_gather, bounded_gather_iter, get_end_user_data_statement, get_end_user_data_statement_or_raise, can_user_send_messages_in, can_user_manage_channel, can_user_react_in
.. autoclass:: AsyncIter
:members:

View File

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

View File

@ -378,10 +378,10 @@ async def run_bot(red: Red, cli_flags: Namespace) -> None:
sys.exit(1)
if cli_flags.dry_run:
await red.http.close()
sys.exit(0)
try:
await red.start(token, bot=True)
# `async with red:` is unnecessary here because we call red.close() in shutdown handler
await red.start(token)
except discord.LoginFailure:
log.critical("This token doesn't seem to be valid.")
db_token = await red._config.token()
@ -451,7 +451,8 @@ async def shutdown_handler(red, signal_type=None, exit_code=None):
red._shutdown_mode = exit_code
try:
await red.close()
if not red.is_closed():
await red.close()
finally:
# Then cancels all outstanding tasks other than ourselves
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]

View File

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

View File

@ -84,12 +84,9 @@ class Admin(commands.Cog):
)
self.__current_announcer = None
self._ready = asyncio.Event()
asyncio.create_task(self.handle_migrations())
# As this is a data migration, don't store this for cancelation.
async def cog_before_invoke(self, ctx: commands.Context):
await self._ready.wait()
async def cog_load(self) -> None:
await self.handle_migrations()
async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete"""
@ -106,9 +103,7 @@ class Admin(commands.Cog):
await self.migrate_config_from_0_to_1()
await self.config.schema_version.set(1)
self._ready.set()
async def migrate_config_from_0_to_1(self):
async def migrate_config_from_0_to_1(self) -> None:
all_guilds = await self.config.all_guilds()
for guild_id, guild_data in all_guilds.items():
@ -354,14 +349,8 @@ class Admin(commands.Cog):
pass
@announceset.command(name="channel")
async def announceset_channel(self, ctx, *, channel: discord.TextChannel = None):
"""
Change the channel where the bot will send announcements.
If channel is left blank it defaults to the current channel.
"""
if channel is None:
channel = ctx.channel
async def announceset_channel(self, ctx, *, channel: discord.TextChannel):
"""Change the channel where the bot will send announcements."""
await self.config.guild(ctx.guild).announce_channel.set(channel.id)
await ctx.send(
_("The announcement channel has been set to {channel.mention}").format(channel=channel)

View File

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

View File

@ -50,7 +50,12 @@ class Alias(commands.Cog):
self.config.register_global(entries=[], handled_string_creator=False)
self.config.register_guild(entries=[])
self._aliases: AliasCache = AliasCache(config=self.config, cache_enabled=True)
self._ready_event = asyncio.Event()
async def cog_load(self) -> None:
await self._maybe_handle_string_keys()
if not self._aliases._loaded:
await self._aliases.load_aliases()
async def red_delete_data_for_user(
self,
@ -61,12 +66,8 @@ class Alias(commands.Cog):
if requester != "discord_deleted_user":
return
await self._ready_event.wait()
await self._aliases.anonymize_aliases(user_id)
async def cog_before_invoke(self, ctx):
await self._ready_event.wait()
async def _maybe_handle_string_keys(self):
# This isn't a normal schema migration because it's being added
# after the fact for GH-3788
@ -119,28 +120,6 @@ class Alias(commands.Cog):
await self.config.handled_string_creator.set(True)
def sync_init(self):
t = asyncio.create_task(self._initialize())
def done_callback(fut: asyncio.Future):
try:
t.result()
except Exception as exc:
log.exception("Failed to load alias cog", exc_info=exc)
# Maybe schedule extension unloading with message to owner in future
t.add_done_callback(done_callback)
async def _initialize(self):
"""Should only ever be a task"""
await self._maybe_handle_string_keys()
if not self._aliases._loaded:
await self._aliases.load_aliases()
self._ready_event.set()
def is_command(self, alias_name: str) -> bool:
"""
The logic here is that if this returns true, the name should not be used for an alias
@ -461,7 +440,7 @@ class Alias(commands.Cog):
@alias.command(name="list")
@commands.guild_only()
@checks.bot_has_permissions(add_reactions=True)
@commands.bot_can_react()
async def _list_alias(self, ctx: commands.Context):
"""List the available aliases on this server."""
guild_aliases = await self._aliases.get_guild_aliases(ctx.guild)
@ -470,7 +449,7 @@ class Alias(commands.Cog):
await self.paginate_alias_list(ctx, guild_aliases)
@global_.command(name="list")
@checks.bot_has_permissions(add_reactions=True)
@commands.bot_can_react()
async def _list_global_alias(self, ctx: commands.Context):
"""List the available global aliases on this bot."""
global_aliases = await self._aliases.get_global_aliases()
@ -480,8 +459,6 @@ class Alias(commands.Cog):
@commands.Cog.listener()
async def on_message_without_command(self, message: discord.Message):
await self._ready_event.wait()
if message.guild is not None:
if await self.bot.cog_disabled_in_guild(self, message.guild):
return

View File

@ -2,7 +2,7 @@ from typing import Tuple, Dict, Optional, List, Union
from re import findall
import discord
from discord.ext.commands.view import StringView
from discord.ext.commands.view import StringView # DEP-WARN
from redbot.core import commands, Config
from redbot.core.i18n import Translator
from redbot.core.utils import AsyncIter

View File

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

View File

@ -973,7 +973,7 @@ class AudioAPIInterface:
and not query.local_track_path.exists()
):
continue
notify_channel = player.guild.get_channel(notify_channel_id)
notify_channel = player.guild.get_channel_or_thread(notify_channel_id)
if not await self.cog.is_query_allowed(
self.config,
notify_channel,

View File

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

View File

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

View File

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

View File

@ -81,7 +81,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.command(name="now")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
@commands.bot_has_permissions(embed_links=True)
@commands.bot_can_react()
async def command_now(self, ctx: commands.Context):
"""Now playing."""
if not self._player_check(ctx):

View File

@ -25,7 +25,8 @@ class EqualizerCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="eq", invoke_without_command=True)
@commands.guild_only()
@commands.cooldown(1, 15, commands.BucketType.guild)
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
@commands.bot_has_permissions(embed_links=True)
@commands.bot_can_react()
async def command_equalizer(self, ctx: commands.Context):
"""Equalizer management.

View File

@ -21,7 +21,8 @@ _ = Translator("Audio", Path(__file__))
class LocalTrackCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.group(name="local")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
@commands.bot_has_permissions(embed_links=True)
@commands.bot_can_react()
async def command_local(self, ctx: commands.Context):
"""Local playback commands."""

View File

@ -41,7 +41,8 @@ class MiscellaneousCommands(MixinMeta, metaclass=CompositeMetaClass):
@commands.command(name="audiostats")
@commands.guild_only()
@commands.is_owner()
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
@commands.bot_has_permissions(embed_links=True)
@commands.bot_can_react()
async def command_audiostats(self, ctx: commands.Context):
"""Audio stats."""
server_num = len(lavalink.active_players())

View File

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

View File

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

View File

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

View File

@ -197,7 +197,7 @@ class AudioEvents(MixinMeta, metaclass=CompositeMetaClass):
):
if not guild:
return
notify_channel = guild.get_channel(player.fetch("notify_channel"))
notify_channel = guild.get_channel_or_thread(player.fetch("notify_channel"))
has_perms = self._has_notify_perms(notify_channel)
tries = 0
while not player._is_playing:

View File

@ -18,6 +18,7 @@ from lavalink import NodeNotFound, PlayerNotFound
from redbot.core import commands
from redbot.core.i18n import Translator
from redbot.core.utils import can_user_send_messages_in
from redbot.core.utils.antispam import AntiSpam
from redbot.core.utils.chat_formatting import box, humanize_list, underline, bold
@ -61,6 +62,16 @@ HUMANIZED_PERM = {
"manage_roles": _("Manage Roles"),
"manage_webhooks": _("Manage Webhooks"),
"manage_emojis": _("Manage Emojis"),
"use_slash_commands": _("Use Slash Commands"),
"request_to_speak": _("Request to Speak"),
"manage_events": _("Manage Events"),
"manage_threads": _("Manage Threads"),
"create_public_threads": _("Create Public Threads"),
"create_private_threads": _("Create Private Threads"),
"external_stickers": _("Use External Stickers"),
"send_messages_in_threads": _("Send Messages in Threads"),
"start_embedded_activities": _("Start Activities"),
"moderate_members": _("Moderate Member"),
}
DANGEROUS_COMMANDS = {
@ -175,13 +186,31 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
"Not running Audio command due to invalid machine architecture for the managed Lavalink node."
)
current_perms = ctx.channel.permissions_for(ctx.me)
surpass_ignore = (
isinstance(ctx.channel, discord.abc.PrivateChannel)
or await ctx.bot.is_owner(ctx.author)
or await ctx.bot.is_admin(ctx.author)
)
guild = ctx.guild
if guild and not can_user_send_messages_in(ctx.me, ctx.channel):
log.debug(
"Missing perms to send messages in %d, Owner ID: %d",
guild.id,
guild.owner.id,
)
if not surpass_ignore:
text = _(
"I'm missing permissions to send messages in this server. "
"Please address this as soon as possible."
)
log.info(
"Missing write permission in %d, Owner ID: %d",
guild.id,
guild.owner.id,
)
raise CheckFailure(message=text)
current_perms = ctx.channel.permissions_for(ctx.me)
if guild and not current_perms.is_superset(self.permission_cache):
current_perms_set = set(iter(current_perms))
expected_perms_set = set(iter(self.permission_cache))
@ -207,14 +236,7 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
perm=_(HUMANIZED_PERM.get(perm, perm)),
)
text = text.strip()
if current_perms.send_messages and current_perms.read_messages:
await ctx.send(box(text=text, lang="ini"))
else:
log.info(
"Missing write permission in %s, Owner ID: %s",
ctx.guild.id,
ctx.guild.owner.id,
)
await ctx.send(box(text=text, lang="ini"))
raise CheckFailure(message=text)
with contextlib.suppress(Exception):
@ -312,7 +334,7 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
)
if error.send_cmd_help:
await ctx.send_help()
elif isinstance(error, commands.ConversionFailure):
elif isinstance(error, commands.BadArgument):
handled = True
if error.args:
if match := RE_CONVERSION.search(error.args[0]):
@ -390,10 +412,10 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
if not handled:
await self.bot.on_command_error(ctx, error, unhandled_by_cog=True)
def cog_unload(self) -> None:
async def cog_unload(self) -> None:
if not self.cog_cleaned_up:
self.bot.dispatch("red_audio_unload", self)
self.session.detach()
await self.session.close()
if self.player_automated_timer_task:
self.player_automated_timer_task.cancel()
@ -408,10 +430,10 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
lavalink.unregister_event_listener(self.lavalink_event_handler)
lavalink.unregister_update_listener(self.lavalink_update_handler)
asyncio.create_task(lavalink.close(self.bot))
asyncio.create_task(self._close_database())
await lavalink.close(self.bot)
await self._close_database()
if self.managed_node_controller is not None:
asyncio.create_task(self.managed_node_controller.shutdown())
await self.managed_node_controller.shutdown()
self.cog_cleaned_up = True

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import contextlib
import logging
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Callable, List, Optional, Set, Union
import discord
@ -75,7 +75,7 @@ class Cleanup(commands.Cog):
@staticmethod
async def get_messages_for_deletion(
*,
channel: Union[discord.TextChannel, discord.DMChannel],
channel: Union[discord.TextChannel, discord.DMChannel, discord.Thread],
number: Optional[PositiveInt] = None,
check: Callable[[discord.Message], bool] = lambda x: True,
limit: Optional[PositiveInt] = None,
@ -99,7 +99,7 @@ class Cleanup(commands.Cog):
"""
# This isn't actually two weeks ago to allow some wiggle room on API limits
two_weeks_ago = datetime.utcnow() - timedelta(days=14, minutes=-5)
two_weeks_ago = datetime.now(timezone.utc) - timedelta(days=14, minutes=-5)
def message_filter(message):
return (
@ -129,7 +129,7 @@ class Cleanup(commands.Cog):
async def send_optional_notification(
self,
num: int,
channel: Union[discord.TextChannel, discord.DMChannel],
channel: Union[discord.TextChannel, discord.DMChannel, discord.Thread],
*,
subtract_invoking: bool = False,
) -> None:
@ -149,7 +149,7 @@ class Cleanup(commands.Cog):
@staticmethod
async def get_message_from_reference(
channel: discord.TextChannel, reference: discord.MessageReference
channel: Union[discord.TextChannel, discord.Thread], reference: discord.MessageReference
) -> Optional[discord.Message]:
message = None
resolved = reference.resolved
@ -696,7 +696,12 @@ class Cleanup(commands.Cog):
def check(m):
if m.attachments:
return False
c = (m.author.id, m.content, [e.to_dict() for e in m.embeds])
c = (
m.author.id,
m.content,
[embed.to_dict() for embed in m.embeds],
[sticker.id for sticker in m.stickers],
)
if c in msgs:
spam.append(m)
return True

View File

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

View File

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

View File

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

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

View File

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

View File

@ -434,11 +434,11 @@ class Economy(commands.Cog):
if show_global and await bank.is_global():
# show_global is only applicable if bank is global
bank_sorted = await bank.get_leaderboard(positions=top, guild=None)
base_embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url)
base_embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.display_avatar)
else:
bank_sorted = await bank.get_leaderboard(positions=top, guild=guild)
if guild:
base_embed.set_author(name=guild.name, icon_url=guild.icon_url)
base_embed.set_author(name=guild.name, icon_url=guild.icon)
try:
bal_len = len(humanize_number(bank_sorted[0][1]["balance"]))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -84,8 +84,6 @@ class Mod(
self.tban_expiry_task = asyncio.create_task(self.tempban_expirations_task())
self.last_case: dict = defaultdict(dict)
self._ready = asyncio.Event()
async def red_delete_data_for_user(
self,
*,
@ -114,12 +112,8 @@ class Mod(
pass
# possible with a context switch between here and getting all guilds
async def initialize(self):
async def cog_load(self) -> None:
await self._maybe_update_config()
self._ready.set()
async def cog_before_invoke(self, ctx: commands.Context) -> None:
await self._ready.wait()
def cog_unload(self):
self.tban_expiry_task.cancel()

View File

@ -195,9 +195,8 @@ class ModInfo(MixinMeta):
if is_special:
joined_at = special_date
elif joined_at := member.joined_at:
joined_at = joined_at.replace(tzinfo=datetime.timezone.utc)
user_created = int(member.created_at.replace(tzinfo=datetime.timezone.utc).timestamp())
else:
joined_at = member.joined_at
voice_state = member.voice
member_number = (
sorted(guild.members, key=lambda m: m.joined_at or ctx.message.created_at).index(
@ -206,9 +205,15 @@ class ModInfo(MixinMeta):
+ 1
)
created_on = "<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:
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:
joined_on = _("Unknown")
@ -296,7 +301,7 @@ class ModInfo(MixinMeta):
name = " ~ ".join((name, member.nick)) if member.nick else name
name = filter_invites(name)
avatar = member.avatar_url_as(static_format="png")
avatar = member.display_avatar.replace(static_format="png")
data.set_author(name=f"{statusemoji} {name}", url=avatar)
data.set_thumbnail(url=avatar)

View File

@ -14,8 +14,8 @@ class Slowmode(MixinMeta):
@commands.command()
@commands.guild_only()
@commands.bot_has_permissions(manage_channels=True)
@checks.admin_or_permissions(manage_channels=True)
@commands.bot_can_manage_channel()
@commands.admin_or_can_manage_channel()
async def slowmode(
self,
ctx,
@ -24,7 +24,7 @@ class Slowmode(MixinMeta):
minimum=timedelta(seconds=0), maximum=timedelta(hours=6), default_unit="seconds"
) = timedelta(seconds=0),
):
"""Changes channel's slowmode setting.
"""Changes thread's or channel's slowmode setting.
Interval can be anything from 0 seconds to 6 hours.
Use without parameters to disable.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,7 +31,7 @@ class GlobalUniqueObjectFinder(commands.Converter):
if guild is not None:
return guild
channel: discord.abc.GuildChannel = bot.get_channel(_id)
if channel is not None:
if channel is not None and not isinstance(channel, discord.Thread):
return channel
user: discord.User = bot.get_user(_id)

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

View File

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

View File

@ -212,7 +212,7 @@ class Reports(commands.Cog):
name=_("Report from {author}{maybe_nick}").format(
author=author, maybe_nick=(f" ({author.nick})" if author.nick else "")
),
icon_url=author.avatar_url,
icon_url=author.display_avatar,
)
em.set_footer(text=_("Report #{}").format(ticket_number))
send_content = None
@ -391,7 +391,7 @@ class Reports(commands.Cog):
async def response(self, ctx, ticket_number: int):
"""Open a message tunnel.
This tunnel will forward things you say in this channel
This tunnel will forward things you say in this channel or thread
to the ticket opener's direct messages.
Tunnels do not persist across bot restarts.

View File

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

View File

@ -79,9 +79,6 @@ class Streams(commands.Cog):
self.yt_cid_pattern = re.compile("^UC[-_A-Za-z0-9]{21}[AQgw]$")
self._ready_event: asyncio.Event = asyncio.Event()
self._init_task: asyncio.Task = asyncio.create_task(self.initialize())
async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete"""
return
@ -92,10 +89,8 @@ class Streams(commands.Cog):
return True
return False
async def initialize(self) -> None:
async def cog_load(self) -> None:
"""Should be called straight after cog instantiation."""
await self.bot.wait_until_ready()
try:
await self.move_api_keys()
await self.get_twitch_bearer_token()
@ -104,16 +99,11 @@ class Streams(commands.Cog):
except Exception as error:
log.exception("Failed to initialize Streams cog:", exc_info=error)
self._ready_event.set()
@commands.Cog.listener()
async def on_red_api_tokens_update(self, service_name, api_tokens):
if service_name == "twitch":
await self.get_twitch_bearer_token(api_tokens)
async def cog_before_invoke(self, ctx: commands.Context):
await self._ready_event.wait()
async def move_api_keys(self) -> None:
"""Move the API keys from cog stored config to core bot config if they exist."""
tokens = await self.config.tokens()
@ -127,6 +117,29 @@ class Streams(commands.Cog):
await self.bot.set_shared_api_tokens("twitch", client_id=token)
await self.config.tokens.clear()
async def _notify_owner_about_missing_twitch_secret(self) -> None:
message = _(
"You need a client secret key if you want to use the Twitch API on this cog.\n"
"Follow these steps:\n"
"1. Go to this page: {link}.\n"
'2. Click "Manage" on your application.\n'
'3. Click on "New secret".\n'
"5. Copy your client ID and your client secret into:\n"
"{command}"
"\n\n"
"Note: These tokens are sensitive and should only be used in a private channel "
"or in DM with the bot."
).format(
link="https://dev.twitch.tv/console/apps",
command=inline(
"[p]set api twitch client_id {} client_secret {}".format(
_("<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:
tokens = (
await self.bot.get_shared_api_tokens("twitch") if api_tokens is None else api_tokens
@ -140,28 +153,8 @@ class Streams(commands.Cog):
if notified_owner_missing_twitch_secret is True:
await self.config.notified_owner_missing_twitch_secret.set(False)
except KeyError:
message = _(
"You need a client secret key if you want to use the Twitch API on this cog.\n"
"Follow these steps:\n"
"1. Go to this page: {link}.\n"
'2. Click "Manage" on your application.\n'
'3. Click on "New secret".\n'
"5. Copy your client ID and your client secret into:\n"
"{command}"
"\n\n"
"Note: These tokens are sensitive and should only be used in a private channel "
"or in DM with the bot."
).format(
link="https://dev.twitch.tv/console/apps",
command=inline(
"[p]set api twitch client_id {} client_secret {}".format(
_("<your_client_id_here>"), _("<your_client_secret_here>")
)
),
)
if notified_owner_missing_twitch_secret is False:
await send_to_owners_with_prefix_replaced(self.bot, message)
await self.config.notified_owner_missing_twitch_secret.set(True)
asyncio.create_task(self._notify_owner_about_missing_twitch_secret())
async with aiohttp.ClientSession() as session:
async with session.post(
"https://id.twitch.tv/oauth2/token",
@ -391,6 +384,9 @@ class Streams(commands.Cog):
await ctx.send(page)
async def stream_alert(self, ctx: commands.Context, _class, channel_name):
if isinstance(ctx.channel, discord.Thread):
await ctx.send("Stream alerts cannot be set up in threads.")
return
stream = self.get_stream(_class, channel_name)
if not stream:
token = await self.bot.get_shared_api_tokens(_class.token_name)

View File

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

View File

@ -249,7 +249,9 @@ class TriviaSession:
answers = tuple(s.lower() for s in answers)
def _pred(message: discord.Message):
early_exit = message.channel != self.ctx.channel or message.author == self.ctx.guild.me
early_exit = (
message.channel.id != self.ctx.channel.id or message.author == self.ctx.guild.me
)
if early_exit:
return False

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,13 +29,11 @@ from .converter import (
parse_timedelta as parse_timedelta,
NoParseOptional as NoParseOptional,
UserInputOptional as UserInputOptional,
Literal as Literal,
RawUserIdConverter as RawUserIdConverter,
CogConverter as CogConverter,
CommandConverter as CommandConverter,
)
from .errors import (
ConversionFailure as ConversionFailure,
BotMissingPermissions as BotMissingPermissions,
UserFeedbackCheckFailure as UserFeedbackCheckFailure,
ArgParserFailure as ArgParserFailure,
@ -57,33 +55,23 @@ from .requires import (
permissions_check as permissions_check,
bot_has_permissions as bot_has_permissions,
bot_in_a_guild as bot_in_a_guild,
bot_can_manage_channel as bot_can_manage_channel,
bot_can_react as bot_can_react,
has_permissions as has_permissions,
can_manage_channel as can_manage_channel,
has_guild_permissions as has_guild_permissions,
is_owner as is_owner,
guildowner as guildowner,
guildowner_or_can_manage_channel as guildowner_or_can_manage_channel,
guildowner_or_permissions as guildowner_or_permissions,
admin as admin,
admin_or_can_manage_channel as admin_or_can_manage_channel,
admin_or_permissions as admin_or_permissions,
mod as mod,
mod_or_can_manage_channel as mod_or_can_manage_channel,
mod_or_permissions as mod_or_permissions,
)
from ._dpy_reimplements import (
check as check,
guild_only as guild_only,
cooldown as cooldown,
dm_only as dm_only,
is_nsfw as is_nsfw,
has_role as has_role,
has_any_role as has_any_role,
bot_has_role as bot_has_role,
when_mentioned_or as when_mentioned_or,
when_mentioned as when_mentioned,
bot_has_any_role as bot_has_any_role,
before_invoke as before_invoke,
after_invoke as after_invoke,
)
### DEP-WARN: Check this *every* discord.py update
from discord.ext.commands import (
BadArgument as BadArgument,
@ -137,7 +125,6 @@ from discord.ext.commands import (
ColorConverter as ColorConverter,
VoiceChannelConverter as VoiceChannelConverter,
StageChannelConverter as StageChannelConverter,
StoreChannelConverter as StoreChannelConverter,
NSFWChannelRequired as NSFWChannelRequired,
IDConverter as IDConverter,
MissingRequiredArgument as MissingRequiredArgument,
@ -167,4 +154,39 @@ from discord.ext.commands import (
EmojiNotFound as EmojiNotFound,
PartialEmojiConversionFailure as PartialEmojiConversionFailure,
BadBoolArgument as BadBoolArgument,
TooManyFlags as TooManyFlags,
MissingRequiredFlag as MissingRequiredFlag,
flag as flag,
FlagError as FlagError,
ObjectNotFound as ObjectNotFound,
GuildStickerNotFound as GuildStickerNotFound,
ThreadNotFound as ThreadNotFound,
GuildChannelConverter as GuildChannelConverter,
run_converters as run_converters,
Flag as Flag,
BadFlagArgument as BadFlagArgument,
BadColorArgument as BadColorArgument,
dynamic_cooldown as dynamic_cooldown,
BadLiteralArgument as BadLiteralArgument,
DynamicCooldownMapping as DynamicCooldownMapping,
ThreadConverter as ThreadConverter,
GuildStickerConverter as GuildStickerConverter,
ObjectConverter as ObjectConverter,
FlagConverter as FlagConverter,
MissingFlagArgument as MissingFlagArgument,
ScheduledEventConverter as ScheduledEventConverter,
ScheduledEventNotFound as ScheduledEventNotFound,
check as check,
guild_only as guild_only,
cooldown as cooldown,
dm_only as dm_only,
is_nsfw as is_nsfw,
has_role as has_role,
has_any_role as has_any_role,
bot_has_role as bot_has_role,
when_mentioned_or as when_mentioned_or,
when_mentioned as when_mentioned,
bot_has_any_role as bot_has_any_role,
before_invoke as before_invoke,
after_invoke as after_invoke,
)

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)
"""
def __call__(self, *args, **kwargs):
if self.cog:
# We need to inject cog as self here
return self.callback(self.cog, *args, **kwargs)
else:
return self.callback(*args, **kwargs)
def __init__(self, *args, **kwargs):
self.ignore_optional_for_conversion = kwargs.pop("ignore_optional_for_conversion", False)
super().__init__(*args, **kwargs)
@ -323,60 +316,27 @@ class Command(CogCommandMixin, DPYCommand):
@callback.setter
def callback(self, function):
"""
Below should be mostly the same as discord.py
# Below should be mostly the same as discord.py
#
# Here's the list of cases where the behavior differs:
# - `typing.Optional` behavior is changed
# when `ignore_optional_for_conversion` option is used
super(Command, Command).callback.__set__(self, function)
Currently, we modify behavior for
if not self.ignore_optional_for_conversion:
return
- functools.partial support
- typing.Optional behavior change as an option
"""
self._callback = function
if isinstance(function, functools.partial):
self.module = function.func.__module__
globals_ = function.func.__globals__
else:
self.module = function.__module__
globals_ = function.__globals__
signature = inspect.signature(function)
self.params = signature.parameters.copy()
# PEP-563 allows postponing evaluation of annotations with a __future__
# import. When postponed, Parameter.annotation will be a string and must
# be replaced with the real value for the converters to work later on
_NoneType = type(None)
for key, value in self.params.items():
if isinstance(value.annotation, str):
self.params[key] = value = value.replace(
annotation=eval(value.annotation, globals_)
)
# fail early for when someone passes an unparameterized Greedy type
if value.annotation is Greedy:
raise TypeError("Unparameterized Greedy[...] is disallowed in signature.")
if not self.ignore_optional_for_conversion:
continue # reduces indentation compared to alternative
try:
vtype = value.annotation.__origin__
if vtype is Union:
_NoneType = type if TYPE_CHECKING else type(None)
args = value.annotation.__args__
if _NoneType in args:
args = tuple(a for a in args if a is not _NoneType)
if len(args) == 1:
# can't have a union of 1 or 0 items
# 1 prevents this from becoming 0
# we need to prevent 2 become 1
# (Don't change that to becoming, it's intentional :musical_note:)
self.params[key] = value = value.replace(annotation=args[0])
else:
# and mypy wretches at the correct Union[args]
temp_type = type if TYPE_CHECKING else Union[args]
self.params[key] = value = value.replace(annotation=temp_type)
except AttributeError:
origin = getattr(value.annotation, "__origin__", None)
if origin is not Union:
continue
args = value.annotation.__args__
if _NoneType in args:
args = tuple(a for a in args if a is not _NoneType)
# typing.Union is automatically deduplicated and flattened
# so we don't need to anything else here
self.params[key] = value = value.replace(annotation=Union[args])
@property
def help(self):
@ -420,6 +380,7 @@ class Command(CogCommandMixin, DPYCommand):
async def can_run(
self,
ctx: "Context",
/,
*,
check_all_parents: bool = False,
change_permission_state: bool = False,
@ -476,7 +437,7 @@ class Command(CogCommandMixin, DPYCommand):
if not change_permission_state:
ctx.permission_state = original_state
async def prepare(self, ctx):
async def prepare(self, ctx, /):
ctx.command = self
if not self.enabled:
@ -502,39 +463,6 @@ class Command(CogCommandMixin, DPYCommand):
await self._max_concurrency.release(ctx)
raise
async def do_conversion(
self, ctx: "Context", converter, argument: str, param: inspect.Parameter
):
"""Convert an argument according to its type annotation.
Raises
------
ConversionFailure
If doing the conversion failed.
Returns
-------
Any
The converted argument.
"""
# Let's not worry about all of this junk if it's just a str converter
if converter is str:
return argument
try:
return await super().do_conversion(ctx, converter, argument, param)
except BadArgument as exc:
raise ConversionFailure(converter, argument, param, *exc.args) from exc
except ValueError as exc:
# Some common converters need special treatment...
if converter in (int, float):
message = _('"{argument}" is not a number.').format(argument=argument)
raise ConversionFailure(converter, argument, param, message) from exc
# We should expose anything which might be a bug in the converter
raise exc
async def can_see(self, ctx: "Context"):
"""Check if this command is visible in the given context.
@ -636,7 +564,7 @@ class Command(CogCommandMixin, DPYCommand):
break
return old_rule, new_rule
def error(self, coro):
def error(self, coro, /):
"""
A decorator that registers a coroutine as a local error handler.
@ -796,7 +724,7 @@ class Group(GroupMixin, Command, CogGroupMixin, DPYGroup):
self.autohelp = kwargs.pop("autohelp", True)
super().__init__(*args, **kwargs)
async def invoke(self, ctx: "Context"):
async def invoke(self, ctx: "Context", /):
# we skip prepare in some cases to avoid some things
# We still always want this part of the behavior though
ctx.command = self
@ -971,7 +899,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
"""
raise RedUnhandledAPI()
async def can_run(self, ctx: "Context", **kwargs) -> bool:
async def can_run(self, ctx: "Context", /, **kwargs) -> bool:
"""
This really just exists to allow easy use with other methods using can_run
on commands and groups such as help formatters.
@ -999,7 +927,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
return can_run
async def can_see(self, ctx: "Context") -> bool:
async def can_see(self, ctx: "Context", /) -> bool:
"""Check if this cog is visible in the given context.
In short, this will verify whether
@ -1112,7 +1040,7 @@ class _AlwaysAvailableMixin:
This particular class is not supported for 3rd party use
"""
async def can_run(self, ctx, *args, **kwargs) -> bool:
async def can_run(self, ctx, /, *args, **kwargs) -> bool:
return not ctx.author.bot
can_see = can_run
@ -1161,7 +1089,7 @@ class _ForgetMeSpecialCommand(_RuleDropper, Command):
We need special can_run behavior here
"""
async def can_run(self, ctx, *args, **kwargs) -> bool:
async def can_run(self, ctx, /, *args, **kwargs) -> bool:
return await ctx.bot._config.datarequests.allow_user_requests()
can_see = can_run

View File

@ -11,7 +11,7 @@ from discord.ext.commands import Context as DPYContext
from .requires import PermState
from ..utils.chat_formatting import box
from ..utils.predicates import MessagePredicate
from ..utils import common_filters
from ..utils import can_user_react_in, common_filters
if TYPE_CHECKING:
from .commands import Command
@ -139,7 +139,7 @@ class Context(DPYContext):
:code:`True` if adding the reaction succeeded.
"""
try:
if not self.channel.permissions_for(self.me).add_reactions:
if not can_user_react_in(self.me, self.channel):
raise RuntimeError
await self.message.add_reaction(reaction)
except (RuntimeError, discord.HTTPException):
@ -283,16 +283,6 @@ class Context(DPYContext):
allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=False),
)
@property
def clean_prefix(self) -> str:
"""
str: The command prefix, but with a sanitized version of the bot's mention if it was used as prefix.
This can be used in a context where discord user mentions might not render properly.
"""
me = self.me
pattern = re.compile(rf"<@!?{me.id}>")
return pattern.sub(f"@{me.display_name}".replace("\\", r"\\"), self.prefix)
@property
def me(self) -> Union[discord.ClientUser, discord.Member]:
"""
@ -349,7 +339,7 @@ if TYPE_CHECKING or os.getenv("BUILDING_DOCS", False):
...
@property
def channel(self) -> discord.TextChannel:
def channel(self) -> Union[discord.TextChannel, discord.Thread]:
...
@property

View File

@ -19,7 +19,6 @@ from typing import (
Dict,
Type,
TypeVar,
Literal as Literal,
Union as UserInputOptional,
)
@ -44,7 +43,6 @@ __all__ = [
"get_timedelta_converter",
"parse_relativedelta",
"parse_timedelta",
"Literal",
"CommandConverter",
"CogConverter",
]
@ -281,7 +279,7 @@ else:
Returns a typechecking safe `DictConverter` suitable for use with discord.py
"""
class PartialMeta(type):
class PartialMeta(type(DictConverter)):
__call__ = functools.partialmethod(
type(DictConverter).__call__, *expected_keys, delims=delims
)
@ -389,7 +387,7 @@ else:
The converter class, which will be a subclass of `TimedeltaConverter`
"""
class PartialMeta(type):
class PartialMeta(type(DictConverter)):
__call__ = functools.partialmethod(
type(DictConverter).__call__,
allowed_units=allowed_units,
@ -475,44 +473,6 @@ if not TYPE_CHECKING:
#: This converter class is still provisional.
UserInputOptional = Optional
if not TYPE_CHECKING:
class Literal(dpy_commands.Converter):
"""
This can be used as a converter for `typing.Literal`.
In a type checking context it is `typing.Literal`.
In a runtime context, it's a converter which only matches the literals it was given.
.. warning::
This converter class is still provisional.
"""
def __init__(self, valid_names: Tuple[str]):
self.valid_names = valid_names
def __call__(self, ctx, arg):
# Callable's are treated as valid types:
# https://github.com/python/cpython/blob/3.8/Lib/typing.py#L148
# Without this, ``typing.Union[Literal["clear"], bool]`` would fail
return self.convert(ctx, arg)
async def convert(self, ctx, arg):
if arg in self.valid_names:
return arg
raise BadArgument(_("Expected one of: {}").format(humanize_list(self.valid_names)))
def __class_getitem__(cls, k):
if not k:
raise ValueError("Need at least one value for Literal")
if isinstance(k, tuple):
return cls(k)
else:
return cls((k,))
if TYPE_CHECKING:
CommandConverter = dpy_commands.Command
CogConverter = dpy_commands.Cog

View File

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

View File

@ -39,7 +39,7 @@ from discord.ext import commands as dpy_commands
from . import commands
from .context import Context
from ..i18n import Translator
from ..utils import menus
from ..utils import can_user_react_in, menus
from ..utils.mod import mass_purge
from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results
from ..utils.chat_formatting import (
@ -478,7 +478,7 @@ class RedHelpFormatter(HelpFormatterABC):
author_info = {
"name": _("{ctx.me.display_name} Help Menu").format(ctx=ctx),
"icon_url": ctx.me.avatar_url,
"icon_url": ctx.me.display_avatar,
}
# Offset calculation here is for total embed size limit
@ -733,7 +733,7 @@ class RedHelpFormatter(HelpFormatterABC):
if use_embeds:
ret.set_author(
name=_("{ctx.me.display_name} Help Menu").format(ctx=ctx),
icon_url=ctx.me.avatar_url,
icon_url=ctx.me.display_avatar,
)
tagline = help_settings.tagline or self.get_default_tagline(ctx)
ret.set_footer(text=tagline)
@ -746,7 +746,7 @@ class RedHelpFormatter(HelpFormatterABC):
ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
ret.set_author(
name=_("{ctx.me.display_name} Help Menu").format(ctx=ctx),
icon_url=ctx.me.avatar_url,
icon_url=ctx.me.display_avatar,
)
tagline = help_settings.tagline or self.get_default_tagline(ctx)
ret.set_footer(text=tagline)
@ -765,7 +765,7 @@ class RedHelpFormatter(HelpFormatterABC):
ret = discord.Embed(color=(await ctx.embed_color()), description=ret)
ret.set_author(
name=_("{ctx.me.display_name} Help Menu").format(ctx=ctx),
icon_url=ctx.me.avatar_url,
icon_url=ctx.me.display_avatar,
)
tagline = help_settings.tagline or self.get_default_tagline(ctx)
ret.set_footer(text=tagline)
@ -813,15 +813,7 @@ class RedHelpFormatter(HelpFormatterABC):
"""
Sends pages based on settings.
"""
# save on config calls
channel_permissions = ctx.channel.permissions_for(ctx.me)
if not (
channel_permissions.add_reactions
and channel_permissions.read_message_history
and help_settings.use_menus
):
if not (can_user_react_in(ctx.me, ctx.channel) and help_settings.use_menus):
max_pages_in_guild = help_settings.max_pages_in_guild
use_DMs = len(pages) > max_pages_in_guild
destination = ctx.author if use_DMs else ctx.channel
@ -846,17 +838,18 @@ class RedHelpFormatter(HelpFormatterABC):
if use_DMs and help_settings.use_tick:
await ctx.tick()
# The if statement takes into account that 'destination' will be
# the context channel in non-DM context, reusing 'channel_permissions' to avoid
# computing the permissions twice.
# the context channel in non-DM context.
if (
not use_DMs # we're not in DMs
and delete_delay > 0 # delete delay is enabled
and channel_permissions.manage_messages # we can manage messages here
and ctx.channel.permissions_for(ctx.me).manage_messages # we can manage messages
):
# We need to wrap this in a task to not block after-sending-help interactions.
# The channel has to be TextChannel as we can't bulk-delete from DMs
# The channel has to be TextChannel or Thread as we can't bulk-delete from DMs
async def _delete_delay_help(
channel: discord.TextChannel, messages: List[discord.Message], delay: int
channel: Union[discord.TextChannel, discord.Thread],
messages: List[discord.Message],
delay: int,
):
await asyncio.sleep(delay)
await mass_purge(messages, channel)

View File

@ -30,6 +30,8 @@ import discord
from discord.ext.commands import check
from .errors import BotMissingPermissions
from redbot.core import utils
if TYPE_CHECKING:
from .commands import Command
from .context import Context
@ -48,14 +50,20 @@ __all__ = [
"permissions_check",
"bot_has_permissions",
"bot_in_a_guild",
"bot_can_manage_channel",
"bot_can_react",
"has_permissions",
"can_manage_channel",
"has_guild_permissions",
"is_owner",
"guildowner",
"guildowner_or_can_manage_channel",
"guildowner_or_permissions",
"admin",
"admin_or_can_manage_channel",
"admin_or_permissions",
"mod",
"mod_or_can_manage_channel",
"mod_or_permissions",
"transition_permstate_to",
"PermStateTransitions",
@ -135,12 +143,11 @@ class PrivilegeLevel(enum.IntEnum):
# admin or mod role.
guild_settings = ctx.bot._config.guild(ctx.guild)
member_snowflakes = ctx.author._roles # DEP-WARN
for snowflake in await guild_settings.admin_role():
if member_snowflakes.has(snowflake): # DEP-WARN
if ctx.author.get_role(snowflake):
return cls.ADMIN
for snowflake in await guild_settings.mod_role():
if member_snowflakes.has(snowflake): # DEP-WARN
if ctx.author.get_role(snowflake):
return cls.MOD
return cls.NONE
@ -596,7 +603,10 @@ class Requires:
channels = []
if author.voice is not None:
channels.append(author.voice.channel)
channels.append(ctx.channel)
if isinstance(ctx.channel, discord.Thread):
channels.append(ctx.channel.parent)
else:
channels.append(ctx.channel)
category = ctx.channel.category
if category is not None:
channels.append(category)
@ -731,6 +741,77 @@ def bot_in_a_guild():
return check(predicate)
def bot_can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]:
"""
Complain if the bot is missing permissions to manage channel.
This check properly resolves the permissions for `discord.Thread` as well.
Parameters
----------
allow_thread_owner: bool
If ``True``, the command will also be allowed to run if the bot is a thread owner.
This can, for example, be useful to check if the bot can edit a channel/thread's name
as that, in addition to members with manage channel/threads permission,
can also be done by the thread owner.
"""
def predicate(ctx: "Context") -> bool:
if ctx.guild is None:
return False
if not utils.can_manage_channel_in(
ctx.channel, ctx.me, allow_thread_owner=allow_thread_owner
):
if isinstance(ctx.channel, discord.Thread):
# This is a slight lie - thread owner *might* also be allowed
# but we just say that bot is missing the Manage Threads permission.
missing = discord.Permissions(manage_threads=True)
else:
missing = discord.Permissions(manage_channels=True)
raise BotMissingPermissions(missing=missing)
return True
return check(predicate)
def bot_can_react() -> Callable[[_T], _T]:
"""
Complain if the bot is missing permissions to react.
This check properly resolves the permissions for `discord.Thread` as well.
"""
async def predicate(ctx: "Context") -> bool:
return not (isinstance(ctx.channel, discord.Thread) and ctx.channel.archived)
def decorator(func: _T) -> _T:
func = bot_has_permissions(read_message_history=True, add_reactions=True)(func)
func = check(predicate)(func)
return func
return decorator
def _can_manage_channel_deco(
privilege_level: Optional[PrivilegeLevel] = None, allow_thread_owner: bool = False
) -> Callable[[_T], _T]:
async def predicate(ctx: "Context") -> bool:
if utils.can_manage_channel_in(
ctx.channel, ctx.author, allow_thread_owner=allow_thread_owner
):
return True
if privilege_level is not None:
if await PrivilegeLevel.from_ctx(ctx) >= privilege_level:
return True
return False
return permissions_check(predicate)
def has_permissions(**perms: bool):
"""Restrict the command to users with these permissions.
@ -741,6 +822,24 @@ def has_permissions(**perms: bool):
return Requires.get_decorator(None, perms)
def can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]:
"""Restrict the command to users with permissions to manage channel.
This check properly resolves the permissions for `discord.Thread` as well.
This check can be overridden by rules.
Parameters
----------
allow_thread_owner: bool
If ``True``, the command will also be allowed to run if the author is a thread owner.
This can, for example, be useful to check if the author can edit a channel/thread's name
as that, in addition to members with manage channel/threads permission,
can also be done by the thread owner.
"""
return _can_manage_channel_deco(allow_thread_owner)
def is_owner():
"""Restrict the command to bot owners.
@ -757,6 +856,24 @@ def guildowner_or_permissions(**perms: bool):
return Requires.get_decorator(PrivilegeLevel.GUILD_OWNER, perms)
def guildowner_or_can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]:
"""Restrict the command to the guild owner or user with permissions to manage channel.
This check properly resolves the permissions for `discord.Thread` as well.
This check can be overridden by rules.
Parameters
----------
allow_thread_owner: bool
If ``True``, the command will also be allowed to run if the author is a thread owner.
This can, for example, be useful to check if the author can edit a channel/thread's name
as that, in addition to members with manage channel/threads permission,
can also be done by the thread owner.
"""
return _can_manage_channel_deco(PrivilegeLevel.GUILD_OWNER, allow_thread_owner)
def guildowner():
"""Restrict the command to the guild owner.
@ -773,6 +890,24 @@ def admin_or_permissions(**perms: bool):
return Requires.get_decorator(PrivilegeLevel.ADMIN, perms)
def admin_or_can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]:
"""Restrict the command to users with the admin role or permissions to manage channel.
This check properly resolves the permissions for `discord.Thread` as well.
This check can be overridden by rules.
Parameters
----------
allow_thread_owner: bool
If ``True``, the command will also be allowed to run if the author is a thread owner.
This can, for example, be useful to check if the author can edit a channel/thread's name
as that, in addition to members with manage channel/threads permission,
can also be done by the thread owner.
"""
return _can_manage_channel_deco(PrivilegeLevel.ADMIN, allow_thread_owner)
def admin():
"""Restrict the command to users with the admin role.
@ -789,6 +924,24 @@ def mod_or_permissions(**perms: bool):
return Requires.get_decorator(PrivilegeLevel.MOD, perms)
def mod_or_can_manage_channel(*, allow_thread_owner: bool = False) -> Callable[[_T], _T]:
"""Restrict the command to users with the mod role or permissions to manage channel.
This check properly resolves the permissions for `discord.Thread` as well.
This check can be overridden by rules.
Parameters
----------
allow_thread_owner: bool
If ``True``, the command will also be allowed to run if the author is a thread owner.
This can, for example, be useful to check if the author can edit a channel/thread's name
as that, in addition to members with manage channel/threads permission,
can also be done by the thread owner.
"""
return _can_manage_channel_deco(PrivilegeLevel.MOD, allow_thread_owner)
def mod():
"""Restrict the command to users with the mod role.

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import sys
import codecs
import logging
import traceback
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import aiohttp
import discord
@ -30,6 +30,7 @@ from .utils._internal_utils import (
expected_version,
fetch_latest_red_version_info,
send_to_owners_with_prefix_replaced,
get_converter,
)
from .utils.chat_formatting import inline, bordered, format_perms_list, humanize_timedelta
@ -70,7 +71,7 @@ def init_events(bot, cli_flags):
guilds = len(bot.guilds)
users = len(set([m for m in bot.get_all_members()]))
invite_url = discord.utils.oauth_url(bot._app_info.id)
invite_url = discord.utils.oauth_url(bot._app_info.id, scopes=("bot",))
prefixes = cli_flags.prefix or (await bot._config.prefix())
lang = await bot._config.locale()
@ -219,7 +220,16 @@ def init_events(bot, cli_flags):
await ctx.send(msg)
if error.send_cmd_help:
await ctx.send_help()
elif isinstance(error, commands.ConversionFailure):
elif isinstance(error, commands.BadArgument):
if isinstance(error.__cause__, ValueError):
converter = get_converter(ctx.current_parameter)
argument = ctx.current_argument
if converter is int:
await ctx.send(_('"{argument}" is not an integer.').format(argument=argument))
return
if converter is float:
await ctx.send(_('"{argument}" is not a number.').format(argument=argument))
return
if error.args:
await ctx.send(error.args[0])
else:
@ -330,7 +340,7 @@ def init_events(bot, cli_flags):
log.exception(type(error).__name__, exc_info=error)
@bot.event
async def on_message(message):
async def on_message(message, /):
await set_contextual_locales_from_guild(bot, message.guild)
await bot.process_commands(message)
@ -339,7 +349,7 @@ def init_events(bot, cli_flags):
not bot._checked_time_accuracy
or (discord_now - timedelta(minutes=60)) > bot._checked_time_accuracy
):
system_now = datetime.utcnow()
system_now = datetime.now(timezone.utc)
diff = abs((discord_now - system_now).total_seconds())
if diff > 60:
log.warning(

View File

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

View File

@ -150,7 +150,7 @@ class IgnoreManager:
self._cached_guilds: Dict[int, bool] = {}
async def get_ignored_channel(
self, channel: discord.TextChannel, check_category: bool = True
self, channel: Union[discord.TextChannel, discord.Thread], check_category: bool = True
) -> bool:
ret: bool
@ -176,7 +176,9 @@ class IgnoreManager:
return ret
async def set_ignored_channel(
self, channel: Union[discord.TextChannel, discord.CategoryChannel], set_to: bool
self,
channel: Union[discord.TextChannel, discord.Thread, discord.CategoryChannel],
set_to: bool,
):
cid: int = channel.id
self._cached_channels[cid] = set_to

View File

@ -7,6 +7,7 @@ from asyncio.futures import isfuture
from itertools import chain
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
AsyncIterable,
@ -15,16 +16,27 @@ from typing import (
Iterable,
Iterator,
List,
Literal,
NoReturn,
Optional,
Tuple,
TypeVar,
Union,
Generator,
Coroutine,
overload,
)
import discord
from discord.ext import commands as dpy_commands
from discord.utils import maybe_coroutine
from redbot.core import commands
if TYPE_CHECKING:
GuildMessageable = Union[commands.GuildContext, discord.abc.GuildChannel, discord.Thread]
DMMessageable = Union[commands.DMContext, discord.Member, discord.User, discord.DMChannel]
__all__ = (
"bounded_gather",
"bounded_gather_iter",
@ -32,6 +44,9 @@ __all__ = (
"AsyncIter",
"get_end_user_data_statement",
"get_end_user_data_statement_or_raise",
"can_user_send_messages_in",
"can_user_manage_channel",
"can_user_react_in",
)
log = logging.getLogger("red.core.utils")
@ -532,7 +547,7 @@ def get_end_user_data_statement(file: Union[Path, str]) -> Optional[str]:
>>> # In cog's `__init__.py`
>>> from redbot.core.utils import get_end_user_data_statement
>>> __red_end_user_data_statement__ = get_end_user_data_statement(__file__)
>>> def setup(bot):
>>> async def setup(bot):
... ...
"""
try:
@ -590,3 +605,209 @@ def get_end_user_data_statement_or_raise(file: Union[Path, str]) -> str:
info_json = file / "info.json"
with info_json.open(encoding="utf-8") as fp:
return json.load(fp)["end_user_data_statement"]
@overload
def can_user_send_messages_in(
obj: discord.abc.User, messageable: discord.PartialMessageable, /
) -> NoReturn:
...
@overload
def can_user_send_messages_in(obj: discord.Member, messageable: GuildMessageable, /) -> bool:
...
@overload
def can_user_send_messages_in(obj: discord.User, messageable: DMMessageable, /) -> Literal[True]:
...
def can_user_send_messages_in(
obj: discord.abc.User, messageable: discord.abc.Messageable, /
) -> bool:
"""
Checks if a user/member can send messages in the given messageable.
This function properly resolves the permissions for `discord.Thread` as well.
.. note::
Without making an API request, it is not possible to reliably detect
whether a guild member (who is NOT current bot user) can send messages in a private thread.
If it's essential for you to reliably detect this, you will need to
try fetching the thread member:
.. code::
can_send_messages = can_user_send_messages_in(member, thread)
if thread.is_private() and not thread.permissions_for(member).manage_threads:
try:
await thread.fetch_member(member.id)
except discord.NotFound:
can_send_messages = False
Parameters
----------
obj: discord.abc.User
The user or member to check permissions for.
If passed ``messageable`` resolves to a guild channel/thread,
this needs to be an instance of `discord.Member`.
messageable: discord.abc.Messageable
The messageable object to check permissions for.
If this resolves to a DM/group channel, this function will return ``True``.
Returns
-------
bool
Whether the user can send messages in the given messageable.
Raises
------
TypeError
When the passed channel is of type `discord.PartialMessageable`.
"""
channel = messageable.channel if isinstance(messageable, dpy_commands.Context) else messageable
if isinstance(channel, discord.PartialMessageable):
# If we have a partial messageable, we sadly can't do much...
raise TypeError("Can't check permissions for PartialMessageable.")
if isinstance(channel, discord.abc.User):
# Unlike DMChannel, abc.User subclasses do not have `permissions_for()`.
return True
perms = channel.permissions_for(obj)
if isinstance(channel, discord.Thread):
return (
perms.send_messages_in_threads
and (not channel.locked or perms.manage_threads)
# For private threads, the only way to know if user can send messages would be to check
# if they're a member of it which we cannot reliably do without an API request.
#
# and (not channel.is_private() or "obj is thread member" or perms.manage_threads)
)
return perms.send_messages
def can_user_manage_channel(
obj: discord.Member,
channel: Union[discord.abc.GuildChannel, discord.Thread],
/,
allow_thread_owner: bool = False,
) -> bool:
"""
Checks if a guild member can manage the given channel.
This function properly resolves the permissions for `discord.Thread` as well.
Parameters
----------
obj: discord.Member
The guild member to check permissions for.
If passed ``messageable`` resolves to a guild channel/thread,
this needs to be an instance of `discord.Member`.
channel: Union[discord.abc.GuildChannel, discord.Thread]
The messageable object to check permissions for.
If this resolves to a DM/group channel, this function will return ``True``.
allow_thread_owner: bool
If ``True``, the function will also return ``True`` if the given member is a thread owner.
This can, for example, be useful to check if the member can edit a channel/thread's name
as that, in addition to members with manage channel/threads permission,
can also be done by the thread owner.
Returns
-------
bool
Whether the user can manage the given channel.
"""
perms = channel.permissions_for(obj)
if isinstance(channel, discord.Thread):
return perms.manage_threads or (allow_thread_owner and channel.owner_id == obj.id)
return perms.manage_channels
@overload
def can_user_react_in(
obj: discord.abc.User, messageable: discord.PartialMessageable, /
) -> NoReturn:
...
@overload
def can_user_react_in(obj: discord.Member, messageable: GuildMessageable, /) -> bool:
...
@overload
def can_user_react_in(obj: discord.User, messageable: DMMessageable, /) -> Literal[True]:
...
def can_user_react_in(obj: discord.abc.User, messageable: discord.abc.Messageable, /) -> bool:
"""
Checks if a user/guild member can react in the given messageable.
This function properly resolves the permissions for `discord.Thread` as well.
.. note::
Without making an API request, it is not possible to reliably detect
whether a guild member (who is NOT current bot user) can react in a private thread.
If it's essential for you to reliably detect this, you will need to
try fetching the thread member:
.. code::
can_react = can_user_react_in(member, thread)
if thread.is_private() and not thread.permissions_for(member).manage_threads:
try:
await thread.fetch_member(member.id)
except discord.NotFound:
can_react = False
Parameters
----------
obj: discord.abc.User
The user or member to check permissions for.
If passed ``messageable`` resolves to a guild channel/thread,
this needs to be an instance of `discord.Member`.
messageable: discord.abc.Messageable
The messageable object to check permissions for.
If this resolves to a DM/group channel, this function will return ``True``.
Returns
-------
bool
Whether the user can send messages in the given messageable.
Raises
------
TypeError
When the passed channel is of type `discord.PartialMessageable`.
"""
channel = messageable.channel if isinstance(messageable, dpy_commands.Context) else messageable
if isinstance(channel, discord.PartialMessageable):
# If we have a partial messageable, we sadly can't do much...
raise TypeError("Can't check permissions for PartialMessageable.")
if isinstance(channel, discord.abc.User):
# Unlike DMChannel, abc.User subclasses do not have `permissions_for()`.
return True
perms = channel.permissions_for(obj)
if isinstance(channel, discord.Thread):
return (
(perms.read_message_history and perms.add_reactions)
and not channel.archived
# For private threads, the only way to know if user can send messages would be to check
# if they're a member of it which we cannot reliably do without an API request.
#
# and (not channel.is_private() or perms.manage_threads or "obj is thread member")
)
return perms.read_message_history and perms.add_reactions

View File

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

View File

@ -106,7 +106,10 @@ async def menu(
if not ctx.me:
return
try:
if message.channel.permissions_for(ctx.me).manage_messages:
if (
isinstance(message.channel, discord.PartialMessageable)
or message.channel.permissions_for(ctx.me).manage_messages
):
await message.clear_reactions()
else:
raise RuntimeError

View File

@ -9,7 +9,9 @@ if TYPE_CHECKING:
from ..commands import Context
async def mass_purge(messages: List[discord.Message], channel: discord.TextChannel):
async def mass_purge(
messages: List[discord.Message], channel: Union[discord.TextChannel, discord.Thread]
):
"""Bulk delete messages from a channel.
If more than 100 messages are supplied, the bot will delete 100 messages at
@ -24,7 +26,7 @@ async def mass_purge(messages: List[discord.Message], channel: discord.TextChann
----------
messages : `list` of `discord.Message`
The messages to bulk delete.
channel : discord.TextChannel
channel : `discord.TextChannel` or `discord.Thread`
The channel to delete messages from.
Raises

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