diff --git a/.readthedocs.yml b/.readthedocs.yml index f67ef5268..68c792379 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,7 +2,9 @@ version: 2 formats: [] build: - image: latest + os: "ubuntu-22.04" + tools: + python: "3.8" sphinx: configuration: docs/conf.py @@ -10,7 +12,6 @@ sphinx: builder: html python: - version: 3.8 install: - method: pip path: . diff --git a/discord/__init__.py b/discord/__init__.py index 81d7bf570..2fbb84f86 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __title__ = 'discord' __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.3.0a' +__version__ = '2.4.0a' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -79,7 +79,7 @@ class VersionInfo(NamedTuple): 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()) diff --git a/discord/abc.py b/discord/abc.py index c6b63920f..242662d15 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -219,7 +219,9 @@ class User(Snowflake, Protocol): name: :class:`str` The user's username. 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` If the user is a bot account. system: :class:`bool` @@ -228,6 +230,7 @@ class User(Snowflake, Protocol): name: str discriminator: str + global_name: Optional[str] bot: bool system: bool @@ -248,7 +251,7 @@ class User(Snowflake, Protocol): @property 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 @property @@ -943,8 +946,6 @@ class GuildChannel: if len(permissions) > 0: raise TypeError('Cannot mix overwrite and keyword arguments.') - # TODO: wait for event - if overwrite is None: await http.delete_channel_permissions(self.id, target.id, reason=reason) elif isinstance(overwrite, PermissionOverwrite): @@ -1722,12 +1723,12 @@ class Messageable: async def _around_strategy(retrieve: int, around: Optional[Snowflake], limit: Optional[int]): if not around: - return [] + return [], None, 0 around_id = around.id if around else None 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]): after_id = after.id if after else None diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index fd6388443..8e6693408 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -46,6 +46,7 @@ from typing import ( ) import re +from copy import copy as shallow_copy from ..enums import AppCommandOptionType, AppCommandType, ChannelType, Locale from .models import Choice @@ -707,25 +708,10 @@ class Command(Generic[GroupT, P, T]): ) -> Command: bindings = {} if bindings is MISSING else bindings - cls = self.__class__ - 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 = shallow_copy(self) copy._params = self._params.copy() - copy.module = self.module copy.parent = parent 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: setattr(copy.binding, copy._attr, copy) @@ -1622,22 +1608,9 @@ class Group: ) -> Group: bindings = {} if bindings is MISSING else bindings - cls = self.__class__ - 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 = shallow_copy(self) 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.extras = self.extras bindings[self] = copy diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py index 0816c5b87..8f009181b 100644 --- a/discord/app_commands/transformers.py +++ b/discord/app_commands/transformers.py @@ -750,7 +750,7 @@ def get_supported_annotation( try: return (_mapping[annotation], MISSING, True) - except KeyError: + except (KeyError, TypeError): pass if isinstance(annotation, Transformer): diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 3c1aa6eca..12d9470ac 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -669,7 +669,9 @@ class AuditLogEntry(Hashable): ): channel_id = utils._get_as_snowflake(extra, 'channel_id') 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) self.extra = _AuditLogProxyAutoModAction( diff --git a/discord/channel.py b/discord/channel.py index cd5490b2e..8c212c0cb 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1687,6 +1687,7 @@ class StageChannel(VocalGuildChannel): *, name: str = ..., nsfw: bool = ..., + user_limit: int = ..., position: int = ..., sync_permissions: int = ..., category: Optional[CategoryChannel] = ..., @@ -1726,6 +1727,8 @@ class StageChannel(VocalGuildChannel): The new channel's position. nsfw: :class:`bool` To mark the channel as NSFW or not. + user_limit: :class:`int` + The new channel's user limit. sync_permissions: :class:`bool` Whether to sync permissions with the channel's new or pre-existing 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): 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.id: int = int(data['id']) diff --git a/discord/client.py b/discord/client.py index 298959b21..c4c59e75f 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2059,13 +2059,15 @@ class Client: limit: Optional[int] = 200, before: Optional[SnowflakeTime] = None, after: Optional[SnowflakeTime] = None, + with_counts: bool = True, ) -> AsyncIterator[Guild]: """Retrieves an :term:`asynchronous iterator` that enables receiving your guilds. .. note:: 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:: @@ -2106,6 +2108,12 @@ class Client: Retrieve guilds after this date or object. 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. + 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 ------ @@ -2120,7 +2128,7 @@ class Client: async def _before_strategy(retrieve: int, before: Optional[Snowflake], limit: Optional[int]): 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 limit is not None: @@ -2132,7 +2140,7 @@ class Client: async def _after_strategy(retrieve: int, after: Optional[Snowflake], limit: Optional[int]): 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 limit is not None: diff --git a/discord/colour.py b/discord/colour.py index 52dca9cc0..e640f9df4 100644 --- a/discord/colour.py +++ b/discord/colour.py @@ -509,5 +509,15 @@ class Colour: """ 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 diff --git a/discord/components.py b/discord/components.py index c0a213efa..5c3679b13 100644 --- a/discord/components.py +++ b/discord/components.py @@ -527,7 +527,7 @@ def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, Acti return ActionRow(data) elif data['type'] == 2: return Button(data) - elif data['type'] == 3: - return SelectMenu(data) elif data['type'] == 4: return TextInput(data) + elif data['type'] in (3, 5, 6, 7, 8): + return SelectMenu(data) diff --git a/discord/enums.py b/discord/enums.py index b8e0bc36d..de5ea6a11 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -296,6 +296,7 @@ class DefaultAvatar(Enum): green = 2 orange = 3 red = 4 + pink = 5 def __str__(self) -> str: return self.name diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 02136bce8..319f85b80 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -550,6 +550,8 @@ class Cog(metaclass=CogMeta): Subclasses must replace this if they want special unloading behaviour. + Exceptions raised in this method are ignored during extension unloading. + .. versionchanged:: 2.0 This method can now be a :term:`coroutine`. diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 92a1a6b51..40ef48cf8 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -430,6 +430,14 @@ class Context(discord.abc.Messageable, Generic[BotT]): return None 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 def guild(self) -> Optional[Guild]: """Optional[:class:`.Guild`]: Returns the guild associated with this context's command. None if not available.""" diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index e5be9d47f..7255f1715 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -186,9 +186,11 @@ class MemberConverter(IDConverter[discord.Member]): 1. Lookup by ID. 2. Lookup by mention. - 3. Lookup by name#discrim - 4. Lookup by name - 5. Lookup by nickname + 3. Lookup by username#discriminator (deprecated). + 4. Lookup by username#0 (deprecated, only gets users that migrated from their discriminator). + 5. Lookup by user name. + 6. Lookup by global name. + 7. Lookup by guild nickname. .. versionchanged:: 1.5 Raise :exc:`.MemberNotFound` instead of generic :exc:`.BadArgument` @@ -196,17 +198,29 @@ class MemberConverter(IDConverter[discord.Member]): .. versionchanged:: 1.5.1 This converter now lazily fetches members from the gateway and HTTP APIs, 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]: cache = guild._state.member_cache_flags.joined - if len(argument) > 5 and argument[-5] == '#': - username, _, discriminator = argument.rpartition('#') - members = await guild.query_members(username, limit=100, cache=cache) - return discord.utils.get(members, name=username, discriminator=discriminator) + username, _, discriminator = argument.rpartition('#') + + # If # isn't found then "discriminator" actually has the username + 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: - members = await guild.query_members(argument, limit=100, cache=cache) - return discord.utils.find(lambda m: m.name == argument or m.nick == argument, members) + lookup = argument + 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]: ws = bot._get_websocket(shard_id=guild.shard_id) @@ -273,8 +287,10 @@ class UserConverter(IDConverter[discord.User]): 1. Lookup by ID. 2. Lookup by mention. - 3. Lookup by name#discrim - 4. Lookup by name + 3. Lookup by username#discriminator (deprecated). + 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 Raise :exc:`.UserNotFound` instead of generic :exc:`.BadArgument` @@ -282,6 +298,10 @@ class UserConverter(IDConverter[discord.User]): .. versionchanged:: 1.6 This converter now lazily fetches users from the HTTP APIs if an ID is passed 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: @@ -300,25 +320,18 @@ class UserConverter(IDConverter[discord.User]): return result # type: ignore - arg = argument + username, _, discriminator = argument.rpartition('#') - # Remove the '@' character if this is the first character from the argument - if arg[0] == '@': - # Remove first character - arg = arg[1:] + # If # isn't found then "discriminator" actually has the username + if not username: + discriminator, username = username, discriminator - # check for discriminator if it exists, - if len(arg) > 5 and arg[-5] == '#': - discrim = arg[-4:] - name = arg[:-5] - 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 + if discriminator == '0' or (len(discriminator) == 4 and discriminator.isdigit()): + predicate = lambda u: u.name == username and u.discriminator == discriminator + else: + predicate = lambda u: u.name == argument or u.global_name == argument - predicate = lambda u: u.name == arg result = discord.utils.find(predicate, state._users.values()) - if result is None: raise UserNotFound(argument) diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 8140dca00..ffbefe284 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -151,6 +151,7 @@ def get_signature_parameters( parameter._default = default.default parameter._description = default._description parameter._displayed_default = default._displayed_default + parameter._displayed_name = default._displayed_name annotation = parameter.annotation @@ -194,8 +195,13 @@ def extract_descriptions_from_docstring(function: Callable[..., Any], params: Di description, param_docstring = divide for match in NUMPY_DOCSTRING_ARG_REGEX.finditer(param_docstring): name = match.group('name') + 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] if param.description is None: @@ -1169,7 +1175,9 @@ class Command(_BaseCommand, Generic[CogT, P, T]): return '' result = [] - for name, param in params.items(): + for param in params.values(): + name = param.displayed_name or param.name + greedy = isinstance(param.converter, Greedy) optional = False # postpone evaluation of if it's an optional argument diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index b25e6ae95..736d0b5af 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -182,7 +182,7 @@ class MissingRequiredArgument(UserInputError): def __init__(self, param: Parameter) -> None: 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): @@ -201,7 +201,7 @@ class MissingRequiredAttachment(UserInputError): def __init__(self, param: Parameter) -> None: 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): @@ -901,7 +901,7 @@ class BadUnionArgument(UserInputError): else: 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): @@ -938,7 +938,7 @@ class BadLiteralArgument(UserInputError): else: 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): diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index d8f341474..163bc2694 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -294,6 +294,9 @@ class _HelpCommandImpl(Command): cog.walk_commands = cog.walk_commands.__wrapped__ 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: r"""The base implementation for help command formatting. @@ -1166,7 +1169,7 @@ class DefaultHelpCommand(HelpCommand): get_width = discord.utils._string_width for argument in arguments: - name = argument.name + name = argument.displayed_name or argument.name width = max_size - (get_width(name) - len(name)) entry = f'{self.indent * " "}{name:<{width}} {argument.description or self.default_argument_description}' # we do not want to shorten the default value, if any. diff --git a/discord/ext/commands/parameters.py b/discord/ext/commands/parameters.py index 5039a16aa..d3302f5a3 100644 --- a/discord/ext/commands/parameters.py +++ b/discord/ext/commands/parameters.py @@ -87,7 +87,7 @@ class Parameter(inspect.Parameter): .. versionadded:: 2.0 """ - __slots__ = ('_displayed_default', '_description', '_fallback') + __slots__ = ('_displayed_default', '_description', '_fallback', '_displayed_name') def __init__( self, @@ -97,6 +97,7 @@ class Parameter(inspect.Parameter): annotation: Any = empty, description: str = empty, displayed_default: str = empty, + displayed_name: str = empty, ) -> None: super().__init__(name=name, kind=kind, default=default, annotation=annotation) self._name = name @@ -106,6 +107,7 @@ class Parameter(inspect.Parameter): self._annotation = annotation self._displayed_default = displayed_default self._fallback = False + self._displayed_name = displayed_name def replace( self, @@ -116,6 +118,7 @@ class Parameter(inspect.Parameter): annotation: Any = MISSING, description: str = MISSING, displayed_default: Any = MISSING, + displayed_name: Any = MISSING, ) -> Self: if name is MISSING: name = self._name @@ -129,6 +132,8 @@ class Parameter(inspect.Parameter): description = self._description if displayed_default is MISSING: displayed_default = self._displayed_default + if displayed_name is MISSING: + displayed_name = self._displayed_name return self.__class__( name=name, @@ -137,6 +142,7 @@ class Parameter(inspect.Parameter): annotation=annotation, description=description, displayed_default=displayed_default, + displayed_name=displayed_name, ) 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) + @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: """|coro| @@ -193,8 +207,9 @@ def parameter( default: Any = empty, description: str = empty, displayed_default: str = empty, + displayed_name: str = empty, ) -> 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. @@ -221,6 +236,10 @@ def parameter( The description of this parameter. displayed_default: :class:`str` The displayed default in :attr:`Command.signature`. + displayed_name: :class:`str` + The name that is displayed to the user. + + .. versionadded:: 2.3 """ return Parameter( name='empty', @@ -229,6 +248,7 @@ def parameter( default=default, description=description, displayed_default=displayed_default, + displayed_name=displayed_name, ) @@ -240,12 +260,13 @@ class ParameterAlias(Protocol): default: Any = empty, description: str = empty, displayed_default: str = empty, + displayed_name: str = empty, ) -> Any: ... param: ParameterAlias = parameter -r"""param(\*, converter=..., default=..., description=..., displayed_default=...) +r"""param(\*, converter=..., default=..., description=..., displayed_default=..., displayed_name=...) An alias for :func:`parameter`. diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index cf175b7a8..1ffb0b851 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -144,6 +144,7 @@ class Loop(Generic[LF]): time: Union[datetime.time, Sequence[datetime.time]], count: Optional[int], reconnect: bool, + name: Optional[str], ) -> None: self.coro: LF = coro self.reconnect: bool = reconnect @@ -165,6 +166,7 @@ class Loop(Generic[LF]): self._is_being_cancelled = False self._has_failed = 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: raise ValueError('count must be greater than 0 or None.') @@ -282,6 +284,7 @@ class Loop(Generic[LF]): time=self._time, count=self.count, reconnect=self.reconnect, + name=self._name, ) copy._injected = obj copy._before_loop = self._before_loop @@ -395,7 +398,7 @@ class Loop(Generic[LF]): args = (self._injected, *args) 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 def stop(self) -> None: @@ -770,6 +773,7 @@ def loop( time: Union[datetime.time, Sequence[datetime.time]] = MISSING, count: Optional[int] = None, reconnect: bool = True, + name: Optional[str] = None, ) -> Callable[[LF], Loop[LF]]: """A decorator that schedules a task in the background for you with optional reconnect logic. The decorator returns a :class:`Loop`. @@ -802,6 +806,12 @@ def loop( Whether to handle errors and restart the task using an exponential back-off algorithm similar to the 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 -------- @@ -821,6 +831,7 @@ def loop( count=count, time=time, reconnect=reconnect, + name=name, ) return decorator diff --git a/discord/flags.py b/discord/flags.py index 1b0005bd2..27eaf2628 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -239,6 +239,12 @@ class SystemChannelFlags(BaseFlags): Returns an iterator of ``(name, value)`` pairs. This allows it 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 ----------- value: :class:`int` @@ -361,6 +367,12 @@ class MessageFlags(BaseFlags): Returns an iterator of ``(name, value)`` pairs. This allows it 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 Attributes @@ -451,6 +463,14 @@ class MessageFlags(BaseFlags): """ 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() class PublicUserFlags(BaseFlags): @@ -501,6 +521,12 @@ class PublicUserFlags(BaseFlags): to be, for example, constructed as a dict or a list of pairs. Note that aliases are not shown. + .. describe:: bool(b) + + Returns whether any flag is set to ``True``. + + .. versionadded:: 2.0 + .. versionadded:: 1.4 Attributes @@ -685,6 +711,12 @@ class Intents(BaseFlags): Returns an iterator of ``(name, value)`` pairs. This allows it 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 ----------- value: :class:`int` @@ -784,6 +816,7 @@ class Intents(BaseFlags): - :attr:`User.name` - :attr:`User.avatar` - :attr:`User.discriminator` + - :attr:`User.global_name` For more information go to the :ref:`member intent documentation `. @@ -817,7 +850,7 @@ class Intents(BaseFlags): """ return 1 << 2 - @flag_value + @alias_flag_value def emojis(self): """:class:`bool`: Alias of :attr:`.emojis_and_stickers`. @@ -826,7 +859,7 @@ class Intents(BaseFlags): """ return 1 << 3 - @alias_flag_value + @flag_value def emojis_and_stickers(self): """: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 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 ----------- value: :class:`int` @@ -1413,6 +1452,10 @@ class ApplicationFlags(BaseFlags): to be, for example, constructed as a dict or a list of pairs. Note that aliases are not shown. + .. describe:: bool(b) + + Returns whether any flag is set to ``True``. + .. versionadded:: 2.0 Attributes @@ -1422,6 +1465,15 @@ class ApplicationFlags(BaseFlags): 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 def gateway_presence(self): """: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. Note that aliases are not shown. + .. describe:: bool(b) + + Returns whether any flag is set to ``True``. + .. versionadded:: 2.0 Attributes @@ -1635,6 +1691,10 @@ class AutoModPresets(ArrayFlags): to be, for example, constructed as a dict or a list of pairs. Note that aliases are not shown. + .. describe:: bool(b) + + Returns whether any flag is set to ``True``. + Attributes ----------- value: :class:`int` @@ -1719,6 +1779,10 @@ class MemberFlags(BaseFlags): to be, for example, constructed as a dict or a list of pairs. Note that aliases are not shown. + .. describe:: bool(b) + + Returns whether any flag is set to ``True``. + Attributes ----------- diff --git a/discord/guild.py b/discord/guild.py index 63d8ac5c1..9ced6114b 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -187,8 +187,6 @@ class Guild(Hashable): .. versionadded:: 2.0 afk_timeout: :class:`int` 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` The guild's ID. owner_id: :class:`int` @@ -251,13 +249,13 @@ class Guild(Hashable): approximate_member_count: Optional[:class:`int`] 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 approximate_presence_count: Optional[:class:`int`] The approximate number of members currently active in the guild. 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 premium_progress_bar_enabled: :class:`bool` @@ -268,11 +266,14 @@ class Guild(Hashable): Indicates if the guild has widget enabled. .. versionadded:: 2.0 + max_stage_video_users: Optional[:class:`int`] + The maximum amount of users in a stage video channel. + + .. versionadded:: 2.3 """ __slots__ = ( 'afk_timeout', - 'afk_channel', 'name', 'id', 'unavailable', @@ -295,6 +296,7 @@ class Guild(Hashable): 'vanity_url_code', 'widget_enabled', '_widget_channel_id', + '_afk_channel_id', '_members', '_channels', '_icon', @@ -316,12 +318,14 @@ class Guild(Hashable): 'approximate_member_count', 'approximate_presence_count', 'premium_progress_bar_enabled', + '_safety_alerts_channel_id', + 'max_stage_video_users', ) _PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = { - None: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), - 0: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), - 1: _GuildLimit(emoji=100, stickers=15, bitrate=128e3, 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=utils.DEFAULT_FILE_SIZE_LIMIT_BYTES), + 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), 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_members: Optional[int] = guild.get('max_members') 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_subscription_count: int = guild.get('premium_subscription_count') or 0 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._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._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.mfa_level: MFALevel = try_enum(MFALevel, guild.get('mfa_level', 0)) self.approximate_presence_count: Optional[int] = guild.get('approximate_presence_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.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._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 - - # TODO: refactor/remove? - def _sync(self, data: GuildPayload) -> None: - try: - self._large = data['large'] - except KeyError: - pass - - if 'channels' in data: - channels = data['channels'] + if 'channels' in guild: + channels = guild['channels'] for c in channels: factory, ch_type = _guild_channel_factory(c['type']) if factory: 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'])) cache_joined = self._state.member_cache_flags.joined cache_voice = self._state.member_cache_flags.voice 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 if cache_joined or member.id == self_id or (cache_voice and member.id in self._voice_states): self._add_member(member) empty_tuple = () - for presence in data.get('presences', []): + for presence in guild.get('presences', []): user_id = int(presence['user']['id']) member = self.get_member(user_id) if member is not None: member._presence_update(presence, empty_tuple) # type: ignore - if 'threads' in data: - threads = data['threads'] + if 'threads' in guild: + threads = guild['threads'] for thread in threads: self._add_thread(Thread(guild=self, state=self._state, data=thread)) - if 'stage_instances' in data: - for s in data['stage_instances']: + if 'stage_instances' in guild: + for s in guild['stage_instances']: stage_instance = StageInstance(guild=self, data=s, state=self._state) self._stage_instances[stage_instance.id] = stage_instance - if 'guild_scheduled_events' in data: - for s in data['guild_scheduled_events']: + if 'guild_scheduled_events' in guild: + for s in guild['guild_scheduled_events']: scheduled_event = ScheduledEvent(data=s, state=self._state) self._scheduled_events[scheduled_event.id] = scheduled_event @@ -763,6 +759,14 @@ class Guild(Hashable): return emoji 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 def system_channel(self) -> Optional[TextChannel]: """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 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 def widget_channel(self) -> Optional[Union[TextChannel, ForumChannel, VoiceChannel, StageChannel]]: """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]: """Returns the first member found that matches the name provided. - The name can have an optional discriminator argument, e.g. "Jake#0001" - 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. + The name is looked up in the following order: - If a nickname is passed, then it is looked up via the nickname. Note - however, that a nickname + discriminator combo will not lookup the nickname - but rather the username + discriminator combo due to nickname + discriminator - not being unique. + - Username#Discriminator (deprecated) + - Username#0 (deprecated, only gets users that migrated from their discriminator) + - Nickname + - Global name + - Username If no member is found, ``None`` is returned. @@ -1073,10 +1086,14 @@ class Guild(Hashable): ``name`` parameter is now positional-only. + .. deprecated:: 2.3 + + Looking up users via discriminator due to Discord API change. + Parameters ----------- name: :class:`str` - The name of the member to lookup with an optional discriminator. + The name of the member to lookup. Returns -------- @@ -1085,22 +1102,19 @@ class Guild(Hashable): then ``None`` is returned. """ - result = None members = self.members - if len(name) > 5 and name[-5] == '#': - # The 5 length is checking to see if #0000 is in the string, - # as a#0000 has a length of 6, the minimum for a potential - # discriminator lookup. - potential_discriminator = name[-4:] - - # do the actual lookup and return if found - # if it isn't found then we'll do a full name lookup below. - result = utils.get(members, name=name[:-5], discriminator=potential_discriminator) - if result is not None: - return result + + username, _, discriminator = name.rpartition('#') + + # If # isn't found then "discriminator" actually has the username + if not username: + discriminator, username = username, discriminator + + if discriminator == '0' or (len(discriminator) == 4 and discriminator.isdigit()): + return utils.find(lambda m: m.name == username and m.discriminator == discriminator, members) 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) @@ -1304,7 +1318,7 @@ class Guild(Hashable): nsfw: :class:`bool` To mark the channel as NSFW or not. 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 default_auto_archive_duration: :class:`int` @@ -1821,6 +1835,8 @@ class Guild(Hashable): widget_enabled: bool = MISSING, widget_channel: Optional[Snowflake] = MISSING, mfa_level: MFALevel = MISSING, + raid_alerts_disabled: bool = MISSING, + safety_alerts_channel: TextChannel = MISSING, ) -> Guild: r"""|coro| @@ -1935,6 +1951,18 @@ class Guild(Hashable): reason: Optional[:class:`str`] 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 ------- Forbidden @@ -1946,9 +1974,9 @@ class Guild(Hashable): PNG or JPG. This is also raised if you are not the owner of the guild and request an ownership transfer. TypeError - The type passed to the ``default_notifications``, ``verification_level``, - ``explicit_content_filter``, ``system_channel_flags``, or ``mfa_level`` parameter was - of the incorrect type. + The type passed to the ``default_notifications``, ``rules_channel``, ``public_updates_channel``, + ``safety_alerts_channel`` ``verification_level``, ``explicit_content_filter``, + ``system_channel_flags``, or ``mfa_level`` parameter was of the incorrect type. Returns -------- @@ -2020,14 +2048,33 @@ class Guild(Hashable): if rules_channel is None: fields['rules_channel_id'] = rules_channel 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 if public_updates_channel is not MISSING: if public_updates_channel is None: fields['public_updates_channel_id'] = public_updates_channel 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 + 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 self.owner_id != self._state.self_id: 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 - 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) if community is not MISSING: @@ -2078,6 +2125,12 @@ class Guild(Hashable): else: 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) 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) role = Role(guild=self, data=data, state=self._state) - # TODO: add to cache return role async def edit_role_positions(self, positions: Mapping[Snowflake, int], *, reason: Optional[str] = None) -> List[Role]: diff --git a/discord/http.py b/discord/http.py index e3c053b27..d30d7a14b 100644 --- a/discord/http.py +++ b/discord/http.py @@ -48,6 +48,7 @@ from typing import ( from urllib.parse import quote as _uriquote from collections import deque import datetime +import socket import aiohttp @@ -785,7 +786,8 @@ class HTTPClient: async def static_login(self, token: str) -> user.User: # Necessary to get aiohttp to stop complaining about session creation 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( connector=self.connector, @@ -1374,9 +1376,11 @@ class HTTPClient: limit: int, before: Optional[Snowflake] = None, after: Optional[Snowflake] = None, + with_counts: bool = True, ) -> Response[List[guild.Guild]]: params: Dict[str, Any] = { 'limit': limit, + 'with_counts': int(with_counts), } if before: @@ -1427,6 +1431,7 @@ class HTTPClient: 'public_updates_channel_id', 'preferred_locale', 'premium_progress_bar_enabled', + 'safety_alerts_channel_id', ) payload = {k: v for k, v in fields.items() if k in valid_keys} diff --git a/discord/interactions.py b/discord/interactions.py index f9ed7976d..595414b2f 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -25,6 +25,8 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations + +import logging from typing import Any, Dict, Optional, Generic, TYPE_CHECKING, Sequence, Tuple, Union import asyncio import datetime @@ -33,7 +35,7 @@ from . import utils from .enums import try_enum, Locale, InteractionType, InteractionResponseType from .errors import InteractionResponded, HTTPException, ClientException, DiscordException from .flags import MessageFlags -from .channel import PartialMessageable, ChannelType +from .channel import ChannelType from ._types import ClientT 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 .app_commands.namespace import Namespace from .app_commands.translator import locale_str, TranslationContext, TranslationContextLocation +from .channel import _threaded_channel_factory __all__ = ( 'Interaction', @@ -69,12 +72,19 @@ if TYPE_CHECKING: from .ui.view import View from .app_commands.models import Choice, ChoiceT 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 .app_commands.commands import Command, ContextMenu InteractionChannel = Union[ - VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel, Thread, PartialMessageable + VoiceChannel, + StageChannel, + TextChannel, + ForumChannel, + CategoryChannel, + Thread, + DMChannel, + GroupChannel, ] MISSING: Any = utils.MISSING @@ -96,8 +106,10 @@ class Interaction(Generic[ClientT]): The interaction type. guild_id: Optional[:class:`int`] The guild ID the interaction was sent from. - channel_id: Optional[:class:`int`] - The channel ID the interaction was sent from. + channel: Optional[Union[:class:`abc.GuildChannel`, :class:`abc.PrivateChannel`, :class:`Thread`]] + 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` The application ID that the interaction was for. user: Union[:class:`User`, :class:`Member`] @@ -128,7 +140,6 @@ class Interaction(Generic[ClientT]): 'id', 'type', 'guild_id', - 'channel_id', 'data', 'application_id', 'message', @@ -148,7 +159,7 @@ class Interaction(Generic[ClientT]): '_original_response', '_cs_response', '_cs_followup', - '_cs_channel', + 'channel', '_cs_namespace', '_cs_command', ) @@ -171,8 +182,8 @@ class Interaction(Generic[ClientT]): self.data: Optional[InteractionData] = data.get('data') self.token: str = data['token'] 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.channel: Optional[InteractionChannel] = None self.application_id: int = int(data['application_id']) self.locale: Locale = try_enum(Locale, data.get('locale', 'en-US')) @@ -182,6 +193,26 @@ class Interaction(Generic[ClientT]): except KeyError: 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] try: # 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._app_permissions: int = int(data.get('app_permissions', 0)) - if self.guild_id: - guild = self._state._get_or_create_unavailable_guild(self.guild_id) - + if guild is not None: # Upgrade Message.guild in case it's missing with partial guild data if self.message is not None and self.message.guild is None: self.message.guild = guild @@ -227,21 +256,10 @@ class Interaction(Generic[ClientT]): """Optional[:class:`Guild`]: The guild the interaction was sent from.""" return self._state and self._state._get_guild(self.guild_id) - @utils.cached_slot_property('_cs_channel') - def channel(self) -> Optional[InteractionChannel]: - """Optional[Union[:class:`abc.GuildChannel`, :class:`PartialMessageable`, :class:`Thread`]]: The channel the interaction was sent from. - - 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 + def channel_id(self) -> Optional[int]: + """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 @property def permissions(self) -> Permissions: @@ -1025,8 +1043,8 @@ class _InteractionMessageState: def _get_guild(self, guild_id): return self._parent._get_guild(guild_id) - def store_user(self, data): - return self._parent.store_user(data) + def store_user(self, data, *, cache: bool = True): + return self._parent.store_user(data, cache=cache) def create_user(self, data): return self._parent.create_user(data) diff --git a/discord/member.py b/discord/member.py index ee303f5bc..04ce645c9 100644 --- a/discord/member.py +++ b/discord/member.py @@ -274,7 +274,7 @@ class Member(discord.abc.Messageable, _UserTag): .. describe:: str(x) - Returns the member's name with the discriminator. + Returns the member's handle (e.g. ``name`` or ``name#discriminator``). Attributes ---------- @@ -293,7 +293,7 @@ class Member(discord.abc.Messageable, _UserTag): guild: :class:`Guild` The guild that the member belongs to. 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` Whether the member is pending member verification. @@ -329,6 +329,7 @@ class Member(discord.abc.Messageable, _UserTag): name: str id: int discriminator: str + global_name: Optional[str] bot: bool system: bool created_at: datetime.datetime @@ -368,7 +369,7 @@ class Member(discord.abc.Messageable, _UserTag): def __repr__(self) -> str: return ( - f'' ) @@ -463,12 +464,18 @@ class Member(discord.abc.Messageable, _UserTag): def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, 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 - 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: 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 return to_return, u @@ -581,11 +588,11 @@ class Member(discord.abc.Messageable, _UserTag): def display_name(self) -> str: """:class:`str`: Returns the user's display name. - For regular users this is just their username, but - if they have a guild specific nickname then that + For regular users this is just their global name or their username, + but if they have a guild specific nickname then that is returned instead. """ - return self.nick or self.name + return self.nick or self.global_name or self.name @property def display_avatar(self) -> Asset: diff --git a/discord/message.py b/discord/message.py index c96c7e022..338d9c33e 100644 --- a/discord/message.py +++ b/discord/message.py @@ -183,6 +183,14 @@ class Attachment(Hashable): Whether the attachment is ephemeral. .. 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__ = ( @@ -197,6 +205,8 @@ class Attachment(Hashable): 'content_type', 'description', 'ephemeral', + 'duration', + 'waveform', ) def __init__(self, *, data: AttachmentPayload, state: ConnectionState): @@ -211,11 +221,19 @@ class Attachment(Hashable): self.content_type: Optional[str] = data.get('content_type') self.description: Optional[str] = data.get('description') 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: """:class:`bool`: Whether this attachment contains a 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: return f'' @@ -1790,7 +1808,7 @@ class Message(PartialMessage, Hashable): self.nonce = value 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): found = self.guild.get_member(self.author.id) if found is not None: @@ -1809,7 +1827,6 @@ class Message(PartialMessage, Hashable): author._update_from_message(member) # type: ignore except AttributeError: # It's a user here - # TODO: consider adding to cache here self.author = Member._from_message(message=self, data=member) 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}**' 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: return f'{self.author.name} pinned a message to this channel.' diff --git a/discord/opus.py b/discord/opus.py index 7cbdd8386..966a8ccc6 100644 --- a/discord/opus.py +++ b/discord/opus.py @@ -454,7 +454,9 @@ class Decoder(_OpusStruct): channel_count = self.CHANNELS else: 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) frame_size = frames * samples_per_frame diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index 17c528c52..7d366949c 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -94,7 +94,7 @@ class PartialEmoji(_EmojiTag, AssetMixin): __slots__ = ('animated', 'name', 'id', '_state') - _CUSTOM_EMOJI_RE = re.compile(r'a)?:?(?P[A-Za-z0-9\_]+):(?P[0-9]{13,20})>?') + _CUSTOM_EMOJI_RE = re.compile(r'a)?:)?(?P[A-Za-z0-9\_]+):(?P[0-9]{13,20})>?') if TYPE_CHECKING: id: Optional[int] diff --git a/discord/permissions.py b/discord/permissions.py index 9716f07c6..74cf22ef4 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -119,6 +119,12 @@ class Permissions(BaseFlags): to be, for example, constructed as a dict or a list of pairs. Note that aliases are not shown. + .. describe:: bool(b) + + Returns whether the permissions object has any permissions set to ``True``. + + .. versionadded:: 2.0 + Attributes ----------- value: :class:`int` @@ -177,7 +183,7 @@ class Permissions(BaseFlags): """A factory method that creates a :class:`Permissions` with all permissions set to ``True``. """ - return cls(0b1111111111111111111111111111111111111111111111) + return cls(0b11111111111111111111111111111111111111111111111) @classmethod def _timeout_mask(cls) -> int: @@ -204,7 +210,7 @@ class Permissions(BaseFlags): ``True`` and the guild-specific ones set to ``False``. The guild-specific permissions are currently: - - :attr:`manage_guild_expressions` + - :attr:`manage_expressions` - :attr:`view_audit_log` - :attr:`view_guild_insights` - :attr:`manage_guild` @@ -213,6 +219,7 @@ class Permissions(BaseFlags): - :attr:`kick_members` - :attr:`ban_members` - :attr:`administrator` + - :attr:`create_expressions` .. versionchanged:: 1.7 Added :attr:`stream`, :attr:`priority_speaker` and :attr:`use_application_commands` permissions. @@ -223,9 +230,9 @@ class Permissions(BaseFlags): :attr:`request_to_speak` permissions. .. versionchanged:: 2.3 - Added :attr:`use_soundboard` + Added :attr:`use_soundboard`, :attr:`create_expressions` permissions. """ - return cls(0b1000111110110110011111101111111111101010001) + return cls(0b01000111110110110011111101111111111101010001) @classmethod def general(cls) -> Self: @@ -237,8 +244,11 @@ class Permissions(BaseFlags): permissions :attr:`administrator`, :attr:`create_instant_invite`, :attr:`kick_members`, :attr:`ban_members`, :attr:`change_nickname` and :attr:`manage_nicknames` are no longer part of the general permissions. + + .. versionchanged:: 2.3 + Added :attr:`create_expressions` permission. """ - return cls(0b01110000000010000000010010110000) + return cls(0b10000000000001110000000010000000010010110000) @classmethod def membership(cls) -> Self: @@ -261,8 +271,11 @@ class Permissions(BaseFlags): .. versionchanged:: 2.0 Added :attr:`create_public_threads`, :attr:`create_private_threads`, :attr:`manage_threads`, :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 def voice(cls) -> Self: @@ -308,7 +321,7 @@ class Permissions(BaseFlags): - :attr:`manage_messages` - :attr:`manage_roles` - :attr:`manage_webhooks` - - :attr:`manage_guild_expressions` + - :attr:`manage_expressions` - :attr:`manage_threads` - :attr:`moderate_members` @@ -547,21 +560,21 @@ class Permissions(BaseFlags): return 1 << 29 @flag_value - def manage_guild_expressions(self) -> int: - """:class:`bool`: Returns ``True`` if a user can create, edit, or delete emojis, stickers, and soundboard sounds. + def manage_expressions(self) -> int: + """:class:`bool`: Returns ``True`` if a user can edit or delete emojis, stickers, and soundboard sounds. .. versionadded:: 2.3 """ return 1 << 30 - @make_permission_alias('manage_guild_expressions') + @make_permission_alias('manage_expressions') 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 - @make_permission_alias('manage_guild_expressions') + @make_permission_alias('manage_expressions') 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 """ @@ -663,6 +676,14 @@ class Permissions(BaseFlags): """ 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 def use_external_sounds(self) -> int: """:class:`bool`: Returns ``True`` if a user can use sounds from other guilds. @@ -671,6 +692,14 @@ class Permissions(BaseFlags): """ 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): cls.VALID_NAMES = set(Permissions.VALID_FLAGS) @@ -772,7 +801,7 @@ class PermissionOverwrite: manage_roles: Optional[bool] manage_permissions: Optional[bool] manage_webhooks: Optional[bool] - manage_guild_expressions: Optional[bool] + manage_expressions: Optional[bool] manage_emojis: Optional[bool] manage_emojis_and_stickers: Optional[bool] use_application_commands: Optional[bool] @@ -788,6 +817,8 @@ class PermissionOverwrite: moderate_members: Optional[bool] use_soundboard: Optional[bool] use_external_sounds: Optional[bool] + send_voice_messages: Optional[bool] + create_expressions: Optional[bool] def __init__(self, **kwargs: Optional[bool]): self._values: Dict[str, Optional[bool]] = {} diff --git a/discord/player.py b/discord/player.py index 2030442af..38113ebd8 100644 --- a/discord/player.py +++ b/discord/player.py @@ -210,7 +210,8 @@ class FFmpegAudio(AudioSource): # arbitrarily large read size data = source.read(8192) if not data: - self._process.terminate() + if self._stdin is not None: + self._stdin.close() return try: if self._stdin is not None: diff --git a/discord/reaction.py b/discord/reaction.py index e1f88061f..6e22019b8 100644 --- a/discord/reaction.py +++ b/discord/reaction.py @@ -89,7 +89,6 @@ class Reaction: self.count: int = data.get('count', 1) self.me: bool = data['me'] - # TODO: typeguard def is_custom_emoji(self) -> bool: """:class:`bool`: If this is a custom emoji.""" return not isinstance(self.emoji, str) diff --git a/discord/state.py b/discord/state.py index 6da0379ca..0d88c6f49 100644 --- a/discord/state.py +++ b/discord/state.py @@ -349,18 +349,18 @@ class ConnectionState(Generic[ClientT]): for vc in self.voice_clients: 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`. user_id = int(data['id']) try: return self._users[user_id] except KeyError: user = User(state=self, data=data) - if user.discriminator != '0000': + if cache: self._users[user_id] = 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) def create_user(self, data: Union[UserPayload, PartialUserPayload]) -> User: @@ -614,7 +614,7 @@ class ConnectionState(Generic[ClientT]): if self._messages is not None: self._messages.append(message) # 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 def parse_message_delete(self, data: gw.MessageDeleteEvent) -> None: diff --git a/discord/team.py b/discord/team.py index eef2c2d81..6d441078e 100644 --- a/discord/team.py +++ b/discord/team.py @@ -108,7 +108,7 @@ class TeamMember(BaseUser): .. 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 @@ -119,7 +119,11 @@ class TeamMember(BaseUser): id: :class:`int` The team member's unique ID. 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` Specifies if the user is a bot account. team: :class:`Team` @@ -139,5 +143,5 @@ class TeamMember(BaseUser): def __repr__(self) -> str: return ( 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}>' ) diff --git a/discord/types/channel.py b/discord/types/channel.py index 421232b45..3068185f1 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -150,16 +150,24 @@ class ForumChannel(_BaseTextChannel): GuildChannel = Union[TextChannel, NewsChannel, VoiceChannel, CategoryChannel, StageChannel, ThreadChannel, ForumChannel] -class DMChannel(_BaseChannel): +class _BaseDMChannel(_BaseChannel): type: Literal[1] last_message_id: Optional[Snowflake] + + +class DMChannel(_BaseDMChannel): recipients: List[PartialUser] +class InteractionDMChannel(_BaseDMChannel): + recipients: NotRequired[List[PartialUser]] + + class GroupDMChannel(_BaseChannel): type: Literal[3] icon: Optional[str] owner_id: Snowflake + recipients: List[PartialUser] Channel = Union[GuildChannel, DMChannel, GroupDMChannel] diff --git a/discord/types/guild.py b/discord/types/guild.py index 1ff2854aa..44d51019a 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -84,6 +84,7 @@ GuildFeature = Literal[ 'VERIFIED', 'VIP_REGIONS', 'WELCOME_SCREEN_ENABLED', + 'RAID_ALERTS_DISABLED', ] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index cfbaf310e..039203dfa 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -27,7 +27,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union from typing_extensions import NotRequired -from .channel import ChannelTypeWithoutThread, ThreadMetadata +from .channel import ChannelTypeWithoutThread, ThreadMetadata, GuildChannel, InteractionDMChannel, GroupDMChannel from .threads import ThreadType from .member import Member from .message import Attachment @@ -204,6 +204,7 @@ class _BaseInteraction(TypedDict): version: Literal[1] guild_id: NotRequired[Snowflake] channel_id: NotRequired[Snowflake] + channel: Union[GuildChannel, InteractionDMChannel, GroupDMChannel] app_permissions: NotRequired[str] locale: NotRequired[str] guild_locale: NotRequired[str] diff --git a/discord/types/message.py b/discord/types/message.py index 1319b2e65..1157100fc 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -68,6 +68,8 @@ class Attachment(TypedDict): content_type: NotRequired[str] spoiler: NotRequired[bool] ephemeral: NotRequired[bool] + duration_secs: NotRequired[float] + waveform: NotRequired[str] MessageActivityType = Literal[1, 2, 3, 5] diff --git a/discord/types/user.py b/discord/types/user.py index fba5aef5b..0a17a5459 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -31,6 +31,7 @@ class PartialUser(TypedDict): username: str discriminator: str avatar: Optional[str] + global_name: Optional[str] PremiumType = Literal[0, 1, 2] @@ -40,7 +41,7 @@ class User(PartialUser, total=False): bot: bool system: bool mfa_enabled: bool - local: str + locale: str verified: bool email: Optional[str] flags: int diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 615930d5c..b26fa9335 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -64,6 +64,7 @@ class Modal(View): .. code-block:: python3 + import discord from discord import ui class Questionnaire(ui.Modal, title='Questionnaire Response'): diff --git a/discord/ui/select.py b/discord/ui/select.py index bcd7b466d..222596075 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -162,10 +162,10 @@ class BaseSelect(Item[V]): @custom_id.setter def custom_id(self, value: str) -> None: 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._provided_custom_id = value is not None + self._provided_custom_id = True @property def placeholder(self) -> Optional[str]: diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index 79ac652b9..23c1d874f 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -137,9 +137,10 @@ class TextInput(Item[V]): @custom_id.setter def custom_id(self, value: str) -> None: 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._provided_custom_id = True @property def width(self) -> int: diff --git a/discord/user.py b/discord/user.py index 23868712c..cc836374a 100644 --- a/discord/user.py +++ b/discord/user.py @@ -65,6 +65,7 @@ class BaseUser(_UserTag): 'name', 'id', 'discriminator', + 'global_name', '_avatar', '_banner', '_accent_colour', @@ -78,6 +79,7 @@ class BaseUser(_UserTag): name: str id: int discriminator: str + global_name: Optional[str] bot: bool system: bool _state: ConnectionState @@ -92,11 +94,13 @@ class BaseUser(_UserTag): def __repr__(self) -> str: return ( - f"" ) def __str__(self) -> str: + if self.discriminator == '0': + return self.name return f'{self.name}#{self.discriminator}' def __eq__(self, other: object) -> bool: @@ -112,6 +116,7 @@ class BaseUser(_UserTag): self.name = data['username'] self.id = int(data['id']) self.discriminator = data['discriminator'] + self.global_name = data.get('global_name') self._avatar = data['avatar'] self._banner = data.get('banner', None) self._accent_colour = data.get('accent_color', None) @@ -126,6 +131,7 @@ class BaseUser(_UserTag): self.name = user.name self.id = user.id self.discriminator = user.discriminator + self.global_name = user.global_name self._avatar = user._avatar self._banner = user._banner self._accent_colour = user._accent_colour @@ -141,6 +147,7 @@ class BaseUser(_UserTag): 'id': self.id, 'avatar': self._avatar, 'discriminator': self.discriminator, + 'global_name': self.global_name, 'bot': self.bot, } @@ -162,8 +169,13 @@ class BaseUser(_UserTag): @property def default_avatar(self) -> Asset: - """:class:`Asset`: Returns the default avatar for a given user. This is calculated by the user's discriminator.""" - return Asset._from_default_avatar(self._state, int(self.discriminator) % len(DefaultAvatar)) + """:class:`Asset`: Returns the default avatar for a given user.""" + 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 def display_avatar(self) -> Asset: @@ -260,10 +272,12 @@ class BaseUser(_UserTag): def display_name(self) -> str: """:class:`str`: Returns the user's display name. - For regular users this is just their username, but - if they have a guild specific nickname then that + For regular users this is just their global name or their username, + but if they have a guild specific nickname then that is returned instead. """ + if self.global_name: + return self.global_name return self.name def mentioned_in(self, message: Message) -> bool: @@ -305,7 +319,7 @@ class ClientUser(BaseUser): .. describe:: str(x) - Returns the user's name with discriminator. + Returns the user's handle (e.g. ``name`` or ``name#discriminator``). Attributes ----------- @@ -314,7 +328,11 @@ class ClientUser(BaseUser): id: :class:`int` The user's unique ID. 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` Specifies if the user is a bot account. system: :class:`bool` @@ -343,7 +361,7 @@ class ClientUser(BaseUser): def __repr__(self) -> str: return ( - f'' ) @@ -409,6 +427,18 @@ class ClientUser(BaseUser): data: UserPayload = await self._state.http.edit_profile(payload) 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): """Represents a Discord user. @@ -429,7 +459,7 @@ class User(BaseUser, discord.abc.Messageable): .. describe:: str(x) - Returns the user's name with discriminator. + Returns the user's handle (e.g. ``name`` or ``name#discriminator``). Attributes ----------- @@ -438,7 +468,11 @@ class User(BaseUser, discord.abc.Messageable): id: :class:`int` The user's unique ID. 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` Specifies if the user is a bot account. system: :class:`bool` @@ -448,7 +482,7 @@ class User(BaseUser, discord.abc.Messageable): __slots__ = ('__weakref__',) def __repr__(self) -> str: - return f'' + return f'' async def _get_channel(self) -> DMChannel: ch = await self.create_dm() diff --git a/discord/utils.py b/discord/utils.py index fd711387b..3ee867975 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -56,7 +56,7 @@ from typing import ( TYPE_CHECKING, ) import unicodedata -from base64 import b64encode +from base64 import b64encode, b64decode from bisect import bisect_left import datetime import functools @@ -100,6 +100,7 @@ __all__ = ( ) DISCORD_EPOCH = 1420070400000 +DEFAULT_FILE_SIZE_LIMIT_BYTES = 26214400 class _MissingSentinel: @@ -628,6 +629,10 @@ def _bytes_to_base64_data(data: bytes) -> str: 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: return parent == child or child.startswith(parent + '.') @@ -1239,11 +1244,12 @@ def is_docker() -> 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 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': # Docker does not consistently have a tty attached to it return is_a_tty or is_docker() diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index acf69c310..0a08851dc 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -715,9 +715,9 @@ class _WebhookState: return self._parent._get_guild(guild_id) 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: - return self._parent.store_user(data) + return self._parent.store_user(data, cache=cache) # state parameter is artificial return BaseUser(state=self, data=data) # type: ignore @@ -1275,7 +1275,7 @@ class Webhook(BaseWebhook): A partial :class:`Webhook`. A partial webhook is just a webhook object with an ID and a token. """ - m = re.search(r'discord(?:app)?\.com/api/webhooks/(?P[0-9]{17,20})/(?P[A-Za-z0-9\.\-\_]{60,68})', url) + m = re.search(r'discord(?:app)?\.com/api/webhooks/(?P[0-9]{17,20})/(?P[A-Za-z0-9\.\-\_]{60,})', url) if m is None: raise ValueError('Invalid webhook URL given.') @@ -1301,7 +1301,13 @@ class Webhook(BaseWebhook): 'name': name, 'channel_id': channel.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 @@ -1511,8 +1517,7 @@ class Webhook(BaseWebhook): proxy_auth=self.proxy_auth, reason=reason, ) - - if prefer_auth and self.auth_token: + elif prefer_auth and self.auth_token: data = await adapter.edit_webhook( self.id, self.auth_token, diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 71564a58d..7da6ada70 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -682,7 +682,7 @@ class SyncWebhook(BaseWebhook): A partial :class:`SyncWebhook`. 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[0-9]{17,20})/(?P[A-Za-z0-9\.\-\_]{60,68})', url) + m = re.search(r'discord(?:app)?\.com/api/webhooks/(?P[0-9]{17,20})/(?P[A-Za-z0-9\.\-\_]{60,})', url) if m is None: raise ValueError('Invalid webhook URL given.') @@ -835,8 +835,7 @@ class SyncWebhook(BaseWebhook): payload['channel_id'] = channel.id data = adapter.edit_webhook(self.id, self.auth_token, payload=payload, session=self.session, reason=reason) - - if prefer_auth and self.auth_token: + elif prefer_auth and self.auth_token: data = adapter.edit_webhook(self.id, self.auth_token, payload=payload, session=self.session, reason=reason) elif self.token: data = adapter.edit_webhook_with_token(self.id, self.token, payload=payload, session=self.session, reason=reason) diff --git a/discord/widget.py b/discord/widget.py index 2a7c17a21..822008665 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -121,7 +121,7 @@ class WidgetMember(BaseUser): .. describe:: str(x) - Returns the widget member's ``name#discriminator``. + Returns the widget member's handle (e.g. ``name`` or ``name#discriminator``). Attributes ----------- @@ -130,13 +130,17 @@ class WidgetMember(BaseUser): name: :class:`str` The member's username. 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` Whether the member is a bot. status: :class:`Status` The member's status. nick: Optional[:class:`str`] - The member's nickname. + The member's guild-specific nickname. Takes precedence over the global name. avatar: Optional[:class:`str`] The member's avatar hash. activity: Optional[Union[:class:`BaseActivity`, :class:`Spotify`]] @@ -191,9 +195,7 @@ class WidgetMember(BaseUser): self.connected_channel: Optional[WidgetChannel] = connected_channel def __repr__(self) -> str: - return ( - f"" - ) + return f"" @property def display_name(self) -> str: diff --git a/docs/api.rst b/docs/api.rst index e2ae7d8d5..06c81120a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -493,6 +493,7 @@ Debug :param payload: The message that is about to be passed on to the WebSocket library. It can be :class:`bytes` to denote a binary message or :class:`str` to denote a regular text message. + :type payload: Union[:class:`bytes`, :class:`str`] Gateway @@ -1363,7 +1364,7 @@ Threads .. versionadded:: 2.0 :param payload: The raw event payload data. - :type member: :class:`RawThreadMembersUpdate` + :type payload: :class:`RawThreadMembersUpdate` Voice ~~~~~~ @@ -2914,27 +2915,33 @@ of :class:`enum.Enum`. .. attribute:: blurple - Represents the default avatar with the color blurple. + Represents the default avatar with the colour blurple. See also :attr:`Colour.blurple` .. attribute:: grey - Represents the default avatar with the color grey. + Represents the default avatar with the colour grey. See also :attr:`Colour.greyple` .. attribute:: gray An alias for :attr:`grey`. .. attribute:: green - Represents the default avatar with the color green. + Represents the default avatar with the colour green. See also :attr:`Colour.green` .. attribute:: orange - Represents the default avatar with the color orange. + Represents the default avatar with the colour orange. See also :attr:`Colour.orange` .. attribute:: red - Represents the default avatar with the color red. + Represents the default avatar with the 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 diff --git a/docs/conf.py b/docs/conf.py index 8fee2cbfa..a5fcc1773 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,7 +51,7 @@ autodoc_typehints = 'none' # napoleon_attr_annotations = False 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), } diff --git a/docs/discord.rst b/docs/discord.rst index 63485138e..d58ca8fb0 100644 --- a/docs/discord.rst +++ b/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 `_. 2. Navigate to the `application 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 :alt: How the OAuth2 page should look like. diff --git a/docs/ext/commands/extensions.rst b/docs/ext/commands/extensions.rst index 9351c9702..d03607845 100644 --- a/docs/ext/commands/extensions.rst +++ b/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. +Exceptions raised in the ``teardown`` function are ignored, and the extension is still unloaded. + .. code-block:: python3 :caption: basic_ext.py diff --git a/docs/migrating.rst b/docs/migrating.rst index 8e31417fd..1a0b52158 100644 --- a/docs/migrating.rst +++ b/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): ... +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 ---------------------------- diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 760be0152..2de164435 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,108 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed 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 ` to get the failed argument's value (:issue:`9283`). +- |commands| Add :attr:`Context.filesize_limit ` property (:issue:`9416`). +- |commands| Add support for :attr:`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 `_. + +- 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: v2.2.2 diff --git a/tests/test_app_commands_group.py b/tests/test_app_commands_group.py index 756f7c48f..d5f07976f 100644 --- a/tests/test_app_commands_group.py +++ b/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.my_cog_command.parent is cog.sub_group 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