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
104 changed files with 1427 additions and 999 deletions

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

View File

@@ -67,7 +67,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def same_context(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the message fits the described context.
@@ -76,7 +76,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
The current invocation context.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
The channel we expect a message in. If unspecified,
defaults to ``ctx.channel``. If ``ctx`` is unspecified
too, the message's channel will be ignored.
@@ -104,7 +104,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def cancelled(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the message is ``[p]cancel``.
@@ -113,7 +113,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -133,7 +133,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def yes_or_no(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the message is "yes"/"y" or "no"/"n".
@@ -145,7 +145,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -176,7 +176,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def valid_int(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response is an integer.
@@ -187,7 +187,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -216,7 +216,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def valid_float(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response is a float.
@@ -227,7 +227,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -256,7 +256,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def positive(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response is a positive number.
@@ -267,7 +267,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -300,7 +300,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def valid_role(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[discord.TextChannel] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response refers to a role in the current guild.
@@ -313,7 +313,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -344,7 +344,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def valid_member(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[discord.TextChannel] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response refers to a member in the current guild.
@@ -357,7 +357,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -392,7 +392,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def valid_text_channel(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[discord.TextChannel] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response refers to a text channel in the current guild.
@@ -405,7 +405,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -440,7 +440,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
def has_role(
cls,
ctx: Optional[commands.Context] = None,
channel: Optional[discord.TextChannel] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response refers to a role which the author has.
@@ -454,7 +454,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
----------
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -492,7 +492,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
cls,
value: str,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response is equal to the specified value.
@@ -503,7 +503,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
The value to compare the response with.
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -522,7 +522,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
cls,
value: str,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response *as lowercase* is equal to the specified value.
@@ -533,7 +533,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
The value to compare the response with.
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -552,7 +552,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
cls,
value: Union[int, float],
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response is less than the specified value.
@@ -563,7 +563,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
The value to compare the response with.
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -583,7 +583,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
cls,
value: Union[int, float],
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response is greater than the specified value.
@@ -594,7 +594,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
The value to compare the response with.
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -614,7 +614,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
cls,
length: int,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response's length is less than the specified length.
@@ -625,7 +625,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
The value to compare the response's length with.
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -644,7 +644,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
cls,
length: int,
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response's length is greater than the specified length.
@@ -655,7 +655,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
The value to compare the response's length with.
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -674,7 +674,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
cls,
collection: Sequence[str],
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response is contained in the specified collection.
@@ -688,7 +688,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
The collection containing valid responses.
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -718,7 +718,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
cls,
collection: Sequence[str],
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Same as :meth:`contained_in`, but the response is set to lowercase before matching.
@@ -729,7 +729,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
The collection containing valid lowercase responses.
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -759,7 +759,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
cls,
pattern: Union[Pattern[str], str],
ctx: Optional[commands.Context] = None,
channel: Optional[Union[discord.TextChannel, discord.DMChannel]] = None,
channel: Optional[Union[discord.TextChannel, discord.Thread, discord.DMChannel]] = None,
user: Optional[discord.abc.User] = None,
) -> "MessagePredicate":
"""Match if the response matches the specified regex pattern.
@@ -774,7 +774,7 @@ class MessagePredicate(Callable[[discord.Message], bool]):
The pattern to search for in the response.
ctx : Optional[Context]
Same as ``ctx`` in :meth:`same_context`.
channel : Optional[discord.TextChannel]
channel : Optional[Union[`discord.TextChannel`, `discord.Thread`, `discord.DMChannel`]]
Same as ``channel`` in :meth:`same_context`.
user : Optional[discord.abc.User]
Same as ``user`` in :meth:`same_context`.
@@ -815,7 +815,9 @@ class MessagePredicate(Callable[[discord.Message], bool]):
@staticmethod
def _get_guild(
ctx: commands.Context, channel: discord.TextChannel, user: discord.Member
ctx: Optional[commands.Context],
channel: Optional[Union[discord.TextChannel, discord.Thread]],
user: Optional[discord.Member],
) -> discord.Guild:
if ctx is not None:
return ctx.guild
@@ -930,6 +932,7 @@ class ReactionPredicate(Callable[[discord.Reaction, discord.abc.User], bool]):
"""
# noinspection PyProtectedMember
# DEP-WARN
me_id = message._state.self_id
return cls(
lambda self, r, u: u.id != me_id

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from redbot.core.utils.chat_formatting import pagify
import io
import weakref
from typing import List, Optional
from typing import List, Optional, Union
from .common_filters import filter_mass_mentions
_instances = weakref.WeakValueDictionary({})
@@ -57,14 +57,18 @@ class Tunnel(metaclass=TunnelMeta):
----------
sender: `discord.Member`
The person who opened the tunnel
origin: `discord.TextChannel`
origin: `discord.TextChannel` or `discord.Thread`
The channel in which it was opened
recipient: `discord.User`
The user on the other end of the tunnel
"""
def __init__(
self, *, sender: discord.Member, origin: discord.TextChannel, recipient: discord.User
self,
*,
sender: discord.Member,
origin: Union[discord.TextChannel, discord.Thread],
recipient: discord.User,
):
self.sender = sender
self.origin = origin
@@ -219,9 +223,9 @@ class Tunnel(metaclass=TunnelMeta):
the bot can't upload at the origin channel
or can't add reactions there.
"""
if message.channel == self.origin and message.author == self.sender:
if message.channel.id == self.origin.id and message.author == self.sender:
send_to = self.recipient
elif message.author == self.recipient and isinstance(message.channel, discord.DMChannel):
elif message.author == self.recipient and message.guild is None:
send_to = self.origin
else:
return None