Browse Source

Merge branch 'master' into feature/guild_onboarding

pull/9260/head
Josh 2 years ago
parent
commit
b213863412
  1. 5
      .readthedocs.yml
  2. 4
      discord/__init__.py
  3. 13
      discord/abc.py
  4. 33
      discord/app_commands/commands.py
  5. 2
      discord/app_commands/transformers.py
  6. 4
      discord/audit_logs.py
  7. 10
      discord/channel.py
  8. 14
      discord/client.py
  9. 10
      discord/colour.py
  10. 4
      discord/components.py
  11. 1
      discord/enums.py
  12. 2
      discord/ext/commands/cog.py
  13. 8
      discord/ext/commands/context.py
  14. 65
      discord/ext/commands/converter.py
  15. 12
      discord/ext/commands/core.py
  16. 8
      discord/ext/commands/errors.py
  17. 5
      discord/ext/commands/help.py
  18. 27
      discord/ext/commands/parameters.py
  19. 13
      discord/ext/tasks/__init__.py
  20. 68
      discord/flags.py
  21. 168
      discord/guild.py
  22. 7
      discord/http.py
  23. 74
      discord/interactions.py
  24. 25
      discord/member.py
  25. 23
      discord/message.py
  26. 4
      discord/opus.py
  27. 2
      discord/partial_emoji.py
  28. 59
      discord/permissions.py
  29. 3
      discord/player.py
  30. 1
      discord/reaction.py
  31. 8
      discord/state.py
  32. 10
      discord/team.py
  33. 10
      discord/types/channel.py
  34. 1
      discord/types/guild.py
  35. 3
      discord/types/interactions.py
  36. 2
      discord/types/message.py
  37. 3
      discord/types/user.py
  38. 1
      discord/ui/modal.py
  39. 4
      discord/ui/select.py
  40. 3
      discord/ui/text_input.py
  41. 56
      discord/user.py
  42. 12
      discord/utils.py
  43. 17
      discord/webhook/async_.py
  44. 5
      discord/webhook/sync.py
  45. 14
      discord/widget.py
  46. 19
      docs/api.rst
  47. 2
      docs/conf.py
  48. 2
      docs/discord.rst
  49. 2
      docs/ext/commands/extensions.rst
  50. 14
      docs/migrating.rst
  51. 102
      docs/whats_new.rst
  52. 43
      tests/test_app_commands_group.py

5
.readthedocs.yml

@ -2,7 +2,9 @@ version: 2
formats: [] formats: []
build: build:
image: latest os: "ubuntu-22.04"
tools:
python: "3.8"
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py
@ -10,7 +12,6 @@ sphinx:
builder: html builder: html
python: python:
version: 3.8
install: install:
- method: pip - method: pip
path: . path: .

4
discord/__init__.py

@ -13,7 +13,7 @@ __title__ = 'discord'
__author__ = 'Rapptz' __author__ = 'Rapptz'
__license__ = 'MIT' __license__ = 'MIT'
__copyright__ = 'Copyright 2015-present Rapptz' __copyright__ = 'Copyright 2015-present Rapptz'
__version__ = '2.3.0a' __version__ = '2.4.0a'
__path__ = __import__('pkgutil').extend_path(__path__, __name__) __path__ = __import__('pkgutil').extend_path(__path__, __name__)
@ -79,7 +79,7 @@ class VersionInfo(NamedTuple):
serial: int serial: int
version_info: VersionInfo = VersionInfo(major=2, minor=3, micro=0, releaselevel='alpha', serial=0) version_info: VersionInfo = VersionInfo(major=2, minor=4, micro=0, releaselevel='alpha', serial=0)
logging.getLogger(__name__).addHandler(logging.NullHandler()) logging.getLogger(__name__).addHandler(logging.NullHandler())

13
discord/abc.py

@ -219,7 +219,9 @@ class User(Snowflake, Protocol):
name: :class:`str` name: :class:`str`
The user's username. The user's username.
discriminator: :class:`str` discriminator: :class:`str`
The user's discriminator. The user's discriminator. This is a legacy concept that is no longer used.
global_name: Optional[:class:`str`]
The user's global nickname.
bot: :class:`bool` bot: :class:`bool`
If the user is a bot account. If the user is a bot account.
system: :class:`bool` system: :class:`bool`
@ -228,6 +230,7 @@ class User(Snowflake, Protocol):
name: str name: str
discriminator: str discriminator: str
global_name: Optional[str]
bot: bool bot: bool
system: bool system: bool
@ -248,7 +251,7 @@ class User(Snowflake, Protocol):
@property @property
def default_avatar(self) -> Asset: def default_avatar(self) -> Asset:
""":class:`~discord.Asset`: Returns the default avatar for a given user. This is calculated by the user's discriminator.""" """:class:`~discord.Asset`: Returns the default avatar for a given user."""
raise NotImplementedError raise NotImplementedError
@property @property
@ -943,8 +946,6 @@ class GuildChannel:
if len(permissions) > 0: if len(permissions) > 0:
raise TypeError('Cannot mix overwrite and keyword arguments.') raise TypeError('Cannot mix overwrite and keyword arguments.')
# TODO: wait for event
if overwrite is None: if overwrite is None:
await http.delete_channel_permissions(self.id, target.id, reason=reason) await http.delete_channel_permissions(self.id, target.id, reason=reason)
elif isinstance(overwrite, PermissionOverwrite): elif isinstance(overwrite, PermissionOverwrite):
@ -1722,12 +1723,12 @@ class Messageable:
async def _around_strategy(retrieve: int, around: Optional[Snowflake], limit: Optional[int]): async def _around_strategy(retrieve: int, around: Optional[Snowflake], limit: Optional[int]):
if not around: if not around:
return [] return [], None, 0
around_id = around.id if around else None around_id = around.id if around else None
data = await self._state.http.logs_from(channel.id, retrieve, around=around_id) data = await self._state.http.logs_from(channel.id, retrieve, around=around_id)
return data, None, limit return data, None, 0
async def _after_strategy(retrieve: int, after: Optional[Snowflake], limit: Optional[int]): async def _after_strategy(retrieve: int, after: Optional[Snowflake], limit: Optional[int]):
after_id = after.id if after else None after_id = after.id if after else None

33
discord/app_commands/commands.py

@ -46,6 +46,7 @@ from typing import (
) )
import re import re
from copy import copy as shallow_copy
from ..enums import AppCommandOptionType, AppCommandType, ChannelType, Locale from ..enums import AppCommandOptionType, AppCommandType, ChannelType, Locale
from .models import Choice from .models import Choice
@ -707,25 +708,10 @@ class Command(Generic[GroupT, P, T]):
) -> Command: ) -> Command:
bindings = {} if bindings is MISSING else bindings bindings = {} if bindings is MISSING else bindings
cls = self.__class__ copy = shallow_copy(self)
copy = cls.__new__(cls)
copy.name = self.name
copy._locale_name = self._locale_name
copy._guild_ids = self._guild_ids
copy.checks = self.checks
copy.description = self.description
copy._locale_description = self._locale_description
copy.default_permissions = self.default_permissions
copy.guild_only = self.guild_only
copy.nsfw = self.nsfw
copy._attr = self._attr
copy._callback = self._callback
copy.on_error = self.on_error
copy._params = self._params.copy() copy._params = self._params.copy()
copy.module = self.module
copy.parent = parent copy.parent = parent
copy.binding = bindings.get(self.binding) if self.binding is not None else binding copy.binding = bindings.get(self.binding) if self.binding is not None else binding
copy.extras = self.extras
if copy._attr and set_on_binding: if copy._attr and set_on_binding:
setattr(copy.binding, copy._attr, copy) setattr(copy.binding, copy._attr, copy)
@ -1622,22 +1608,9 @@ class Group:
) -> Group: ) -> Group:
bindings = {} if bindings is MISSING else bindings bindings = {} if bindings is MISSING else bindings
cls = self.__class__ copy = shallow_copy(self)
copy = cls.__new__(cls)
copy.name = self.name
copy._locale_name = self._locale_name
copy._guild_ids = self._guild_ids
copy.description = self.description
copy._locale_description = self._locale_description
copy.parent = parent copy.parent = parent
copy.module = self.module
copy.default_permissions = self.default_permissions
copy.guild_only = self.guild_only
copy.nsfw = self.nsfw
copy._attr = self._attr
copy._owner_cls = self._owner_cls
copy._children = {} copy._children = {}
copy.extras = self.extras
bindings[self] = copy bindings[self] = copy

2
discord/app_commands/transformers.py

@ -750,7 +750,7 @@ def get_supported_annotation(
try: try:
return (_mapping[annotation], MISSING, True) return (_mapping[annotation], MISSING, True)
except KeyError: except (KeyError, TypeError):
pass pass
if isinstance(annotation, Transformer): if isinstance(annotation, Transformer):

4
discord/audit_logs.py

@ -669,7 +669,9 @@ class AuditLogEntry(Hashable):
): ):
channel_id = utils._get_as_snowflake(extra, 'channel_id') channel_id = utils._get_as_snowflake(extra, 'channel_id')
channel = None channel = None
if channel_id is not None:
# May be an empty string instead of None due to a Discord issue
if channel_id:
channel = self.guild.get_channel_or_thread(channel_id) or Object(id=channel_id) channel = self.guild.get_channel_or_thread(channel_id) or Object(id=channel_id)
self.extra = _AuditLogProxyAutoModAction( self.extra = _AuditLogProxyAutoModAction(

10
discord/channel.py

@ -1687,6 +1687,7 @@ class StageChannel(VocalGuildChannel):
*, *,
name: str = ..., name: str = ...,
nsfw: bool = ..., nsfw: bool = ...,
user_limit: int = ...,
position: int = ..., position: int = ...,
sync_permissions: int = ..., sync_permissions: int = ...,
category: Optional[CategoryChannel] = ..., category: Optional[CategoryChannel] = ...,
@ -1726,6 +1727,8 @@ class StageChannel(VocalGuildChannel):
The new channel's position. The new channel's position.
nsfw: :class:`bool` nsfw: :class:`bool`
To mark the channel as NSFW or not. To mark the channel as NSFW or not.
user_limit: :class:`int`
The new channel's user limit.
sync_permissions: :class:`bool` sync_permissions: :class:`bool`
Whether to sync permissions with the channel's new or pre-existing Whether to sync permissions with the channel's new or pre-existing
category. Defaults to ``False``. category. Defaults to ``False``.
@ -2887,7 +2890,12 @@ class DMChannel(discord.abc.Messageable, discord.abc.PrivateChannel, Hashable):
def __init__(self, *, me: ClientUser, state: ConnectionState, data: DMChannelPayload): def __init__(self, *, me: ClientUser, state: ConnectionState, data: DMChannelPayload):
self._state: ConnectionState = state self._state: ConnectionState = state
self.recipient: Optional[User] = state.store_user(data['recipients'][0]) self.recipient: Optional[User] = None
recipients = data.get('recipients')
if recipients is not None:
self.recipient = state.store_user(recipients[0])
self.me: ClientUser = me self.me: ClientUser = me
self.id: int = int(data['id']) self.id: int = int(data['id'])

14
discord/client.py

@ -2059,13 +2059,15 @@ class Client:
limit: Optional[int] = 200, limit: Optional[int] = 200,
before: Optional[SnowflakeTime] = None, before: Optional[SnowflakeTime] = None,
after: Optional[SnowflakeTime] = None, after: Optional[SnowflakeTime] = None,
with_counts: bool = True,
) -> AsyncIterator[Guild]: ) -> AsyncIterator[Guild]:
"""Retrieves an :term:`asynchronous iterator` that enables receiving your guilds. """Retrieves an :term:`asynchronous iterator` that enables receiving your guilds.
.. note:: .. note::
Using this, you will only receive :attr:`.Guild.owner`, :attr:`.Guild.icon`, Using this, you will only receive :attr:`.Guild.owner`, :attr:`.Guild.icon`,
:attr:`.Guild.id`, and :attr:`.Guild.name` per :class:`.Guild`. :attr:`.Guild.id`, :attr:`.Guild.name`, :attr:`.Guild.approximate_member_count`,
and :attr:`.Guild.approximate_presence_count` per :class:`.Guild`.
.. note:: .. note::
@ -2106,6 +2108,12 @@ class Client:
Retrieve guilds after this date or object. Retrieve guilds after this date or object.
If a datetime is provided, it is recommended to use a UTC aware datetime. If a datetime is provided, it is recommended to use a UTC aware datetime.
If the datetime is naive, it is assumed to be local time. If the datetime is naive, it is assumed to be local time.
with_counts: :class:`bool`
Whether to include count information in the guilds. This fills the
:attr:`.Guild.approximate_member_count` and :attr:`.Guild.approximate_presence_count`
attributes without needing any privileged intents. Defaults to ``True``.
.. versionadded:: 2.3
Raises Raises
------ ------
@ -2120,7 +2128,7 @@ class Client:
async def _before_strategy(retrieve: int, before: Optional[Snowflake], limit: Optional[int]): async def _before_strategy(retrieve: int, before: Optional[Snowflake], limit: Optional[int]):
before_id = before.id if before else None before_id = before.id if before else None
data = await self.http.get_guilds(retrieve, before=before_id) data = await self.http.get_guilds(retrieve, before=before_id, with_counts=with_counts)
if data: if data:
if limit is not None: if limit is not None:
@ -2132,7 +2140,7 @@ class Client:
async def _after_strategy(retrieve: int, after: Optional[Snowflake], limit: Optional[int]): async def _after_strategy(retrieve: int, after: Optional[Snowflake], limit: Optional[int]):
after_id = after.id if after else None after_id = after.id if after else None
data = await self.http.get_guilds(retrieve, after=after_id) data = await self.http.get_guilds(retrieve, after=after_id, with_counts=with_counts)
if data: if data:
if limit is not None: if limit is not None:

10
discord/colour.py

@ -509,5 +509,15 @@ class Colour:
""" """
return cls(0xEEEFF1) return cls(0xEEEFF1)
@classmethod
def pink(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xEB459F``.
.. colour:: #EB459F
.. versionadded:: 2.3
"""
return cls(0xEB459F)
Color = Colour Color = Colour

4
discord/components.py

@ -527,7 +527,7 @@ def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, Acti
return ActionRow(data) return ActionRow(data)
elif data['type'] == 2: elif data['type'] == 2:
return Button(data) return Button(data)
elif data['type'] == 3:
return SelectMenu(data)
elif data['type'] == 4: elif data['type'] == 4:
return TextInput(data) return TextInput(data)
elif data['type'] in (3, 5, 6, 7, 8):
return SelectMenu(data)

1
discord/enums.py

@ -296,6 +296,7 @@ class DefaultAvatar(Enum):
green = 2 green = 2
orange = 3 orange = 3
red = 4 red = 4
pink = 5
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name

2
discord/ext/commands/cog.py

@ -550,6 +550,8 @@ class Cog(metaclass=CogMeta):
Subclasses must replace this if they want special unloading behaviour. Subclasses must replace this if they want special unloading behaviour.
Exceptions raised in this method are ignored during extension unloading.
.. versionchanged:: 2.0 .. versionchanged:: 2.0
This method can now be a :term:`coroutine`. This method can now be a :term:`coroutine`.

8
discord/ext/commands/context.py

@ -430,6 +430,14 @@ class Context(discord.abc.Messageable, Generic[BotT]):
return None return None
return self.command.cog return self.command.cog
@property
def filesize_limit(self) -> int:
""":class:`int`: Returns the maximum number of bytes files can have when uploaded to this guild or DM channel associated with this context.
.. versionadded:: 2.3
"""
return self.guild.filesize_limit if self.guild is not None else discord.utils.DEFAULT_FILE_SIZE_LIMIT_BYTES
@discord.utils.cached_property @discord.utils.cached_property
def guild(self) -> Optional[Guild]: def guild(self) -> Optional[Guild]:
"""Optional[:class:`.Guild`]: Returns the guild associated with this context's command. None if not available.""" """Optional[:class:`.Guild`]: Returns the guild associated with this context's command. None if not available."""

65
discord/ext/commands/converter.py

@ -186,9 +186,11 @@ class MemberConverter(IDConverter[discord.Member]):
1. Lookup by ID. 1. Lookup by ID.
2. Lookup by mention. 2. Lookup by mention.
3. Lookup by name#discrim 3. Lookup by username#discriminator (deprecated).
4. Lookup by name 4. Lookup by username#0 (deprecated, only gets users that migrated from their discriminator).
5. Lookup by nickname 5. Lookup by user name.
6. Lookup by global name.
7. Lookup by guild nickname.
.. versionchanged:: 1.5 .. versionchanged:: 1.5
Raise :exc:`.MemberNotFound` instead of generic :exc:`.BadArgument` Raise :exc:`.MemberNotFound` instead of generic :exc:`.BadArgument`
@ -196,17 +198,29 @@ class MemberConverter(IDConverter[discord.Member]):
.. versionchanged:: 1.5.1 .. versionchanged:: 1.5.1
This converter now lazily fetches members from the gateway and HTTP APIs, This converter now lazily fetches members from the gateway and HTTP APIs,
optionally caching the result if :attr:`.MemberCacheFlags.joined` is enabled. optionally caching the result if :attr:`.MemberCacheFlags.joined` is enabled.
.. deprecated:: 2.3
Looking up users by discriminator will be removed in a future version due to
the removal of discriminators in an API change.
""" """
async def query_member_named(self, guild: discord.Guild, argument: str) -> Optional[discord.Member]: async def query_member_named(self, guild: discord.Guild, argument: str) -> Optional[discord.Member]:
cache = guild._state.member_cache_flags.joined cache = guild._state.member_cache_flags.joined
if len(argument) > 5 and argument[-5] == '#': username, _, discriminator = argument.rpartition('#')
username, _, discriminator = argument.rpartition('#')
members = await guild.query_members(username, limit=100, cache=cache) # If # isn't found then "discriminator" actually has the username
return discord.utils.get(members, name=username, discriminator=discriminator) if not username:
discriminator, username = username, discriminator
if discriminator == '0' or (len(discriminator) == 4 and discriminator.isdigit()):
lookup = username
predicate = lambda m: m.name == username and m.discriminator == discriminator
else: else:
members = await guild.query_members(argument, limit=100, cache=cache) lookup = argument
return discord.utils.find(lambda m: m.name == argument or m.nick == argument, members) predicate = lambda m: m.name == argument or m.global_name == argument or m.nick == argument
members = await guild.query_members(lookup, limit=100, cache=cache)
return discord.utils.find(predicate, members)
async def query_member_by_id(self, bot: _Bot, guild: discord.Guild, user_id: int) -> Optional[discord.Member]: async def query_member_by_id(self, bot: _Bot, guild: discord.Guild, user_id: int) -> Optional[discord.Member]:
ws = bot._get_websocket(shard_id=guild.shard_id) ws = bot._get_websocket(shard_id=guild.shard_id)
@ -273,8 +287,10 @@ class UserConverter(IDConverter[discord.User]):
1. Lookup by ID. 1. Lookup by ID.
2. Lookup by mention. 2. Lookup by mention.
3. Lookup by name#discrim 3. Lookup by username#discriminator (deprecated).
4. Lookup by name 4. Lookup by username#0 (deprecated, only gets users that migrated from their discriminator).
5. Lookup by user name.
6. Lookup by global name.
.. versionchanged:: 1.5 .. versionchanged:: 1.5
Raise :exc:`.UserNotFound` instead of generic :exc:`.BadArgument` Raise :exc:`.UserNotFound` instead of generic :exc:`.BadArgument`
@ -282,6 +298,10 @@ class UserConverter(IDConverter[discord.User]):
.. versionchanged:: 1.6 .. versionchanged:: 1.6
This converter now lazily fetches users from the HTTP APIs if an ID is passed This converter now lazily fetches users from the HTTP APIs if an ID is passed
and it's not available in cache. and it's not available in cache.
.. deprecated:: 2.3
Looking up users by discriminator will be removed in a future version due to
the removal of discriminators in an API change.
""" """
async def convert(self, ctx: Context[BotT], argument: str) -> discord.User: async def convert(self, ctx: Context[BotT], argument: str) -> discord.User:
@ -300,25 +320,18 @@ class UserConverter(IDConverter[discord.User]):
return result # type: ignore return result # type: ignore
arg = argument username, _, discriminator = argument.rpartition('#')
# Remove the '@' character if this is the first character from the argument # If # isn't found then "discriminator" actually has the username
if arg[0] == '@': if not username:
# Remove first character discriminator, username = username, discriminator
arg = arg[1:]
# check for discriminator if it exists, if discriminator == '0' or (len(discriminator) == 4 and discriminator.isdigit()):
if len(arg) > 5 and arg[-5] == '#': predicate = lambda u: u.name == username and u.discriminator == discriminator
discrim = arg[-4:] else:
name = arg[:-5] predicate = lambda u: u.name == argument or u.global_name == argument
predicate = lambda u: u.name == name and u.discriminator == discrim
result = discord.utils.find(predicate, state._users.values())
if result is not None:
return result
predicate = lambda u: u.name == arg
result = discord.utils.find(predicate, state._users.values()) result = discord.utils.find(predicate, state._users.values())
if result is None: if result is None:
raise UserNotFound(argument) raise UserNotFound(argument)

12
discord/ext/commands/core.py

@ -151,6 +151,7 @@ def get_signature_parameters(
parameter._default = default.default parameter._default = default.default
parameter._description = default._description parameter._description = default._description
parameter._displayed_default = default._displayed_default parameter._displayed_default = default._displayed_default
parameter._displayed_name = default._displayed_name
annotation = parameter.annotation annotation = parameter.annotation
@ -194,8 +195,13 @@ def extract_descriptions_from_docstring(function: Callable[..., Any], params: Di
description, param_docstring = divide description, param_docstring = divide
for match in NUMPY_DOCSTRING_ARG_REGEX.finditer(param_docstring): for match in NUMPY_DOCSTRING_ARG_REGEX.finditer(param_docstring):
name = match.group('name') name = match.group('name')
if name not in params: if name not in params:
continue is_display_name = discord.utils.get(params.values(), displayed_name=name)
if is_display_name:
name = is_display_name.name
else:
continue
param = params[name] param = params[name]
if param.description is None: if param.description is None:
@ -1169,7 +1175,9 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
return '' return ''
result = [] result = []
for name, param in params.items(): for param in params.values():
name = param.displayed_name or param.name
greedy = isinstance(param.converter, Greedy) greedy = isinstance(param.converter, Greedy)
optional = False # postpone evaluation of if it's an optional argument optional = False # postpone evaluation of if it's an optional argument

8
discord/ext/commands/errors.py

@ -182,7 +182,7 @@ class MissingRequiredArgument(UserInputError):
def __init__(self, param: Parameter) -> None: def __init__(self, param: Parameter) -> None:
self.param: Parameter = param self.param: Parameter = param
super().__init__(f'{param.name} is a required argument that is missing.') super().__init__(f'{param.displayed_name or param.name} is a required argument that is missing.')
class MissingRequiredAttachment(UserInputError): class MissingRequiredAttachment(UserInputError):
@ -201,7 +201,7 @@ class MissingRequiredAttachment(UserInputError):
def __init__(self, param: Parameter) -> None: def __init__(self, param: Parameter) -> None:
self.param: Parameter = param self.param: Parameter = param
super().__init__(f'{param.name} is a required argument that is missing an attachment.') super().__init__(f'{param.displayed_name or param.name} is a required argument that is missing an attachment.')
class TooManyArguments(UserInputError): class TooManyArguments(UserInputError):
@ -901,7 +901,7 @@ class BadUnionArgument(UserInputError):
else: else:
fmt = ' or '.join(to_string) fmt = ' or '.join(to_string)
super().__init__(f'Could not convert "{param.name}" into {fmt}.') super().__init__(f'Could not convert "{param.displayed_name or param.name}" into {fmt}.')
class BadLiteralArgument(UserInputError): class BadLiteralArgument(UserInputError):
@ -938,7 +938,7 @@ class BadLiteralArgument(UserInputError):
else: else:
fmt = ' or '.join(to_string) fmt = ' or '.join(to_string)
super().__init__(f'Could not convert "{param.name}" into the literal {fmt}.') super().__init__(f'Could not convert "{param.displayed_name or param.name}" into the literal {fmt}.')
class ArgumentParsingError(UserInputError): class ArgumentParsingError(UserInputError):

5
discord/ext/commands/help.py

@ -294,6 +294,9 @@ class _HelpCommandImpl(Command):
cog.walk_commands = cog.walk_commands.__wrapped__ cog.walk_commands = cog.walk_commands.__wrapped__
self.cog = None self.cog = None
# Revert `on_error` to use the original one in case of race conditions
self.on_error = self._injected.on_help_command_error
class HelpCommand: class HelpCommand:
r"""The base implementation for help command formatting. r"""The base implementation for help command formatting.
@ -1166,7 +1169,7 @@ class DefaultHelpCommand(HelpCommand):
get_width = discord.utils._string_width get_width = discord.utils._string_width
for argument in arguments: for argument in arguments:
name = argument.name name = argument.displayed_name or argument.name
width = max_size - (get_width(name) - len(name)) width = max_size - (get_width(name) - len(name))
entry = f'{self.indent * " "}{name:<{width}} {argument.description or self.default_argument_description}' entry = f'{self.indent * " "}{name:<{width}} {argument.description or self.default_argument_description}'
# we do not want to shorten the default value, if any. # we do not want to shorten the default value, if any.

27
discord/ext/commands/parameters.py

@ -87,7 +87,7 @@ class Parameter(inspect.Parameter):
.. versionadded:: 2.0 .. versionadded:: 2.0
""" """
__slots__ = ('_displayed_default', '_description', '_fallback') __slots__ = ('_displayed_default', '_description', '_fallback', '_displayed_name')
def __init__( def __init__(
self, self,
@ -97,6 +97,7 @@ class Parameter(inspect.Parameter):
annotation: Any = empty, annotation: Any = empty,
description: str = empty, description: str = empty,
displayed_default: str = empty, displayed_default: str = empty,
displayed_name: str = empty,
) -> None: ) -> None:
super().__init__(name=name, kind=kind, default=default, annotation=annotation) super().__init__(name=name, kind=kind, default=default, annotation=annotation)
self._name = name self._name = name
@ -106,6 +107,7 @@ class Parameter(inspect.Parameter):
self._annotation = annotation self._annotation = annotation
self._displayed_default = displayed_default self._displayed_default = displayed_default
self._fallback = False self._fallback = False
self._displayed_name = displayed_name
def replace( def replace(
self, self,
@ -116,6 +118,7 @@ class Parameter(inspect.Parameter):
annotation: Any = MISSING, annotation: Any = MISSING,
description: str = MISSING, description: str = MISSING,
displayed_default: Any = MISSING, displayed_default: Any = MISSING,
displayed_name: Any = MISSING,
) -> Self: ) -> Self:
if name is MISSING: if name is MISSING:
name = self._name name = self._name
@ -129,6 +132,8 @@ class Parameter(inspect.Parameter):
description = self._description description = self._description
if displayed_default is MISSING: if displayed_default is MISSING:
displayed_default = self._displayed_default displayed_default = self._displayed_default
if displayed_name is MISSING:
displayed_name = self._displayed_name
return self.__class__( return self.__class__(
name=name, name=name,
@ -137,6 +142,7 @@ class Parameter(inspect.Parameter):
annotation=annotation, annotation=annotation,
description=description, description=description,
displayed_default=displayed_default, displayed_default=displayed_default,
displayed_name=displayed_name,
) )
if not TYPE_CHECKING: # this is to prevent anything breaking if inspect internals change if not TYPE_CHECKING: # this is to prevent anything breaking if inspect internals change
@ -171,6 +177,14 @@ class Parameter(inspect.Parameter):
return None if self.required else str(self.default) return None if self.required else str(self.default)
@property
def displayed_name(self) -> Optional[str]:
"""Optional[:class:`str`]: The name that is displayed to the user.
.. versionadded:: 2.3
"""
return self._displayed_name if self._displayed_name is not empty else None
async def get_default(self, ctx: Context[Any]) -> Any: async def get_default(self, ctx: Context[Any]) -> Any:
"""|coro| """|coro|
@ -193,8 +207,9 @@ def parameter(
default: Any = empty, default: Any = empty,
description: str = empty, description: str = empty,
displayed_default: str = empty, displayed_default: str = empty,
displayed_name: str = empty,
) -> Any: ) -> Any:
r"""parameter(\*, converter=..., default=..., description=..., displayed_default=...) r"""parameter(\*, converter=..., default=..., description=..., displayed_default=..., displayed_name=...)
A way to assign custom metadata for a :class:`Command`\'s parameter. A way to assign custom metadata for a :class:`Command`\'s parameter.
@ -221,6 +236,10 @@ def parameter(
The description of this parameter. The description of this parameter.
displayed_default: :class:`str` displayed_default: :class:`str`
The displayed default in :attr:`Command.signature`. The displayed default in :attr:`Command.signature`.
displayed_name: :class:`str`
The name that is displayed to the user.
.. versionadded:: 2.3
""" """
return Parameter( return Parameter(
name='empty', name='empty',
@ -229,6 +248,7 @@ def parameter(
default=default, default=default,
description=description, description=description,
displayed_default=displayed_default, displayed_default=displayed_default,
displayed_name=displayed_name,
) )
@ -240,12 +260,13 @@ class ParameterAlias(Protocol):
default: Any = empty, default: Any = empty,
description: str = empty, description: str = empty,
displayed_default: str = empty, displayed_default: str = empty,
displayed_name: str = empty,
) -> Any: ) -> Any:
... ...
param: ParameterAlias = parameter param: ParameterAlias = parameter
r"""param(\*, converter=..., default=..., description=..., displayed_default=...) r"""param(\*, converter=..., default=..., description=..., displayed_default=..., displayed_name=...)
An alias for :func:`parameter`. An alias for :func:`parameter`.

13
discord/ext/tasks/__init__.py

@ -144,6 +144,7 @@ class Loop(Generic[LF]):
time: Union[datetime.time, Sequence[datetime.time]], time: Union[datetime.time, Sequence[datetime.time]],
count: Optional[int], count: Optional[int],
reconnect: bool, reconnect: bool,
name: Optional[str],
) -> None: ) -> None:
self.coro: LF = coro self.coro: LF = coro
self.reconnect: bool = reconnect self.reconnect: bool = reconnect
@ -165,6 +166,7 @@ class Loop(Generic[LF]):
self._is_being_cancelled = False self._is_being_cancelled = False
self._has_failed = False self._has_failed = False
self._stop_next_iteration = False self._stop_next_iteration = False
self._name: str = f'discord-ext-tasks: {coro.__qualname__}' if name is None else name
if self.count is not None and self.count <= 0: if self.count is not None and self.count <= 0:
raise ValueError('count must be greater than 0 or None.') raise ValueError('count must be greater than 0 or None.')
@ -282,6 +284,7 @@ class Loop(Generic[LF]):
time=self._time, time=self._time,
count=self.count, count=self.count,
reconnect=self.reconnect, reconnect=self.reconnect,
name=self._name,
) )
copy._injected = obj copy._injected = obj
copy._before_loop = self._before_loop copy._before_loop = self._before_loop
@ -395,7 +398,7 @@ class Loop(Generic[LF]):
args = (self._injected, *args) args = (self._injected, *args)
self._has_failed = False self._has_failed = False
self._task = asyncio.create_task(self._loop(*args, **kwargs)) self._task = asyncio.create_task(self._loop(*args, **kwargs), name=self._name)
return self._task return self._task
def stop(self) -> None: def stop(self) -> None:
@ -770,6 +773,7 @@ def loop(
time: Union[datetime.time, Sequence[datetime.time]] = MISSING, time: Union[datetime.time, Sequence[datetime.time]] = MISSING,
count: Optional[int] = None, count: Optional[int] = None,
reconnect: bool = True, reconnect: bool = True,
name: Optional[str] = None,
) -> Callable[[LF], Loop[LF]]: ) -> Callable[[LF], Loop[LF]]:
"""A decorator that schedules a task in the background for you with """A decorator that schedules a task in the background for you with
optional reconnect logic. The decorator returns a :class:`Loop`. optional reconnect logic. The decorator returns a :class:`Loop`.
@ -802,6 +806,12 @@ def loop(
Whether to handle errors and restart the task Whether to handle errors and restart the task
using an exponential back-off algorithm similar to the using an exponential back-off algorithm similar to the
one used in :meth:`discord.Client.connect`. one used in :meth:`discord.Client.connect`.
name: Optional[:class:`str`]
The name to assign to the internal task. By default
it is assigned a name based off of the callable name
such as ``discord-ext-tasks: function_name``.
.. versionadded:: 2.4
Raises Raises
-------- --------
@ -821,6 +831,7 @@ def loop(
count=count, count=count,
time=time, time=time,
reconnect=reconnect, reconnect=reconnect,
name=name,
) )
return decorator return decorator

68
discord/flags.py

@ -239,6 +239,12 @@ class SystemChannelFlags(BaseFlags):
Returns an iterator of ``(name, value)`` pairs. This allows it Returns an iterator of ``(name, value)`` pairs. This allows it
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
.. describe:: bool(b)
Returns whether any flag is set to ``True``.
.. versionadded:: 2.0
Attributes Attributes
----------- -----------
value: :class:`int` value: :class:`int`
@ -361,6 +367,12 @@ class MessageFlags(BaseFlags):
Returns an iterator of ``(name, value)`` pairs. This allows it Returns an iterator of ``(name, value)`` pairs. This allows it
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
.. describe:: bool(b)
Returns whether any flag is set to ``True``.
.. versionadded:: 2.0
.. versionadded:: 1.3 .. versionadded:: 1.3
Attributes Attributes
@ -451,6 +463,14 @@ class MessageFlags(BaseFlags):
""" """
return 4096 return 4096
@flag_value
def voice(self):
""":class:`bool`: Returns ``True`` if the message is a voice message.
.. versionadded:: 2.3
"""
return 8192
@fill_with_flags() @fill_with_flags()
class PublicUserFlags(BaseFlags): class PublicUserFlags(BaseFlags):
@ -501,6 +521,12 @@ class PublicUserFlags(BaseFlags):
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
Note that aliases are not shown. Note that aliases are not shown.
.. describe:: bool(b)
Returns whether any flag is set to ``True``.
.. versionadded:: 2.0
.. versionadded:: 1.4 .. versionadded:: 1.4
Attributes Attributes
@ -685,6 +711,12 @@ class Intents(BaseFlags):
Returns an iterator of ``(name, value)`` pairs. This allows it Returns an iterator of ``(name, value)`` pairs. This allows it
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
.. describe:: bool(b)
Returns whether any intent is enabled.
.. versionadded:: 2.0
Attributes Attributes
----------- -----------
value: :class:`int` value: :class:`int`
@ -784,6 +816,7 @@ class Intents(BaseFlags):
- :attr:`User.name` - :attr:`User.name`
- :attr:`User.avatar` - :attr:`User.avatar`
- :attr:`User.discriminator` - :attr:`User.discriminator`
- :attr:`User.global_name`
For more information go to the :ref:`member intent documentation <need_members_intent>`. For more information go to the :ref:`member intent documentation <need_members_intent>`.
@ -817,7 +850,7 @@ class Intents(BaseFlags):
""" """
return 1 << 2 return 1 << 2
@flag_value @alias_flag_value
def emojis(self): def emojis(self):
""":class:`bool`: Alias of :attr:`.emojis_and_stickers`. """:class:`bool`: Alias of :attr:`.emojis_and_stickers`.
@ -826,7 +859,7 @@ class Intents(BaseFlags):
""" """
return 1 << 3 return 1 << 3
@alias_flag_value @flag_value
def emojis_and_stickers(self): def emojis_and_stickers(self):
""":class:`bool`: Whether guild emoji and sticker related events are enabled. """:class:`bool`: Whether guild emoji and sticker related events are enabled.
@ -1270,6 +1303,12 @@ class MemberCacheFlags(BaseFlags):
Returns an iterator of ``(name, value)`` pairs. This allows it Returns an iterator of ``(name, value)`` pairs. This allows it
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
.. describe:: bool(b)
Returns whether any flag is set to ``True``.
.. versionadded:: 2.0
Attributes Attributes
----------- -----------
value: :class:`int` value: :class:`int`
@ -1413,6 +1452,10 @@ class ApplicationFlags(BaseFlags):
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
Note that aliases are not shown. Note that aliases are not shown.
.. describe:: bool(b)
Returns whether any flag is set to ``True``.
.. versionadded:: 2.0 .. versionadded:: 2.0
Attributes Attributes
@ -1422,6 +1465,15 @@ class ApplicationFlags(BaseFlags):
rather than using this raw value. rather than using this raw value.
""" """
@flag_value
def auto_mod_badge(self):
""":class:`bool`: Returns ``True`` if the application uses at least 100 automod rules across all guilds.
This shows up as a badge in the official client.
.. versionadded:: 2.3
"""
return 1 << 6
@flag_value @flag_value
def gateway_presence(self): def gateway_presence(self):
""":class:`bool`: Returns ``True`` if the application is verified and is allowed to """:class:`bool`: Returns ``True`` if the application is verified and is allowed to
@ -1539,6 +1591,10 @@ class ChannelFlags(BaseFlags):
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
Note that aliases are not shown. Note that aliases are not shown.
.. describe:: bool(b)
Returns whether any flag is set to ``True``.
.. versionadded:: 2.0 .. versionadded:: 2.0
Attributes Attributes
@ -1635,6 +1691,10 @@ class AutoModPresets(ArrayFlags):
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
Note that aliases are not shown. Note that aliases are not shown.
.. describe:: bool(b)
Returns whether any flag is set to ``True``.
Attributes Attributes
----------- -----------
value: :class:`int` value: :class:`int`
@ -1719,6 +1779,10 @@ class MemberFlags(BaseFlags):
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
Note that aliases are not shown. Note that aliases are not shown.
.. describe:: bool(b)
Returns whether any flag is set to ``True``.
Attributes Attributes
----------- -----------

168
discord/guild.py

@ -187,8 +187,6 @@ class Guild(Hashable):
.. versionadded:: 2.0 .. versionadded:: 2.0
afk_timeout: :class:`int` afk_timeout: :class:`int`
The number of seconds until someone is moved to the AFK channel. The number of seconds until someone is moved to the AFK channel.
afk_channel: Optional[:class:`VoiceChannel`]
The channel that denotes the AFK channel. ``None`` if it doesn't exist.
id: :class:`int` id: :class:`int`
The guild's ID. The guild's ID.
owner_id: :class:`int` owner_id: :class:`int`
@ -251,13 +249,13 @@ class Guild(Hashable):
approximate_member_count: Optional[:class:`int`] approximate_member_count: Optional[:class:`int`]
The approximate number of members in the guild. This is ``None`` unless the guild is obtained The approximate number of members in the guild. This is ``None`` unless the guild is obtained
using :meth:`Client.fetch_guild` with ``with_counts=True``. using :meth:`Client.fetch_guild` or :meth:`Client.fetch_guilds` with ``with_counts=True``.
.. versionadded:: 2.0 .. versionadded:: 2.0
approximate_presence_count: Optional[:class:`int`] approximate_presence_count: Optional[:class:`int`]
The approximate number of members currently active in the guild. The approximate number of members currently active in the guild.
Offline members are excluded. This is ``None`` unless the guild is obtained using Offline members are excluded. This is ``None`` unless the guild is obtained using
:meth:`Client.fetch_guild` with ``with_counts=True``. :meth:`Client.fetch_guild` or :meth:`Client.fetch_guilds` with ``with_counts=True``.
.. versionchanged:: 2.0 .. versionchanged:: 2.0
premium_progress_bar_enabled: :class:`bool` premium_progress_bar_enabled: :class:`bool`
@ -268,11 +266,14 @@ class Guild(Hashable):
Indicates if the guild has widget enabled. Indicates if the guild has widget enabled.
.. versionadded:: 2.0 .. versionadded:: 2.0
max_stage_video_users: Optional[:class:`int`]
The maximum amount of users in a stage video channel.
.. versionadded:: 2.3
""" """
__slots__ = ( __slots__ = (
'afk_timeout', 'afk_timeout',
'afk_channel',
'name', 'name',
'id', 'id',
'unavailable', 'unavailable',
@ -295,6 +296,7 @@ class Guild(Hashable):
'vanity_url_code', 'vanity_url_code',
'widget_enabled', 'widget_enabled',
'_widget_channel_id', '_widget_channel_id',
'_afk_channel_id',
'_members', '_members',
'_channels', '_channels',
'_icon', '_icon',
@ -316,12 +318,14 @@ class Guild(Hashable):
'approximate_member_count', 'approximate_member_count',
'approximate_presence_count', 'approximate_presence_count',
'premium_progress_bar_enabled', 'premium_progress_bar_enabled',
'_safety_alerts_channel_id',
'max_stage_video_users',
) )
_PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = { _PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = {
None: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), None: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=utils.DEFAULT_FILE_SIZE_LIMIT_BYTES),
0: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), 0: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=utils.DEFAULT_FILE_SIZE_LIMIT_BYTES),
1: _GuildLimit(emoji=100, stickers=15, bitrate=128e3, filesize=26214400), 1: _GuildLimit(emoji=100, stickers=15, bitrate=128e3, filesize=utils.DEFAULT_FILE_SIZE_LIMIT_BYTES),
2: _GuildLimit(emoji=150, stickers=30, bitrate=256e3, filesize=52428800), 2: _GuildLimit(emoji=150, stickers=30, bitrate=256e3, filesize=52428800),
3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104857600), 3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104857600),
} }
@ -479,6 +483,7 @@ class Guild(Hashable):
self.max_presences: Optional[int] = guild.get('max_presences') self.max_presences: Optional[int] = guild.get('max_presences')
self.max_members: Optional[int] = guild.get('max_members') self.max_members: Optional[int] = guild.get('max_members')
self.max_video_channel_users: Optional[int] = guild.get('max_video_channel_users') self.max_video_channel_users: Optional[int] = guild.get('max_video_channel_users')
self.max_stage_video_users: Optional[int] = guild.get('max_stage_video_channel_users')
self.premium_tier: int = guild.get('premium_tier', 0) self.premium_tier: int = guild.get('premium_tier', 0)
self.premium_subscription_count: int = guild.get('premium_subscription_count') or 0 self.premium_subscription_count: int = guild.get('premium_subscription_count') or 0
self.vanity_url_code: Optional[str] = guild.get('vanity_url_code') self.vanity_url_code: Optional[str] = guild.get('vanity_url_code')
@ -489,62 +494,53 @@ class Guild(Hashable):
self._discovery_splash: Optional[str] = guild.get('discovery_splash') self._discovery_splash: Optional[str] = guild.get('discovery_splash')
self._rules_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'rules_channel_id') self._rules_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'rules_channel_id')
self._public_updates_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'public_updates_channel_id') self._public_updates_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'public_updates_channel_id')
self._safety_alerts_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'safety_alerts_channel_id')
self.nsfw_level: NSFWLevel = try_enum(NSFWLevel, guild.get('nsfw_level', 0)) self.nsfw_level: NSFWLevel = try_enum(NSFWLevel, guild.get('nsfw_level', 0))
self.mfa_level: MFALevel = try_enum(MFALevel, guild.get('mfa_level', 0)) self.mfa_level: MFALevel = try_enum(MFALevel, guild.get('mfa_level', 0))
self.approximate_presence_count: Optional[int] = guild.get('approximate_presence_count') self.approximate_presence_count: Optional[int] = guild.get('approximate_presence_count')
self.approximate_member_count: Optional[int] = guild.get('approximate_member_count') self.approximate_member_count: Optional[int] = guild.get('approximate_member_count')
self.premium_progress_bar_enabled: bool = guild.get('premium_progress_bar_enabled', False) self.premium_progress_bar_enabled: bool = guild.get('premium_progress_bar_enabled', False)
self.owner_id: Optional[int] = utils._get_as_snowflake(guild, 'owner_id') self.owner_id: Optional[int] = utils._get_as_snowflake(guild, 'owner_id')
self._sync(guild)
self._large: Optional[bool] = None if self._member_count is None else self._member_count >= 250 self._large: Optional[bool] = None if self._member_count is None else self._member_count >= 250
self._afk_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'afk_channel_id')
self.afk_channel: Optional[VocalGuildChannel] = self.get_channel(utils._get_as_snowflake(guild, 'afk_channel_id')) # type: ignore if 'channels' in guild:
channels = guild['channels']
# TODO: refactor/remove?
def _sync(self, data: GuildPayload) -> None:
try:
self._large = data['large']
except KeyError:
pass
if 'channels' in data:
channels = data['channels']
for c in channels: for c in channels:
factory, ch_type = _guild_channel_factory(c['type']) factory, ch_type = _guild_channel_factory(c['type'])
if factory: if factory:
self._add_channel(factory(guild=self, data=c, state=self._state)) # type: ignore self._add_channel(factory(guild=self, data=c, state=self._state)) # type: ignore
for obj in data.get('voice_states', []): for obj in guild.get('voice_states', []):
self._update_voice_state(obj, int(obj['channel_id'])) self._update_voice_state(obj, int(obj['channel_id']))
cache_joined = self._state.member_cache_flags.joined cache_joined = self._state.member_cache_flags.joined
cache_voice = self._state.member_cache_flags.voice cache_voice = self._state.member_cache_flags.voice
self_id = self._state.self_id self_id = self._state.self_id
for mdata in data.get('members', []): for mdata in guild.get('members', []):
member = Member(data=mdata, guild=self, state=self._state) # type: ignore # Members will have the 'user' key in this scenario member = Member(data=mdata, guild=self, state=self._state) # type: ignore # Members will have the 'user' key in this scenario
if cache_joined or member.id == self_id or (cache_voice and member.id in self._voice_states): if cache_joined or member.id == self_id or (cache_voice and member.id in self._voice_states):
self._add_member(member) self._add_member(member)
empty_tuple = () empty_tuple = ()
for presence in data.get('presences', []): for presence in guild.get('presences', []):
user_id = int(presence['user']['id']) user_id = int(presence['user']['id'])
member = self.get_member(user_id) member = self.get_member(user_id)
if member is not None: if member is not None:
member._presence_update(presence, empty_tuple) # type: ignore member._presence_update(presence, empty_tuple) # type: ignore
if 'threads' in data: if 'threads' in guild:
threads = data['threads'] threads = guild['threads']
for thread in threads: for thread in threads:
self._add_thread(Thread(guild=self, state=self._state, data=thread)) self._add_thread(Thread(guild=self, state=self._state, data=thread))
if 'stage_instances' in data: if 'stage_instances' in guild:
for s in data['stage_instances']: for s in guild['stage_instances']:
stage_instance = StageInstance(guild=self, data=s, state=self._state) stage_instance = StageInstance(guild=self, data=s, state=self._state)
self._stage_instances[stage_instance.id] = stage_instance self._stage_instances[stage_instance.id] = stage_instance
if 'guild_scheduled_events' in data: if 'guild_scheduled_events' in guild:
for s in data['guild_scheduled_events']: for s in guild['guild_scheduled_events']:
scheduled_event = ScheduledEvent(data=s, state=self._state) scheduled_event = ScheduledEvent(data=s, state=self._state)
self._scheduled_events[scheduled_event.id] = scheduled_event self._scheduled_events[scheduled_event.id] = scheduled_event
@ -763,6 +759,14 @@ class Guild(Hashable):
return emoji return emoji
return None return None
@property
def afk_channel(self) -> Optional[VocalGuildChannel]:
"""Optional[Union[:class:`VoiceChannel`, :class:`StageChannel`]]: The channel that denotes the AFK channel.
If no channel is set, then this returns ``None``.
"""
return self.get_channel(self._afk_channel_id) # type: ignore
@property @property
def system_channel(self) -> Optional[TextChannel]: def system_channel(self) -> Optional[TextChannel]:
"""Optional[:class:`TextChannel`]: Returns the guild's channel used for system messages. """Optional[:class:`TextChannel`]: Returns the guild's channel used for system messages.
@ -802,6 +806,17 @@ class Guild(Hashable):
channel_id = self._public_updates_channel_id channel_id = self._public_updates_channel_id
return channel_id and self._channels.get(channel_id) # type: ignore return channel_id and self._channels.get(channel_id) # type: ignore
@property
def safety_alerts_channel(self) -> Optional[TextChannel]:
"""Optional[:class:`TextChannel`]: Return's the guild's channel used for safety alerts, if set.
For example, this is used for the raid protection setting. The guild must have the ``COMMUNITY`` feature.
.. versionadded:: 2.3
"""
channel_id = self._safety_alerts_channel_id
return channel_id and self._channels.get(channel_id) # type: ignore
@property @property
def widget_channel(self) -> Optional[Union[TextChannel, ForumChannel, VoiceChannel, StageChannel]]: def widget_channel(self) -> Optional[Union[TextChannel, ForumChannel, VoiceChannel, StageChannel]]:
"""Optional[Union[:class:`TextChannel`, :class:`ForumChannel`, :class:`VoiceChannel`, :class:`StageChannel`]]: Returns """Optional[Union[:class:`TextChannel`, :class:`ForumChannel`, :class:`VoiceChannel`, :class:`StageChannel`]]: Returns
@ -1057,15 +1072,13 @@ class Guild(Hashable):
def get_member_named(self, name: str, /) -> Optional[Member]: def get_member_named(self, name: str, /) -> Optional[Member]:
"""Returns the first member found that matches the name provided. """Returns the first member found that matches the name provided.
The name can have an optional discriminator argument, e.g. "Jake#0001" The name is looked up in the following order:
or "Jake" will both do the lookup. However the former will give a more
precise result. Note that the discriminator must have all 4 digits
for this to work.
If a nickname is passed, then it is looked up via the nickname. Note - Username#Discriminator (deprecated)
however, that a nickname + discriminator combo will not lookup the nickname - Username#0 (deprecated, only gets users that migrated from their discriminator)
but rather the username + discriminator combo due to nickname + discriminator - Nickname
not being unique. - Global name
- Username
If no member is found, ``None`` is returned. If no member is found, ``None`` is returned.
@ -1073,10 +1086,14 @@ class Guild(Hashable):
``name`` parameter is now positional-only. ``name`` parameter is now positional-only.
.. deprecated:: 2.3
Looking up users via discriminator due to Discord API change.
Parameters Parameters
----------- -----------
name: :class:`str` name: :class:`str`
The name of the member to lookup with an optional discriminator. The name of the member to lookup.
Returns Returns
-------- --------
@ -1085,22 +1102,19 @@ class Guild(Hashable):
then ``None`` is returned. then ``None`` is returned.
""" """
result = None
members = self.members members = self.members
if len(name) > 5 and name[-5] == '#':
# The 5 length is checking to see if #0000 is in the string, username, _, discriminator = name.rpartition('#')
# as a#0000 has a length of 6, the minimum for a potential
# discriminator lookup. # If # isn't found then "discriminator" actually has the username
potential_discriminator = name[-4:] if not username:
discriminator, username = username, discriminator
# do the actual lookup and return if found
# if it isn't found then we'll do a full name lookup below. if discriminator == '0' or (len(discriminator) == 4 and discriminator.isdigit()):
result = utils.get(members, name=name[:-5], discriminator=potential_discriminator) return utils.find(lambda m: m.name == username and m.discriminator == discriminator, members)
if result is not None:
return result
def pred(m: Member) -> bool: def pred(m: Member) -> bool:
return m.nick == name or m.name == name return m.nick == name or m.global_name == name or m.name == name
return utils.find(pred, members) return utils.find(pred, members)
@ -1304,7 +1318,7 @@ class Guild(Hashable):
nsfw: :class:`bool` nsfw: :class:`bool`
To mark the channel as NSFW or not. To mark the channel as NSFW or not.
news: :class:`bool` news: :class:`bool`
Whether to create the text channel as a news channel. Whether to create the text channel as a news channel.
.. versionadded:: 2.0 .. versionadded:: 2.0
default_auto_archive_duration: :class:`int` default_auto_archive_duration: :class:`int`
@ -1821,6 +1835,8 @@ class Guild(Hashable):
widget_enabled: bool = MISSING, widget_enabled: bool = MISSING,
widget_channel: Optional[Snowflake] = MISSING, widget_channel: Optional[Snowflake] = MISSING,
mfa_level: MFALevel = MISSING, mfa_level: MFALevel = MISSING,
raid_alerts_disabled: bool = MISSING,
safety_alerts_channel: TextChannel = MISSING,
) -> Guild: ) -> Guild:
r"""|coro| r"""|coro|
@ -1935,6 +1951,18 @@ class Guild(Hashable):
reason: Optional[:class:`str`] reason: Optional[:class:`str`]
The reason for editing this guild. Shows up on the audit log. The reason for editing this guild. Shows up on the audit log.
raid_alerts_disabled: :class:`bool`
Whether the alerts for raid protection should be disabled for the guild.
.. versionadded:: 2.3
safety_alerts_channel: Optional[:class:`TextChannel`]
The new channel that is used for safety alerts. This is only available to
guilds that contain ``COMMUNITY`` in :attr:`Guild.features`. Could be ``None`` for no
safety alerts channel.
.. versionadded:: 2.3
Raises Raises
------- -------
Forbidden Forbidden
@ -1946,9 +1974,9 @@ class Guild(Hashable):
PNG or JPG. This is also raised if you are not the owner of the PNG or JPG. This is also raised if you are not the owner of the
guild and request an ownership transfer. guild and request an ownership transfer.
TypeError TypeError
The type passed to the ``default_notifications``, ``verification_level``, The type passed to the ``default_notifications``, ``rules_channel``, ``public_updates_channel``,
``explicit_content_filter``, ``system_channel_flags``, or ``mfa_level`` parameter was ``safety_alerts_channel`` ``verification_level``, ``explicit_content_filter``,
of the incorrect type. ``system_channel_flags``, or ``mfa_level`` parameter was of the incorrect type.
Returns Returns
-------- --------
@ -2020,14 +2048,33 @@ class Guild(Hashable):
if rules_channel is None: if rules_channel is None:
fields['rules_channel_id'] = rules_channel fields['rules_channel_id'] = rules_channel
else: else:
if not isinstance(rules_channel, TextChannel):
raise TypeError(f'rules_channel must be of type TextChannel not {rules_channel.__class__.__name__}')
fields['rules_channel_id'] = rules_channel.id fields['rules_channel_id'] = rules_channel.id
if public_updates_channel is not MISSING: if public_updates_channel is not MISSING:
if public_updates_channel is None: if public_updates_channel is None:
fields['public_updates_channel_id'] = public_updates_channel fields['public_updates_channel_id'] = public_updates_channel
else: else:
if not isinstance(public_updates_channel, TextChannel):
raise TypeError(
f'public_updates_channel must be of type TextChannel not {public_updates_channel.__class__.__name__}'
)
fields['public_updates_channel_id'] = public_updates_channel.id fields['public_updates_channel_id'] = public_updates_channel.id
if safety_alerts_channel is not MISSING:
if safety_alerts_channel is None:
fields['safety_alerts_channel_id'] = safety_alerts_channel
else:
if not isinstance(safety_alerts_channel, TextChannel):
raise TypeError(
f'safety_alerts_channel must be of type TextChannel not {safety_alerts_channel.__class__.__name__}'
)
fields['safety_alerts_channel_id'] = safety_alerts_channel.id
if owner is not MISSING: if owner is not MISSING:
if self.owner_id != self._state.self_id: if self.owner_id != self._state.self_id:
raise ValueError('To transfer ownership you must be the owner of the guild.') raise ValueError('To transfer ownership you must be the owner of the guild.')
@ -2052,7 +2099,7 @@ class Guild(Hashable):
fields['system_channel_flags'] = system_channel_flags.value fields['system_channel_flags'] = system_channel_flags.value
if any(feat is not MISSING for feat in (community, discoverable, invites_disabled)): if any(feat is not MISSING for feat in (community, discoverable, invites_disabled, raid_alerts_disabled)):
features = set(self.features) features = set(self.features)
if community is not MISSING: if community is not MISSING:
@ -2078,6 +2125,12 @@ class Guild(Hashable):
else: else:
features.discard('INVITES_DISABLED') features.discard('INVITES_DISABLED')
if raid_alerts_disabled is not MISSING:
if raid_alerts_disabled:
features.add('RAID_ALERTS_DISABLED')
else:
features.discard('RAID_ALERTS_DISABLED')
fields['features'] = list(features) fields['features'] = list(features)
if premium_progress_bar_enabled is not MISSING: if premium_progress_bar_enabled is not MISSING:
@ -3433,7 +3486,6 @@ class Guild(Hashable):
data = await self._state.http.create_role(self.id, reason=reason, **fields) data = await self._state.http.create_role(self.id, reason=reason, **fields)
role = Role(guild=self, data=data, state=self._state) role = Role(guild=self, data=data, state=self._state)
# TODO: add to cache
return role return role
async def edit_role_positions(self, positions: Mapping[Snowflake, int], *, reason: Optional[str] = None) -> List[Role]: async def edit_role_positions(self, positions: Mapping[Snowflake, int], *, reason: Optional[str] = None) -> List[Role]:

7
discord/http.py

@ -48,6 +48,7 @@ from typing import (
from urllib.parse import quote as _uriquote from urllib.parse import quote as _uriquote
from collections import deque from collections import deque
import datetime import datetime
import socket
import aiohttp import aiohttp
@ -785,7 +786,8 @@ class HTTPClient:
async def static_login(self, token: str) -> user.User: async def static_login(self, token: str) -> user.User:
# Necessary to get aiohttp to stop complaining about session creation # Necessary to get aiohttp to stop complaining about session creation
if self.connector is MISSING: if self.connector is MISSING:
self.connector = aiohttp.TCPConnector(limit=0) # discord does not support ipv6
self.connector = aiohttp.TCPConnector(limit=0, family=socket.AF_INET)
self.__session = aiohttp.ClientSession( self.__session = aiohttp.ClientSession(
connector=self.connector, connector=self.connector,
@ -1374,9 +1376,11 @@ class HTTPClient:
limit: int, limit: int,
before: Optional[Snowflake] = None, before: Optional[Snowflake] = None,
after: Optional[Snowflake] = None, after: Optional[Snowflake] = None,
with_counts: bool = True,
) -> Response[List[guild.Guild]]: ) -> Response[List[guild.Guild]]:
params: Dict[str, Any] = { params: Dict[str, Any] = {
'limit': limit, 'limit': limit,
'with_counts': int(with_counts),
} }
if before: if before:
@ -1427,6 +1431,7 @@ class HTTPClient:
'public_updates_channel_id', 'public_updates_channel_id',
'preferred_locale', 'preferred_locale',
'premium_progress_bar_enabled', 'premium_progress_bar_enabled',
'safety_alerts_channel_id',
) )
payload = {k: v for k, v in fields.items() if k in valid_keys} payload = {k: v for k, v in fields.items() if k in valid_keys}

74
discord/interactions.py

@ -25,6 +25,8 @@ DEALINGS IN THE SOFTWARE.
""" """
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any, Dict, Optional, Generic, TYPE_CHECKING, Sequence, Tuple, Union from typing import Any, Dict, Optional, Generic, TYPE_CHECKING, Sequence, Tuple, Union
import asyncio import asyncio
import datetime import datetime
@ -33,7 +35,7 @@ from . import utils
from .enums import try_enum, Locale, InteractionType, InteractionResponseType from .enums import try_enum, Locale, InteractionType, InteractionResponseType
from .errors import InteractionResponded, HTTPException, ClientException, DiscordException from .errors import InteractionResponded, HTTPException, ClientException, DiscordException
from .flags import MessageFlags from .flags import MessageFlags
from .channel import PartialMessageable, ChannelType from .channel import ChannelType
from ._types import ClientT from ._types import ClientT
from .user import User from .user import User
@ -44,6 +46,7 @@ from .http import handle_message_parameters
from .webhook.async_ import async_context, Webhook, interaction_response_params, interaction_message_response_params from .webhook.async_ import async_context, Webhook, interaction_response_params, interaction_message_response_params
from .app_commands.namespace import Namespace from .app_commands.namespace import Namespace
from .app_commands.translator import locale_str, TranslationContext, TranslationContextLocation from .app_commands.translator import locale_str, TranslationContext, TranslationContextLocation
from .channel import _threaded_channel_factory
__all__ = ( __all__ = (
'Interaction', 'Interaction',
@ -69,12 +72,19 @@ if TYPE_CHECKING:
from .ui.view import View from .ui.view import View
from .app_commands.models import Choice, ChoiceT from .app_commands.models import Choice, ChoiceT
from .ui.modal import Modal from .ui.modal import Modal
from .channel import VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel from .channel import VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel, DMChannel, GroupChannel
from .threads import Thread from .threads import Thread
from .app_commands.commands import Command, ContextMenu from .app_commands.commands import Command, ContextMenu
InteractionChannel = Union[ InteractionChannel = Union[
VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel, Thread, PartialMessageable VoiceChannel,
StageChannel,
TextChannel,
ForumChannel,
CategoryChannel,
Thread,
DMChannel,
GroupChannel,
] ]
MISSING: Any = utils.MISSING MISSING: Any = utils.MISSING
@ -96,8 +106,10 @@ class Interaction(Generic[ClientT]):
The interaction type. The interaction type.
guild_id: Optional[:class:`int`] guild_id: Optional[:class:`int`]
The guild ID the interaction was sent from. The guild ID the interaction was sent from.
channel_id: Optional[:class:`int`] channel: Optional[Union[:class:`abc.GuildChannel`, :class:`abc.PrivateChannel`, :class:`Thread`]]
The channel ID the interaction was sent from. The channel the interaction was sent from.
Note that due to a Discord limitation, if sent from a DM channel :attr:`~DMChannel.recipient` is ``None``.
application_id: :class:`int` application_id: :class:`int`
The application ID that the interaction was for. The application ID that the interaction was for.
user: Union[:class:`User`, :class:`Member`] user: Union[:class:`User`, :class:`Member`]
@ -128,7 +140,6 @@ class Interaction(Generic[ClientT]):
'id', 'id',
'type', 'type',
'guild_id', 'guild_id',
'channel_id',
'data', 'data',
'application_id', 'application_id',
'message', 'message',
@ -148,7 +159,7 @@ class Interaction(Generic[ClientT]):
'_original_response', '_original_response',
'_cs_response', '_cs_response',
'_cs_followup', '_cs_followup',
'_cs_channel', 'channel',
'_cs_namespace', '_cs_namespace',
'_cs_command', '_cs_command',
) )
@ -171,8 +182,8 @@ class Interaction(Generic[ClientT]):
self.data: Optional[InteractionData] = data.get('data') self.data: Optional[InteractionData] = data.get('data')
self.token: str = data['token'] self.token: str = data['token']
self.version: int = data['version'] self.version: int = data['version']
self.channel_id: Optional[int] = utils._get_as_snowflake(data, 'channel_id')
self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id')
self.channel: Optional[InteractionChannel] = None
self.application_id: int = int(data['application_id']) self.application_id: int = int(data['application_id'])
self.locale: Locale = try_enum(Locale, data.get('locale', 'en-US')) self.locale: Locale = try_enum(Locale, data.get('locale', 'en-US'))
@ -182,6 +193,26 @@ class Interaction(Generic[ClientT]):
except KeyError: except KeyError:
self.guild_locale = None self.guild_locale = None
guild = None
if self.guild_id:
guild = self._state._get_or_create_unavailable_guild(self.guild_id)
raw_channel = data.get('channel', {})
channel_id = utils._get_as_snowflake(raw_channel, 'id')
if channel_id is not None and guild is not None:
self.channel = guild and guild._resolve_channel(channel_id)
raw_ch_type = raw_channel.get('type')
if self.channel is None and raw_ch_type is not None:
factory, ch_type = _threaded_channel_factory(raw_ch_type) # type is never None
if factory is None:
logging.info('Unknown channel type {type} for channel ID {id}.'.format_map(raw_channel))
else:
if ch_type in (ChannelType.group, ChannelType.private):
self.channel = factory(me=self._client.user, data=raw_channel, state=self._state) # type: ignore
elif guild is not None:
self.channel = factory(guild=guild, state=self._state, data=raw_channel) # type: ignore
self.message: Optional[Message] self.message: Optional[Message]
try: try:
# The channel and message payloads are mismatched yet handled properly at runtime # The channel and message payloads are mismatched yet handled properly at runtime
@ -193,9 +224,7 @@ class Interaction(Generic[ClientT]):
self._permissions: int = 0 self._permissions: int = 0
self._app_permissions: int = int(data.get('app_permissions', 0)) self._app_permissions: int = int(data.get('app_permissions', 0))
if self.guild_id: if guild is not None:
guild = self._state._get_or_create_unavailable_guild(self.guild_id)
# Upgrade Message.guild in case it's missing with partial guild data # Upgrade Message.guild in case it's missing with partial guild data
if self.message is not None and self.message.guild is None: if self.message is not None and self.message.guild is None:
self.message.guild = guild self.message.guild = guild
@ -227,21 +256,10 @@ class Interaction(Generic[ClientT]):
"""Optional[:class:`Guild`]: The guild the interaction was sent from.""" """Optional[:class:`Guild`]: The guild the interaction was sent from."""
return self._state and self._state._get_guild(self.guild_id) return self._state and self._state._get_guild(self.guild_id)
@utils.cached_slot_property('_cs_channel') @property
def channel(self) -> Optional[InteractionChannel]: def channel_id(self) -> Optional[int]:
"""Optional[Union[:class:`abc.GuildChannel`, :class:`PartialMessageable`, :class:`Thread`]]: The channel the interaction was sent from. """Optional[:class:`int`]: The ID of the channel the interaction was sent from."""
return self.channel.id if self.channel is not None else None
Note that due to a Discord limitation, DM channels are not resolved since there is
no data to complete them. These are :class:`PartialMessageable` instead.
"""
guild = self.guild
channel = guild and guild._resolve_channel(self.channel_id)
if channel is None:
if self.channel_id is not None:
type = ChannelType.text if self.guild_id is not None else ChannelType.private
return PartialMessageable(state=self._state, guild_id=self.guild_id, id=self.channel_id, type=type)
return None
return channel
@property @property
def permissions(self) -> Permissions: def permissions(self) -> Permissions:
@ -1025,8 +1043,8 @@ class _InteractionMessageState:
def _get_guild(self, guild_id): def _get_guild(self, guild_id):
return self._parent._get_guild(guild_id) return self._parent._get_guild(guild_id)
def store_user(self, data): def store_user(self, data, *, cache: bool = True):
return self._parent.store_user(data) return self._parent.store_user(data, cache=cache)
def create_user(self, data): def create_user(self, data):
return self._parent.create_user(data) return self._parent.create_user(data)

25
discord/member.py

@ -274,7 +274,7 @@ class Member(discord.abc.Messageable, _UserTag):
.. describe:: str(x) .. describe:: str(x)
Returns the member's name with the discriminator. Returns the member's handle (e.g. ``name`` or ``name#discriminator``).
Attributes Attributes
---------- ----------
@ -293,7 +293,7 @@ class Member(discord.abc.Messageable, _UserTag):
guild: :class:`Guild` guild: :class:`Guild`
The guild that the member belongs to. The guild that the member belongs to.
nick: Optional[:class:`str`] nick: Optional[:class:`str`]
The guild specific nickname of the user. The guild specific nickname of the user. Takes precedence over the global name.
pending: :class:`bool` pending: :class:`bool`
Whether the member is pending member verification. Whether the member is pending member verification.
@ -329,6 +329,7 @@ class Member(discord.abc.Messageable, _UserTag):
name: str name: str
id: int id: int
discriminator: str discriminator: str
global_name: Optional[str]
bot: bool bot: bool
system: bool system: bool
created_at: datetime.datetime created_at: datetime.datetime
@ -368,7 +369,7 @@ class Member(discord.abc.Messageable, _UserTag):
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f'<Member id={self._user.id} name={self._user.name!r} discriminator={self._user.discriminator!r}' f'<Member id={self._user.id} name={self._user.name!r} global_name={self._user.global_name!r}'
f' bot={self._user.bot} nick={self.nick!r} guild={self.guild!r}>' f' bot={self._user.bot} nick={self.nick!r} guild={self.guild!r}>'
) )
@ -463,12 +464,18 @@ class Member(discord.abc.Messageable, _UserTag):
def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]: def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]:
u = self._user u = self._user
original = (u.name, u._avatar, u.discriminator, u._public_flags) original = (u.name, u.discriminator, u._avatar, u.global_name, u._public_flags)
# These keys seem to always be available # These keys seem to always be available
modified = (user['username'], user['avatar'], user['discriminator'], user.get('public_flags', 0)) modified = (
user['username'],
user['discriminator'],
user['avatar'],
user.get('global_name'),
user.get('public_flags', 0),
)
if original != modified: if original != modified:
to_return = User._copy(self._user) to_return = User._copy(self._user)
u.name, u._avatar, u.discriminator, u._public_flags = modified u.name, u.discriminator, u._avatar, u.global_name, u._public_flags = modified
# Signal to dispatch on_user_update # Signal to dispatch on_user_update
return to_return, u return to_return, u
@ -581,11 +588,11 @@ class Member(discord.abc.Messageable, _UserTag):
def display_name(self) -> str: def display_name(self) -> str:
""":class:`str`: Returns the user's display name. """:class:`str`: Returns the user's display name.
For regular users this is just their username, but For regular users this is just their global name or their username,
if they have a guild specific nickname then that but if they have a guild specific nickname then that
is returned instead. is returned instead.
""" """
return self.nick or self.name return self.nick or self.global_name or self.name
@property @property
def display_avatar(self) -> Asset: def display_avatar(self) -> Asset:

23
discord/message.py

@ -183,6 +183,14 @@ class Attachment(Hashable):
Whether the attachment is ephemeral. Whether the attachment is ephemeral.
.. versionadded:: 2.0 .. versionadded:: 2.0
duration: Optional[:class:`float`]
The duration of the audio file in seconds. Returns ``None`` if it's not a voice message.
.. versionadded:: 2.3
waveform: Optional[:class:`bytes`]
The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message.
.. versionadded:: 2.3
""" """
__slots__ = ( __slots__ = (
@ -197,6 +205,8 @@ class Attachment(Hashable):
'content_type', 'content_type',
'description', 'description',
'ephemeral', 'ephemeral',
'duration',
'waveform',
) )
def __init__(self, *, data: AttachmentPayload, state: ConnectionState): def __init__(self, *, data: AttachmentPayload, state: ConnectionState):
@ -211,11 +221,19 @@ class Attachment(Hashable):
self.content_type: Optional[str] = data.get('content_type') self.content_type: Optional[str] = data.get('content_type')
self.description: Optional[str] = data.get('description') self.description: Optional[str] = data.get('description')
self.ephemeral: bool = data.get('ephemeral', False) self.ephemeral: bool = data.get('ephemeral', False)
self.duration: Optional[float] = data.get('duration_secs')
waveform = data.get('waveform')
self.waveform: Optional[bytes] = utils._base64_to_bytes(waveform) if waveform is not None else None
def is_spoiler(self) -> bool: def is_spoiler(self) -> bool:
""":class:`bool`: Whether this attachment contains a spoiler.""" """:class:`bool`: Whether this attachment contains a spoiler."""
return self.filename.startswith('SPOILER_') return self.filename.startswith('SPOILER_')
def is_voice_message(self) -> bool:
""":class:`bool`: Whether this attachment is a voice message."""
return self.duration is not None and 'voice-message' in self.url
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<Attachment id={self.id} filename={self.filename!r} url={self.url!r}>' return f'<Attachment id={self.id} filename={self.filename!r} url={self.url!r}>'
@ -1790,7 +1808,7 @@ class Message(PartialMessage, Hashable):
self.nonce = value self.nonce = value
def _handle_author(self, author: UserPayload) -> None: def _handle_author(self, author: UserPayload) -> None:
self.author = self._state.store_user(author) self.author = self._state.store_user(author, cache=self.webhook_id is None)
if isinstance(self.guild, Guild): if isinstance(self.guild, Guild):
found = self.guild.get_member(self.author.id) found = self.guild.get_member(self.author.id)
if found is not None: if found is not None:
@ -1809,7 +1827,6 @@ class Message(PartialMessage, Hashable):
author._update_from_message(member) # type: ignore author._update_from_message(member) # type: ignore
except AttributeError: except AttributeError:
# It's a user here # It's a user here
# TODO: consider adding to cache here
self.author = Member._from_message(message=self, data=member) self.author = Member._from_message(message=self, data=member)
def _handle_mentions(self, mentions: List[UserWithMemberPayload]) -> None: def _handle_mentions(self, mentions: List[UserWithMemberPayload]) -> None:
@ -2005,7 +2022,7 @@ class Message(PartialMessage, Hashable):
return f'{self.author.name} changed the channel name: **{self.content}**' return f'{self.author.name} changed the channel name: **{self.content}**'
if self.type is MessageType.channel_icon_change: if self.type is MessageType.channel_icon_change:
return f'{self.author.name} changed the channel icon.' return f'{self.author.name} changed the group icon.'
if self.type is MessageType.pins_add: if self.type is MessageType.pins_add:
return f'{self.author.name} pinned a message to this channel.' return f'{self.author.name} pinned a message to this channel.'

4
discord/opus.py

@ -454,7 +454,9 @@ class Decoder(_OpusStruct):
channel_count = self.CHANNELS channel_count = self.CHANNELS
else: else:
frames = self.packet_get_nb_frames(data) frames = self.packet_get_nb_frames(data)
channel_count = self.packet_get_nb_channels(data) # Discord silent frames erroneously present themselves as 1 channel instead of 2
# Therefore we need to hardcode the number instead of using packet_get_nb_channels
channel_count = self.CHANNELS
samples_per_frame = self.packet_get_samples_per_frame(data) samples_per_frame = self.packet_get_samples_per_frame(data)
frame_size = frames * samples_per_frame frame_size = frames * samples_per_frame

2
discord/partial_emoji.py

@ -94,7 +94,7 @@ class PartialEmoji(_EmojiTag, AssetMixin):
__slots__ = ('animated', 'name', 'id', '_state') __slots__ = ('animated', 'name', 'id', '_state')
_CUSTOM_EMOJI_RE = re.compile(r'<?(?P<animated>a)?:?(?P<name>[A-Za-z0-9\_]+):(?P<id>[0-9]{13,20})>?') _CUSTOM_EMOJI_RE = re.compile(r'<?(?:(?P<animated>a)?:)?(?P<name>[A-Za-z0-9\_]+):(?P<id>[0-9]{13,20})>?')
if TYPE_CHECKING: if TYPE_CHECKING:
id: Optional[int] id: Optional[int]

59
discord/permissions.py

@ -119,6 +119,12 @@ class Permissions(BaseFlags):
to be, for example, constructed as a dict or a list of pairs. to be, for example, constructed as a dict or a list of pairs.
Note that aliases are not shown. Note that aliases are not shown.
.. describe:: bool(b)
Returns whether the permissions object has any permissions set to ``True``.
.. versionadded:: 2.0
Attributes Attributes
----------- -----------
value: :class:`int` value: :class:`int`
@ -177,7 +183,7 @@ class Permissions(BaseFlags):
"""A factory method that creates a :class:`Permissions` with all """A factory method that creates a :class:`Permissions` with all
permissions set to ``True``. permissions set to ``True``.
""" """
return cls(0b1111111111111111111111111111111111111111111111) return cls(0b11111111111111111111111111111111111111111111111)
@classmethod @classmethod
def _timeout_mask(cls) -> int: def _timeout_mask(cls) -> int:
@ -204,7 +210,7 @@ class Permissions(BaseFlags):
``True`` and the guild-specific ones set to ``False``. The guild-specific ``True`` and the guild-specific ones set to ``False``. The guild-specific
permissions are currently: permissions are currently:
- :attr:`manage_guild_expressions` - :attr:`manage_expressions`
- :attr:`view_audit_log` - :attr:`view_audit_log`
- :attr:`view_guild_insights` - :attr:`view_guild_insights`
- :attr:`manage_guild` - :attr:`manage_guild`
@ -213,6 +219,7 @@ class Permissions(BaseFlags):
- :attr:`kick_members` - :attr:`kick_members`
- :attr:`ban_members` - :attr:`ban_members`
- :attr:`administrator` - :attr:`administrator`
- :attr:`create_expressions`
.. versionchanged:: 1.7 .. versionchanged:: 1.7
Added :attr:`stream`, :attr:`priority_speaker` and :attr:`use_application_commands` permissions. Added :attr:`stream`, :attr:`priority_speaker` and :attr:`use_application_commands` permissions.
@ -223,9 +230,9 @@ class Permissions(BaseFlags):
:attr:`request_to_speak` permissions. :attr:`request_to_speak` permissions.
.. versionchanged:: 2.3 .. versionchanged:: 2.3
Added :attr:`use_soundboard` Added :attr:`use_soundboard`, :attr:`create_expressions` permissions.
""" """
return cls(0b1000111110110110011111101111111111101010001) return cls(0b01000111110110110011111101111111111101010001)
@classmethod @classmethod
def general(cls) -> Self: def general(cls) -> Self:
@ -237,8 +244,11 @@ class Permissions(BaseFlags):
permissions :attr:`administrator`, :attr:`create_instant_invite`, :attr:`kick_members`, permissions :attr:`administrator`, :attr:`create_instant_invite`, :attr:`kick_members`,
:attr:`ban_members`, :attr:`change_nickname` and :attr:`manage_nicknames` are :attr:`ban_members`, :attr:`change_nickname` and :attr:`manage_nicknames` are
no longer part of the general permissions. no longer part of the general permissions.
.. versionchanged:: 2.3
Added :attr:`create_expressions` permission.
""" """
return cls(0b01110000000010000000010010110000) return cls(0b10000000000001110000000010000000010010110000)
@classmethod @classmethod
def membership(cls) -> Self: def membership(cls) -> Self:
@ -261,8 +271,11 @@ class Permissions(BaseFlags):
.. versionchanged:: 2.0 .. versionchanged:: 2.0
Added :attr:`create_public_threads`, :attr:`create_private_threads`, :attr:`manage_threads`, Added :attr:`create_public_threads`, :attr:`create_private_threads`, :attr:`manage_threads`,
:attr:`send_messages_in_threads` and :attr:`use_external_stickers` permissions. :attr:`send_messages_in_threads` and :attr:`use_external_stickers` permissions.
.. versionchanged:: 2.3
Added :attr:`send_voice_messages` permission.
""" """
return cls(0b111110010000000000001111111100001000000) return cls(0b10000000111110010000000000001111111100001000000)
@classmethod @classmethod
def voice(cls) -> Self: def voice(cls) -> Self:
@ -308,7 +321,7 @@ class Permissions(BaseFlags):
- :attr:`manage_messages` - :attr:`manage_messages`
- :attr:`manage_roles` - :attr:`manage_roles`
- :attr:`manage_webhooks` - :attr:`manage_webhooks`
- :attr:`manage_guild_expressions` - :attr:`manage_expressions`
- :attr:`manage_threads` - :attr:`manage_threads`
- :attr:`moderate_members` - :attr:`moderate_members`
@ -547,21 +560,21 @@ class Permissions(BaseFlags):
return 1 << 29 return 1 << 29
@flag_value @flag_value
def manage_guild_expressions(self) -> int: def manage_expressions(self) -> int:
""":class:`bool`: Returns ``True`` if a user can create, edit, or delete emojis, stickers, and soundboard sounds. """:class:`bool`: Returns ``True`` if a user can edit or delete emojis, stickers, and soundboard sounds.
.. versionadded:: 2.3 .. versionadded:: 2.3
""" """
return 1 << 30 return 1 << 30
@make_permission_alias('manage_guild_expressions') @make_permission_alias('manage_expressions')
def manage_emojis(self) -> int: def manage_emojis(self) -> int:
""":class:`bool`: An alias for :attr:`manage_guild_expressions`.""" """:class:`bool`: An alias for :attr:`manage_expressions`."""
return 1 << 30 return 1 << 30
@make_permission_alias('manage_guild_expressions') @make_permission_alias('manage_expressions')
def manage_emojis_and_stickers(self) -> int: def manage_emojis_and_stickers(self) -> int:
""":class:`bool`: An alias for :attr:`manage_guild_expressions`. """:class:`bool`: An alias for :attr:`manage_expressions`.
.. versionadded:: 2.0 .. versionadded:: 2.0
""" """
@ -663,6 +676,14 @@ class Permissions(BaseFlags):
""" """
return 1 << 42 return 1 << 42
@flag_value
def create_expressions(self) -> int:
""":class:`bool`: Returns ``True`` if a user can create emojis, stickers, and soundboard sounds.
.. versionadded:: 2.3
"""
return 1 << 43
@flag_value @flag_value
def use_external_sounds(self) -> int: def use_external_sounds(self) -> int:
""":class:`bool`: Returns ``True`` if a user can use sounds from other guilds. """:class:`bool`: Returns ``True`` if a user can use sounds from other guilds.
@ -671,6 +692,14 @@ class Permissions(BaseFlags):
""" """
return 1 << 45 return 1 << 45
@flag_value
def send_voice_messages(self) -> int:
""":class:`bool`: Returns ``True`` if a user can send voice messages.
.. versionadded:: 2.3
"""
return 1 << 46
def _augment_from_permissions(cls): def _augment_from_permissions(cls):
cls.VALID_NAMES = set(Permissions.VALID_FLAGS) cls.VALID_NAMES = set(Permissions.VALID_FLAGS)
@ -772,7 +801,7 @@ class PermissionOverwrite:
manage_roles: Optional[bool] manage_roles: Optional[bool]
manage_permissions: Optional[bool] manage_permissions: Optional[bool]
manage_webhooks: Optional[bool] manage_webhooks: Optional[bool]
manage_guild_expressions: Optional[bool] manage_expressions: Optional[bool]
manage_emojis: Optional[bool] manage_emojis: Optional[bool]
manage_emojis_and_stickers: Optional[bool] manage_emojis_and_stickers: Optional[bool]
use_application_commands: Optional[bool] use_application_commands: Optional[bool]
@ -788,6 +817,8 @@ class PermissionOverwrite:
moderate_members: Optional[bool] moderate_members: Optional[bool]
use_soundboard: Optional[bool] use_soundboard: Optional[bool]
use_external_sounds: Optional[bool] use_external_sounds: Optional[bool]
send_voice_messages: Optional[bool]
create_expressions: Optional[bool]
def __init__(self, **kwargs: Optional[bool]): def __init__(self, **kwargs: Optional[bool]):
self._values: Dict[str, Optional[bool]] = {} self._values: Dict[str, Optional[bool]] = {}

3
discord/player.py

@ -210,7 +210,8 @@ class FFmpegAudio(AudioSource):
# arbitrarily large read size # arbitrarily large read size
data = source.read(8192) data = source.read(8192)
if not data: if not data:
self._process.terminate() if self._stdin is not None:
self._stdin.close()
return return
try: try:
if self._stdin is not None: if self._stdin is not None:

1
discord/reaction.py

@ -89,7 +89,6 @@ class Reaction:
self.count: int = data.get('count', 1) self.count: int = data.get('count', 1)
self.me: bool = data['me'] self.me: bool = data['me']
# TODO: typeguard
def is_custom_emoji(self) -> bool: def is_custom_emoji(self) -> bool:
""":class:`bool`: If this is a custom emoji.""" """:class:`bool`: If this is a custom emoji."""
return not isinstance(self.emoji, str) return not isinstance(self.emoji, str)

8
discord/state.py

@ -349,18 +349,18 @@ class ConnectionState(Generic[ClientT]):
for vc in self.voice_clients: for vc in self.voice_clients:
vc.main_ws = ws # type: ignore # Silencing the unknown attribute (ok at runtime). vc.main_ws = ws # type: ignore # Silencing the unknown attribute (ok at runtime).
def store_user(self, data: Union[UserPayload, PartialUserPayload]) -> User: def store_user(self, data: Union[UserPayload, PartialUserPayload], *, cache: bool = True) -> User:
# this way is 300% faster than `dict.setdefault`. # this way is 300% faster than `dict.setdefault`.
user_id = int(data['id']) user_id = int(data['id'])
try: try:
return self._users[user_id] return self._users[user_id]
except KeyError: except KeyError:
user = User(state=self, data=data) user = User(state=self, data=data)
if user.discriminator != '0000': if cache:
self._users[user_id] = user self._users[user_id] = user
return user return user
def store_user_no_intents(self, data: Union[UserPayload, PartialUserPayload]) -> User: def store_user_no_intents(self, data: Union[UserPayload, PartialUserPayload], *, cache: bool = True) -> User:
return User(state=self, data=data) return User(state=self, data=data)
def create_user(self, data: Union[UserPayload, PartialUserPayload]) -> User: def create_user(self, data: Union[UserPayload, PartialUserPayload]) -> User:
@ -614,7 +614,7 @@ class ConnectionState(Generic[ClientT]):
if self._messages is not None: if self._messages is not None:
self._messages.append(message) self._messages.append(message)
# we ensure that the channel is either a TextChannel, VoiceChannel, or Thread # we ensure that the channel is either a TextChannel, VoiceChannel, or Thread
if channel and channel.__class__ in (TextChannel, VoiceChannel, Thread): if channel and channel.__class__ in (TextChannel, VoiceChannel, Thread, StageChannel):
channel.last_message_id = message.id # type: ignore channel.last_message_id = message.id # type: ignore
def parse_message_delete(self, data: gw.MessageDeleteEvent) -> None: def parse_message_delete(self, data: gw.MessageDeleteEvent) -> None:

10
discord/team.py

@ -108,7 +108,7 @@ class TeamMember(BaseUser):
.. describe:: str(x) .. describe:: str(x)
Returns the team member's name with discriminator. Returns the team member's handle (e.g. ``name`` or ``name#discriminator``).
.. versionadded:: 1.3 .. versionadded:: 1.3
@ -119,7 +119,11 @@ class TeamMember(BaseUser):
id: :class:`int` id: :class:`int`
The team member's unique ID. The team member's unique ID.
discriminator: :class:`str` discriminator: :class:`str`
The team member's discriminator. This is given when the username has conflicts. The team member's discriminator. This is a legacy concept that is no longer used.
global_name: Optional[:class:`str`]
The team member's global nickname, taking precedence over the username in display.
.. versionadded:: 2.3
bot: :class:`bool` bot: :class:`bool`
Specifies if the user is a bot account. Specifies if the user is a bot account.
team: :class:`Team` team: :class:`Team`
@ -139,5 +143,5 @@ class TeamMember(BaseUser):
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f'<{self.__class__.__name__} id={self.id} name={self.name!r} ' f'<{self.__class__.__name__} id={self.id} name={self.name!r} '
f'discriminator={self.discriminator!r} membership_state={self.membership_state!r}>' f'global_name={self.global_name!r} membership_state={self.membership_state!r}>'
) )

10
discord/types/channel.py

@ -150,16 +150,24 @@ class ForumChannel(_BaseTextChannel):
GuildChannel = Union[TextChannel, NewsChannel, VoiceChannel, CategoryChannel, StageChannel, ThreadChannel, ForumChannel] GuildChannel = Union[TextChannel, NewsChannel, VoiceChannel, CategoryChannel, StageChannel, ThreadChannel, ForumChannel]
class DMChannel(_BaseChannel): class _BaseDMChannel(_BaseChannel):
type: Literal[1] type: Literal[1]
last_message_id: Optional[Snowflake] last_message_id: Optional[Snowflake]
class DMChannel(_BaseDMChannel):
recipients: List[PartialUser] recipients: List[PartialUser]
class InteractionDMChannel(_BaseDMChannel):
recipients: NotRequired[List[PartialUser]]
class GroupDMChannel(_BaseChannel): class GroupDMChannel(_BaseChannel):
type: Literal[3] type: Literal[3]
icon: Optional[str] icon: Optional[str]
owner_id: Snowflake owner_id: Snowflake
recipients: List[PartialUser]
Channel = Union[GuildChannel, DMChannel, GroupDMChannel] Channel = Union[GuildChannel, DMChannel, GroupDMChannel]

1
discord/types/guild.py

@ -84,6 +84,7 @@ GuildFeature = Literal[
'VERIFIED', 'VERIFIED',
'VIP_REGIONS', 'VIP_REGIONS',
'WELCOME_SCREEN_ENABLED', 'WELCOME_SCREEN_ENABLED',
'RAID_ALERTS_DISABLED',
] ]

3
discord/types/interactions.py

@ -27,7 +27,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union
from typing_extensions import NotRequired from typing_extensions import NotRequired
from .channel import ChannelTypeWithoutThread, ThreadMetadata from .channel import ChannelTypeWithoutThread, ThreadMetadata, GuildChannel, InteractionDMChannel, GroupDMChannel
from .threads import ThreadType from .threads import ThreadType
from .member import Member from .member import Member
from .message import Attachment from .message import Attachment
@ -204,6 +204,7 @@ class _BaseInteraction(TypedDict):
version: Literal[1] version: Literal[1]
guild_id: NotRequired[Snowflake] guild_id: NotRequired[Snowflake]
channel_id: NotRequired[Snowflake] channel_id: NotRequired[Snowflake]
channel: Union[GuildChannel, InteractionDMChannel, GroupDMChannel]
app_permissions: NotRequired[str] app_permissions: NotRequired[str]
locale: NotRequired[str] locale: NotRequired[str]
guild_locale: NotRequired[str] guild_locale: NotRequired[str]

2
discord/types/message.py

@ -68,6 +68,8 @@ class Attachment(TypedDict):
content_type: NotRequired[str] content_type: NotRequired[str]
spoiler: NotRequired[bool] spoiler: NotRequired[bool]
ephemeral: NotRequired[bool] ephemeral: NotRequired[bool]
duration_secs: NotRequired[float]
waveform: NotRequired[str]
MessageActivityType = Literal[1, 2, 3, 5] MessageActivityType = Literal[1, 2, 3, 5]

3
discord/types/user.py

@ -31,6 +31,7 @@ class PartialUser(TypedDict):
username: str username: str
discriminator: str discriminator: str
avatar: Optional[str] avatar: Optional[str]
global_name: Optional[str]
PremiumType = Literal[0, 1, 2] PremiumType = Literal[0, 1, 2]
@ -40,7 +41,7 @@ class User(PartialUser, total=False):
bot: bool bot: bool
system: bool system: bool
mfa_enabled: bool mfa_enabled: bool
local: str locale: str
verified: bool verified: bool
email: Optional[str] email: Optional[str]
flags: int flags: int

1
discord/ui/modal.py

@ -64,6 +64,7 @@ class Modal(View):
.. code-block:: python3 .. code-block:: python3
import discord
from discord import ui from discord import ui
class Questionnaire(ui.Modal, title='Questionnaire Response'): class Questionnaire(ui.Modal, title='Questionnaire Response'):

4
discord/ui/select.py

@ -162,10 +162,10 @@ class BaseSelect(Item[V]):
@custom_id.setter @custom_id.setter
def custom_id(self, value: str) -> None: def custom_id(self, value: str) -> None:
if not isinstance(value, str): if not isinstance(value, str):
raise TypeError('custom_id must be None or str') raise TypeError('custom_id must be a str')
self._underlying.custom_id = value self._underlying.custom_id = value
self._provided_custom_id = value is not None self._provided_custom_id = True
@property @property
def placeholder(self) -> Optional[str]: def placeholder(self) -> Optional[str]:

3
discord/ui/text_input.py

@ -137,9 +137,10 @@ class TextInput(Item[V]):
@custom_id.setter @custom_id.setter
def custom_id(self, value: str) -> None: def custom_id(self, value: str) -> None:
if not isinstance(value, str): if not isinstance(value, str):
raise TypeError('custom_id must be None or str') raise TypeError('custom_id must be a str')
self._underlying.custom_id = value self._underlying.custom_id = value
self._provided_custom_id = True
@property @property
def width(self) -> int: def width(self) -> int:

56
discord/user.py

@ -65,6 +65,7 @@ class BaseUser(_UserTag):
'name', 'name',
'id', 'id',
'discriminator', 'discriminator',
'global_name',
'_avatar', '_avatar',
'_banner', '_banner',
'_accent_colour', '_accent_colour',
@ -78,6 +79,7 @@ class BaseUser(_UserTag):
name: str name: str
id: int id: int
discriminator: str discriminator: str
global_name: Optional[str]
bot: bool bot: bool
system: bool system: bool
_state: ConnectionState _state: ConnectionState
@ -92,11 +94,13 @@ class BaseUser(_UserTag):
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<BaseUser id={self.id} name={self.name!r} discriminator={self.discriminator!r}" f"<BaseUser id={self.id} name={self.name!r} global_name={self.global_name!r}"
f" bot={self.bot} system={self.system}>" f" bot={self.bot} system={self.system}>"
) )
def __str__(self) -> str: def __str__(self) -> str:
if self.discriminator == '0':
return self.name
return f'{self.name}#{self.discriminator}' return f'{self.name}#{self.discriminator}'
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
@ -112,6 +116,7 @@ class BaseUser(_UserTag):
self.name = data['username'] self.name = data['username']
self.id = int(data['id']) self.id = int(data['id'])
self.discriminator = data['discriminator'] self.discriminator = data['discriminator']
self.global_name = data.get('global_name')
self._avatar = data['avatar'] self._avatar = data['avatar']
self._banner = data.get('banner', None) self._banner = data.get('banner', None)
self._accent_colour = data.get('accent_color', None) self._accent_colour = data.get('accent_color', None)
@ -126,6 +131,7 @@ class BaseUser(_UserTag):
self.name = user.name self.name = user.name
self.id = user.id self.id = user.id
self.discriminator = user.discriminator self.discriminator = user.discriminator
self.global_name = user.global_name
self._avatar = user._avatar self._avatar = user._avatar
self._banner = user._banner self._banner = user._banner
self._accent_colour = user._accent_colour self._accent_colour = user._accent_colour
@ -141,6 +147,7 @@ class BaseUser(_UserTag):
'id': self.id, 'id': self.id,
'avatar': self._avatar, 'avatar': self._avatar,
'discriminator': self.discriminator, 'discriminator': self.discriminator,
'global_name': self.global_name,
'bot': self.bot, 'bot': self.bot,
} }
@ -162,8 +169,13 @@ class BaseUser(_UserTag):
@property @property
def default_avatar(self) -> Asset: def default_avatar(self) -> Asset:
""":class:`Asset`: Returns the default avatar for a given user. This is calculated by the user's discriminator.""" """:class:`Asset`: Returns the default avatar for a given user."""
return Asset._from_default_avatar(self._state, int(self.discriminator) % len(DefaultAvatar)) if self.discriminator == '0':
avatar_id = (self.id >> 22) % len(DefaultAvatar)
else:
avatar_id = int(self.discriminator) % 5
return Asset._from_default_avatar(self._state, avatar_id)
@property @property
def display_avatar(self) -> Asset: def display_avatar(self) -> Asset:
@ -260,10 +272,12 @@ class BaseUser(_UserTag):
def display_name(self) -> str: def display_name(self) -> str:
""":class:`str`: Returns the user's display name. """:class:`str`: Returns the user's display name.
For regular users this is just their username, but For regular users this is just their global name or their username,
if they have a guild specific nickname then that but if they have a guild specific nickname then that
is returned instead. is returned instead.
""" """
if self.global_name:
return self.global_name
return self.name return self.name
def mentioned_in(self, message: Message) -> bool: def mentioned_in(self, message: Message) -> bool:
@ -305,7 +319,7 @@ class ClientUser(BaseUser):
.. describe:: str(x) .. describe:: str(x)
Returns the user's name with discriminator. Returns the user's handle (e.g. ``name`` or ``name#discriminator``).
Attributes Attributes
----------- -----------
@ -314,7 +328,11 @@ class ClientUser(BaseUser):
id: :class:`int` id: :class:`int`
The user's unique ID. The user's unique ID.
discriminator: :class:`str` discriminator: :class:`str`
The user's discriminator. This is given when the username has conflicts. The user's discriminator. This is a legacy concept that is no longer used.
global_name: Optional[:class:`str`]
The user's global nickname, taking precedence over the username in display.
.. versionadded:: 2.3
bot: :class:`bool` bot: :class:`bool`
Specifies if the user is a bot account. Specifies if the user is a bot account.
system: :class:`bool` system: :class:`bool`
@ -343,7 +361,7 @@ class ClientUser(BaseUser):
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f'<ClientUser id={self.id} name={self.name!r} discriminator={self.discriminator!r}' f'<ClientUser id={self.id} name={self.name!r} global_name={self.global_name!r}'
f' bot={self.bot} verified={self.verified} mfa_enabled={self.mfa_enabled}>' f' bot={self.bot} verified={self.verified} mfa_enabled={self.mfa_enabled}>'
) )
@ -409,6 +427,18 @@ class ClientUser(BaseUser):
data: UserPayload = await self._state.http.edit_profile(payload) data: UserPayload = await self._state.http.edit_profile(payload)
return ClientUser(state=self._state, data=data) return ClientUser(state=self._state, data=data)
@property
def mutual_guilds(self) -> List[Guild]:
"""List[:class:`Guild`]: The guilds that the user shares with the client.
.. note::
This will only return mutual guilds within the client's internal cache.
.. versionadded:: 1.7
"""
return list(self._state.guilds)
class User(BaseUser, discord.abc.Messageable): class User(BaseUser, discord.abc.Messageable):
"""Represents a Discord user. """Represents a Discord user.
@ -429,7 +459,7 @@ class User(BaseUser, discord.abc.Messageable):
.. describe:: str(x) .. describe:: str(x)
Returns the user's name with discriminator. Returns the user's handle (e.g. ``name`` or ``name#discriminator``).
Attributes Attributes
----------- -----------
@ -438,7 +468,11 @@ class User(BaseUser, discord.abc.Messageable):
id: :class:`int` id: :class:`int`
The user's unique ID. The user's unique ID.
discriminator: :class:`str` discriminator: :class:`str`
The user's discriminator. This is given when the username has conflicts. The user's discriminator. This is a legacy concept that is no longer used.
global_name: Optional[:class:`str`]
The user's global nickname, taking precedence over the username in display.
.. versionadded:: 2.3
bot: :class:`bool` bot: :class:`bool`
Specifies if the user is a bot account. Specifies if the user is a bot account.
system: :class:`bool` system: :class:`bool`
@ -448,7 +482,7 @@ class User(BaseUser, discord.abc.Messageable):
__slots__ = ('__weakref__',) __slots__ = ('__weakref__',)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<User id={self.id} name={self.name!r} discriminator={self.discriminator!r} bot={self.bot}>' return f'<User id={self.id} name={self.name!r} global_name={self.global_name!r} bot={self.bot}>'
async def _get_channel(self) -> DMChannel: async def _get_channel(self) -> DMChannel:
ch = await self.create_dm() ch = await self.create_dm()

12
discord/utils.py

@ -56,7 +56,7 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
) )
import unicodedata import unicodedata
from base64 import b64encode from base64 import b64encode, b64decode
from bisect import bisect_left from bisect import bisect_left
import datetime import datetime
import functools import functools
@ -100,6 +100,7 @@ __all__ = (
) )
DISCORD_EPOCH = 1420070400000 DISCORD_EPOCH = 1420070400000
DEFAULT_FILE_SIZE_LIMIT_BYTES = 26214400
class _MissingSentinel: class _MissingSentinel:
@ -628,6 +629,10 @@ def _bytes_to_base64_data(data: bytes) -> str:
return fmt.format(mime=mime, data=b64) return fmt.format(mime=mime, data=b64)
def _base64_to_bytes(data: str) -> bytes:
return b64decode(data.encode('ascii'))
def _is_submodule(parent: str, child: str) -> bool: def _is_submodule(parent: str, child: str) -> bool:
return parent == child or child.startswith(parent + '.') return parent == child or child.startswith(parent + '.')
@ -1239,11 +1244,12 @@ def is_docker() -> bool:
def stream_supports_colour(stream: Any) -> bool: def stream_supports_colour(stream: Any) -> bool:
is_a_tty = hasattr(stream, 'isatty') and stream.isatty()
# Pycharm and Vscode support colour in their inbuilt editors # Pycharm and Vscode support colour in their inbuilt editors
if 'PYCHARM_HOSTED' in os.environ or os.environ.get('TERM_PROGRAM') == 'vscode': if 'PYCHARM_HOSTED' in os.environ or os.environ.get('TERM_PROGRAM') == 'vscode':
return True return is_a_tty
is_a_tty = hasattr(stream, 'isatty') and stream.isatty()
if sys.platform != 'win32': if sys.platform != 'win32':
# Docker does not consistently have a tty attached to it # Docker does not consistently have a tty attached to it
return is_a_tty or is_docker() return is_a_tty or is_docker()

17
discord/webhook/async_.py

@ -715,9 +715,9 @@ class _WebhookState:
return self._parent._get_guild(guild_id) return self._parent._get_guild(guild_id)
return None return None
def store_user(self, data: Union[UserPayload, PartialUserPayload]) -> BaseUser: def store_user(self, data: Union[UserPayload, PartialUserPayload], *, cache: bool = True) -> BaseUser:
if self._parent is not None: if self._parent is not None:
return self._parent.store_user(data) return self._parent.store_user(data, cache=cache)
# state parameter is artificial # state parameter is artificial
return BaseUser(state=self, data=data) # type: ignore return BaseUser(state=self, data=data) # type: ignore
@ -1275,7 +1275,7 @@ class Webhook(BaseWebhook):
A partial :class:`Webhook`. A partial :class:`Webhook`.
A partial webhook is just a webhook object with an ID and a token. A partial webhook is just a webhook object with an ID and a token.
""" """
m = re.search(r'discord(?:app)?\.com/api/webhooks/(?P<id>[0-9]{17,20})/(?P<token>[A-Za-z0-9\.\-\_]{60,68})', url) m = re.search(r'discord(?:app)?\.com/api/webhooks/(?P<id>[0-9]{17,20})/(?P<token>[A-Za-z0-9\.\-\_]{60,})', url)
if m is None: if m is None:
raise ValueError('Invalid webhook URL given.') raise ValueError('Invalid webhook URL given.')
@ -1301,7 +1301,13 @@ class Webhook(BaseWebhook):
'name': name, 'name': name,
'channel_id': channel.id, 'channel_id': channel.id,
'guild_id': channel.guild.id, 'guild_id': channel.guild.id,
'user': {'username': user.name, 'discriminator': user.discriminator, 'id': user.id, 'avatar': user._avatar}, 'user': {
'username': user.name,
'discriminator': user.discriminator,
'global_name': user.global_name,
'id': user.id,
'avatar': user._avatar,
},
} }
state = channel._state state = channel._state
@ -1511,8 +1517,7 @@ class Webhook(BaseWebhook):
proxy_auth=self.proxy_auth, proxy_auth=self.proxy_auth,
reason=reason, reason=reason,
) )
elif prefer_auth and self.auth_token:
if prefer_auth and self.auth_token:
data = await adapter.edit_webhook( data = await adapter.edit_webhook(
self.id, self.id,
self.auth_token, self.auth_token,

5
discord/webhook/sync.py

@ -682,7 +682,7 @@ class SyncWebhook(BaseWebhook):
A partial :class:`SyncWebhook`. A partial :class:`SyncWebhook`.
A partial :class:`SyncWebhook` is just a :class:`SyncWebhook` object with an ID and a token. A partial :class:`SyncWebhook` is just a :class:`SyncWebhook` object with an ID and a token.
""" """
m = re.search(r'discord(?:app)?\.com/api/webhooks/(?P<id>[0-9]{17,20})/(?P<token>[A-Za-z0-9\.\-\_]{60,68})', url) m = re.search(r'discord(?:app)?\.com/api/webhooks/(?P<id>[0-9]{17,20})/(?P<token>[A-Za-z0-9\.\-\_]{60,})', url)
if m is None: if m is None:
raise ValueError('Invalid webhook URL given.') raise ValueError('Invalid webhook URL given.')
@ -835,8 +835,7 @@ class SyncWebhook(BaseWebhook):
payload['channel_id'] = channel.id payload['channel_id'] = channel.id
data = adapter.edit_webhook(self.id, self.auth_token, payload=payload, session=self.session, reason=reason) data = adapter.edit_webhook(self.id, self.auth_token, payload=payload, session=self.session, reason=reason)
elif prefer_auth and self.auth_token:
if prefer_auth and self.auth_token:
data = adapter.edit_webhook(self.id, self.auth_token, payload=payload, session=self.session, reason=reason) data = adapter.edit_webhook(self.id, self.auth_token, payload=payload, session=self.session, reason=reason)
elif self.token: elif self.token:
data = adapter.edit_webhook_with_token(self.id, self.token, payload=payload, session=self.session, reason=reason) data = adapter.edit_webhook_with_token(self.id, self.token, payload=payload, session=self.session, reason=reason)

14
discord/widget.py

@ -121,7 +121,7 @@ class WidgetMember(BaseUser):
.. describe:: str(x) .. describe:: str(x)
Returns the widget member's ``name#discriminator``. Returns the widget member's handle (e.g. ``name`` or ``name#discriminator``).
Attributes Attributes
----------- -----------
@ -130,13 +130,17 @@ class WidgetMember(BaseUser):
name: :class:`str` name: :class:`str`
The member's username. The member's username.
discriminator: :class:`str` discriminator: :class:`str`
The member's discriminator. The member's discriminator. This is a legacy concept that is no longer used.
global_name: Optional[:class:`str`]
The member's global nickname, taking precedence over the username in display.
.. versionadded:: 2.3
bot: :class:`bool` bot: :class:`bool`
Whether the member is a bot. Whether the member is a bot.
status: :class:`Status` status: :class:`Status`
The member's status. The member's status.
nick: Optional[:class:`str`] nick: Optional[:class:`str`]
The member's nickname. The member's guild-specific nickname. Takes precedence over the global name.
avatar: Optional[:class:`str`] avatar: Optional[:class:`str`]
The member's avatar hash. The member's avatar hash.
activity: Optional[Union[:class:`BaseActivity`, :class:`Spotify`]] activity: Optional[Union[:class:`BaseActivity`, :class:`Spotify`]]
@ -191,9 +195,7 @@ class WidgetMember(BaseUser):
self.connected_channel: Optional[WidgetChannel] = connected_channel self.connected_channel: Optional[WidgetChannel] = connected_channel
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return f"<WidgetMember name={self.name!r} global_name={self.global_name!r}" f" bot={self.bot} nick={self.nick!r}>"
f"<WidgetMember name={self.name!r} discriminator={self.discriminator!r}" f" bot={self.bot} nick={self.nick!r}>"
)
@property @property
def display_name(self) -> str: def display_name(self) -> str:

19
docs/api.rst

@ -493,6 +493,7 @@ Debug
:param payload: The message that is about to be passed on to the :param payload: The message that is about to be passed on to the
WebSocket library. It can be :class:`bytes` to denote a binary WebSocket library. It can be :class:`bytes` to denote a binary
message or :class:`str` to denote a regular text message. message or :class:`str` to denote a regular text message.
:type payload: Union[:class:`bytes`, :class:`str`]
Gateway Gateway
@ -1363,7 +1364,7 @@ Threads
.. versionadded:: 2.0 .. versionadded:: 2.0
:param payload: The raw event payload data. :param payload: The raw event payload data.
:type member: :class:`RawThreadMembersUpdate` :type payload: :class:`RawThreadMembersUpdate`
Voice Voice
~~~~~~ ~~~~~~
@ -2914,27 +2915,33 @@ of :class:`enum.Enum`.
.. attribute:: blurple .. attribute:: blurple
Represents the default avatar with the color blurple. Represents the default avatar with the colour blurple.
See also :attr:`Colour.blurple` See also :attr:`Colour.blurple`
.. attribute:: grey .. attribute:: grey
Represents the default avatar with the color grey. Represents the default avatar with the colour grey.
See also :attr:`Colour.greyple` See also :attr:`Colour.greyple`
.. attribute:: gray .. attribute:: gray
An alias for :attr:`grey`. An alias for :attr:`grey`.
.. attribute:: green .. attribute:: green
Represents the default avatar with the color green. Represents the default avatar with the colour green.
See also :attr:`Colour.green` See also :attr:`Colour.green`
.. attribute:: orange .. attribute:: orange
Represents the default avatar with the color orange. Represents the default avatar with the colour orange.
See also :attr:`Colour.orange` See also :attr:`Colour.orange`
.. attribute:: red .. attribute:: red
Represents the default avatar with the color red. Represents the default avatar with the colour red.
See also :attr:`Colour.red` See also :attr:`Colour.red`
.. attribute:: pink
Represents the default avatar with the colour pink.
See also :attr:`Colour.pink`
.. versionadded:: 2.3
.. class:: StickerType .. class:: StickerType

2
docs/conf.py

@ -51,7 +51,7 @@ autodoc_typehints = 'none'
# napoleon_attr_annotations = False # napoleon_attr_annotations = False
extlinks = { extlinks = {
'issue': ('https://github.com/Rapptz/discord.py/issues/%s', 'GH-'), 'issue': ('https://github.com/Rapptz/discord.py/issues/%s', 'GH-%s'),
'ddocs': ('https://discord.com/developers/docs/%s', None), 'ddocs': ('https://discord.com/developers/docs/%s', None),
} }

2
docs/discord.rst

@ -61,7 +61,7 @@ If you want to invite your bot you must create an invite URL for it.
1. Make sure you're logged on to the `Discord website <https://discord.com>`_. 1. Make sure you're logged on to the `Discord website <https://discord.com>`_.
2. Navigate to the `application page <https://discord.com/developers/applications>`_ 2. Navigate to the `application page <https://discord.com/developers/applications>`_
3. Click on your bot's page. 3. Click on your bot's page.
4. Go to the "OAuth2" tab. 4. Go to the "OAuth2 > URL Generator" tab.
.. image:: /images/discord_oauth2.png .. image:: /images/discord_oauth2.png
:alt: How the OAuth2 page should look like. :alt: How the OAuth2 page should look like.

2
docs/ext/commands/extensions.rst

@ -54,6 +54,8 @@ Cleaning Up
Although rare, sometimes an extension needs to clean-up or know when it's being unloaded. For cases like these, there is another entry point named ``teardown`` which is similar to ``setup`` except called when the extension is unloaded. Although rare, sometimes an extension needs to clean-up or know when it's being unloaded. For cases like these, there is another entry point named ``teardown`` which is similar to ``setup`` except called when the extension is unloaded.
Exceptions raised in the ``teardown`` function are ignored, and the extension is still unloaded.
.. code-block:: python3 .. code-block:: python3
:caption: basic_ext.py :caption: basic_ext.py

14
docs/migrating.rst

@ -1006,6 +1006,20 @@ Due to a breaking API change by Discord, :meth:`Guild.bans` no longer returns a
async for ban in guild.bans(limit=1000): async for ban in guild.bans(limit=1000):
... ...
Flag classes now have a custom ``bool()`` implementation
--------------------------------------------------------
To allow library users to easily check whether an instance of a flag class has any flags enabled,
using `bool` on them will now only return ``True`` if at least one flag is enabled.
This means that evaluating instances of the following classes in a bool context (such as ``if obj:``) may no longer return ``True``:
- :class:`Intents`
- :class:`MemberCacheFlags`
- :class:`MessageFlags`
- :class:`Permissions`
- :class:`PublicUserFlags`
- :class:`SystemChannelFlags`
Function Signature Changes Function Signature Changes
---------------------------- ----------------------------

102
docs/whats_new.rst

@ -11,6 +11,108 @@ Changelog
This page keeps a detailed human friendly rendering of what's new and changed This page keeps a detailed human friendly rendering of what's new and changed
in specific versions. in specific versions.
.. _vp2p3p1:
v2.3.1
-------
Bug Fixes
~~~~~~~~~~
- Fix username lookup in :meth:`Guild.get_member_named` (:issue:`9451`).
- Use cache data first for :attr:`Interaction.channel` instead of API data.
- This bug usually manifested in incomplete channel objects (e.g. no ``overwrites``) because Discord does not provide this data.
- Fix false positives in :meth:`PartialEmoji.from_str` inappropriately setting ``animated`` to ``True`` (:issue:`9456`, :issue:`9457`).
- Fix certain select types not appearing in :attr:`Message.components` (:issue:`9462`).
- |commands| Change lookup order for :class:`~ext.commands.MemberConverter` and :class:`~ext.commands.UserConverter` to prioritise usernames instead of nicknames.
.. _vp2p3p0:
v2.3.0
--------
New Features
~~~~~~~~~~~~~
- Add support for the new username system (also known as "pomelo").
- Add :attr:`User.global_name` to get their global nickname or "display name".
- Update :attr:`User.display_name` and :attr:`Member.display_name` to understand global nicknames.
- Update ``__str__`` for :class:`User` to drop discriminators if the user has been migrated.
- Update :meth:`Guild.get_member_named` to work with migrated users.
- Update :attr:`User.default_avatar` to work with migrated users.
- |commands| Update user and member converters to understand migrated users.
- Add :attr:`DefaultAvatar.pink` for new pink default avatars.
- Add :meth:`Colour.pink` to get the pink default avatar colour.
- Add support for voice messages (:issue:`9358`)
- Add :attr:`MessageFlags.voice`
- Add :attr:`Attachment.duration` and :attr:`Attachment.waveform`
- Add :meth:`Attachment.is_voice_message`
- This does not support *sending* voice messages because this is currently unsupported by the API.
- Add support for new :attr:`Interaction.channel` attribute from the API update (:issue:`9339`).
- Add support for :attr:`TextChannel.default_thread_slowmode_delay` (:issue:`9291`).
- Add support for :attr:`ForumChannel.default_sort_order` (:issue:`9290`).
- Add support for ``default_reaction_emoji`` and ``default_forum_layout`` in :meth:`Guild.create_forum` (:issue:`9300`).
- Add support for ``widget_channel``, ``widget_enabled``, and ``mfa_level`` in :meth:`Guild.edit` (:issue:`9302`, :issue:`9303`).
- Add various new :class:`Permissions` and changes (:issue:`9312`, :issue:`9325`, :issue:`9358`, :issue:`9378`)
- Add new :attr:`~Permissions.manage_expressions`, :attr:`~Permissions.use_external_sounds`, :attr:`~Permissions.use_soundboard`, :attr:`~Permissions.send_voice_messages`, :attr:`~Permissions.create_expressions` permissions.
- Change :attr:`Permissions.manage_emojis` to be an alias of :attr:`~Permissions.manage_expressions`.
- Add various new properties to :class:`PartialAppInfo` and :class:`AppInfo` (:issue:`9298`).
- Add support for ``with_counts`` parameter to :meth:`Client.fetch_guilds` (:issue:`9369`).
- Add new :meth:`Guild.get_emoji` helper (:issue:`9296`).
- Add :attr:`ApplicationFlags.auto_mod_badge` (:issue:`9313`).
- Add :attr:`Guild.max_stage_video_users` and :attr:`Guild.safety_alerts_channel` (:issue:`9318`).
- Add support for ``raid_alerts_disabled`` and ``safety_alerts_channel`` in :meth:`Guild.edit` (:issue:`9318`).
- |commands| Add :attr:`BadLiteralArgument.argument <ext.commands.BadLiteralArgument.argument>` to get the failed argument's value (:issue:`9283`).
- |commands| Add :attr:`Context.filesize_limit <ext.commands.Context.filesize_limit>` property (:issue:`9416`).
- |commands| Add support for :attr:`Parameter.displayed_name <ext.commands.Parameter.displayed_name>` (:issue:`9427`).
Bug Fixes
~~~~~~~~~~
- Fix ``FileHandler`` handlers being written ANSI characters when the bot is executed inside PyCharm.
- This has the side effect of removing coloured logs from the PyCharm terminal due an upstream bug involving TTY detection. This issue is tracked under `PY-43798 <https://youtrack.jetbrains.com/issue/PY-43798>`_.
- Fix channel edits with :meth:`Webhook.edit` sending two requests instead of one.
- Fix :attr:`StageChannel.last_message_id` always being ``None`` (:issue:`9422`).
- Fix piped audio input ending prematurely (:issue:`9001`, :issue:`9380`).
- Fix persistent detection for :class:`ui.TextInput` being incorrect if the ``custom_id`` is set later (:issue:`9438`).
- Fix custom attributes not being copied over when inheriting from :class:`app_commands.Group` (:issue:`9383`).
- Fix AutoMod audit log entry error due to empty channel_id (:issue:`9384`).
- Fix handling of ``around`` parameter in :meth:`abc.Messageable.history` (:issue:`9388`).
- Fix occasional :exc:`AttributeError` when accessing the :attr:`ClientUser.mutual_guilds` property (:issue:`9387`).
- Fix :func:`utils.escape_markdown` not escaping the new markdown (:issue:`9361`).
- Fix webhook targets not being converted in audit logs (:issue:`9332`).
- Fix error when not passing ``enabled`` in :meth:`Guild.create_automod_rule` (:issue:`9292`).
- Fix how various parameters are handled in :meth:`Guild.create_scheduled_event` (:issue:`9275`).
- Fix not sending the ``ssrc`` parameter when sending the SPEAKING payload (:issue:`9301`).
- Fix :attr:`Message.guild` being ``None`` sometimes when received via an interaction.
- Fix :attr:`Message.system_content` for :attr:`MessageType.channel_icon_change` (:issue:`9410`).
Miscellaneous
~~~~~~~~~~~~~~
- Update the base :attr:`Guild.filesize_limit` to 25MiB (:issue:`9353`).
- Allow Interaction webhook URLs to be used in :meth:`Webhook.from_url`.
- Set the socket family of internal connector to ``AF_INET`` to prevent IPv6 connections (:issue:`9442`, :issue:`9443`).
.. _vp2p2p3:
v2.2.3
-------
Bug Fixes
~~~~~~~~~~
- Fix crash from Discord sending null ``channel_id`` for automod audit logs.
- Fix ``channel`` edits when using :meth:`Webhook.edit` sending two requests.
- Fix :attr:`AuditLogEntry.target` being ``None`` for invites (:issue:`9336`).
- Fix :exc:`KeyError` when accessing data for :class:`GuildSticker` (:issue:`9324`).
.. _vp2p2p2: .. _vp2p2p2:
v2.2.2 v2.2.2

43
tests/test_app_commands_group.py

@ -354,3 +354,46 @@ def test_cog_group_with_subclassed_subclass_group():
assert cog.sub_group.my_command.parent is cog.sub_group assert cog.sub_group.my_command.parent is cog.sub_group
assert cog.my_cog_command.parent is cog.sub_group assert cog.my_cog_command.parent is cog.sub_group
assert cog.my_cog_command.binding is cog assert cog.my_cog_command.binding is cog
def test_cog_group_with_custom_state_issue9383():
class InnerGroup(app_commands.Group):
def __init__(self):
super().__init__()
self.state: int = 20
@app_commands.command()
async def my_command(self, interaction: discord.Interaction) -> None:
...
class MyCog(commands.GroupCog):
inner = InnerGroup()
@app_commands.command()
async def my_regular_command(self, interaction: discord.Interaction) -> None:
...
@inner.command()
async def my_inner_command(self, interaction: discord.Interaction) -> None:
...
cog = MyCog()
assert cog.inner.state == 20
assert cog.my_regular_command is not MyCog.my_regular_command
# Basically the same tests as above... (superset?)
assert MyCog.__cog_app_commands__[0].parent is not cog
assert MyCog.__cog_app_commands__[0].parent is not cog.__cog_app_commands_group__
assert InnerGroup.__discord_app_commands_group_children__[0].parent is not cog.inner
assert InnerGroup.__discord_app_commands_group_children__[0].parent is not cog.inner
assert cog.inner is not MyCog.inner
assert cog.inner.my_command is not InnerGroup.my_command
assert cog.inner.my_command is not InnerGroup.my_command
assert cog.my_inner_command is not MyCog.my_inner_command
assert not hasattr(cog.inner, 'my_inner_command')
assert cog.__cog_app_commands_group__ is not None
assert cog.__cog_app_commands_group__.parent is None
assert cog.inner.parent is cog.__cog_app_commands_group__
assert cog.inner.my_command.parent is cog.inner
assert cog.my_inner_command.parent is cog.inner
assert cog.my_inner_command.binding is cog

Loading…
Cancel
Save