diff --git a/changelog/1025.feature.rst b/changelog/1025.feature.rst new file mode 100644 index 0000000000..7a063ba158 --- /dev/null +++ b/changelog/1025.feature.rst @@ -0,0 +1,7 @@ +Add support for new username system - see the official `help article `__ for details. Existing functionality is kept backwards-compatible while the migration is still ongoing. +- Add :attr:`User.global_name`, and update attributes/methods to account for it: + - :attr:`User.display_name` and :attr:`Member.display_name` + - :meth:`Guild.get_member_named` + - |commands| :class:`~ext.commands.UserConverter` and :class:`~ext.commands.MemberConverter`, now largely matching the behavior of :meth:`Guild.get_member_named` +- Update ``str(user)`` and ``str(member)`` to not include ``#0`` discriminator of migrated users. +- Adjust :attr:`User.default_avatar` to account for new default avatar handling. diff --git a/disnake/abc.py b/disnake/abc.py index 3a2c0e32fd..39ee1c9b96 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -132,6 +132,21 @@ class User(Snowflake, Protocol): The user's username. discriminator: :class:`str` The user's discriminator. + + .. note:: + This is being phased out by Discord; the username system is moving away from ``username#discriminator`` + to users having a globally unique username. + The value of a single zero (``"0"``) indicates that the user has been migrated to the new system. + See the `help article `__ for details. + + global_name: Optional[:class:`str`] + The user's global display name, if set. + This takes precedence over :attr:`.name` when shown. + + For bots, this is the application name. + + .. versionadded:: 2.9 + avatar: :class:`~disnake.Asset` The avatar asset the user has. bot: :class:`bool` @@ -142,6 +157,7 @@ class User(Snowflake, Protocol): name: str discriminator: str + global_name: Optional[str] avatar: Asset bot: bool diff --git a/disnake/ext/commands/converter.py b/disnake/ext/commands/converter.py index a136db4762..8bca2bd6dd 100644 --- a/disnake/ext/commands/converter.py +++ b/disnake/ext/commands/converter.py @@ -186,11 +186,15 @@ class MemberConverter(IDConverter[disnake.Member]): The lookup strategy is as follows (in order): - 1. Lookup by ID. - 2. Lookup by mention. - 3. Lookup by name#discrim - 4. Lookup by name + 1. Lookup by ID + 2. Lookup by mention + 3. Lookup by username#discrim + 4. Lookup by username#0 5. Lookup by nickname + 6. Lookup by global name + 7. Lookup by username + + The name resolution order matches the one used by :meth:`.Guild.get_member_named`. .. versionchanged:: 1.5 Raise :exc:`.MemberNotFound` instead of generic :exc:`.BadArgument` @@ -198,19 +202,30 @@ class MemberConverter(IDConverter[disnake.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. + + .. versionchanged:: 2.9 + Name resolution order changed from ``username > nick`` to + ``nick > global_name > username`` to account for the username migration. """ async def query_member_named( self, guild: disnake.Guild, argument: str ) -> Optional[disnake.Member]: cache = guild._state.member_cache_flags.joined - if len(argument) > 5 and argument[-5] == "#": - username, _, discriminator = argument.rpartition("#") + + username, _, discriminator = argument.rpartition("#") + if username and ( + discriminator == "0" or (len(discriminator) == 4 and discriminator.isdecimal()) + ): + # legacy behavior members = await guild.query_members(username, limit=100, cache=cache) return _utils_get(members, name=username, discriminator=discriminator) else: members = await guild.query_members(argument, limit=100, cache=cache) - return disnake.utils.find(lambda m: m.name == argument or m.nick == argument, members) + return disnake.utils.find( + lambda m: m.nick == argument or m.global_name == argument or m.name == argument, + members, + ) async def query_member_by_id( self, bot: disnake.Client, guild: disnake.Guild, user_id: int @@ -284,10 +299,12 @@ class UserConverter(IDConverter[disnake.User]): The lookup strategy is as follows (in order): - 1. Lookup by ID. - 2. Lookup by mention. - 3. Lookup by name#discrim - 4. Lookup by name + 1. Lookup by ID + 2. Lookup by mention + 3. Lookup by username#discrim + 4. Lookup by username#0 + 5. Lookup by global name + 6. Lookup by username .. versionchanged:: 1.5 Raise :exc:`.UserNotFound` instead of generic :exc:`.BadArgument` @@ -295,6 +312,10 @@ class UserConverter(IDConverter[disnake.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. + + .. versionchanged:: 2.9 + Now takes :attr:`~disnake.User.global_name` into account. + No longer automatically removes ``"@"`` prefix from arguments. """ async def convert(self, ctx: AnyContext, argument: str) -> disnake.User: @@ -323,24 +344,21 @@ async def convert(self, ctx: AnyContext, argument: str) -> disnake.User: return result._user return result - arg = argument - - # Remove the '@' character if this is the first character from the argument - if arg[0] == "@": - # Remove first character - arg = arg[1:] - - # check for discriminator if it exists, - if len(arg) > 5 and arg[-5] == "#": - discrim = arg[-4:] - name = arg[:-5] - result = disnake.utils.find( - lambda u: u.name == name and u.discriminator == discrim, state._users.values() - ) + username, _, discriminator = argument.rpartition("#") + # n.b. there's no builtin method that only matches arabic digits, `isdecimal` is the closest one. + # it really doesn't matter much, worst case is unnecessary computations + if username and ( + discriminator == "0" or (len(discriminator) == 4 and discriminator.isdecimal()) + ): + # legacy behavior + result = _utils_get(state._users.values(), name=username, discriminator=discriminator) if result is not None: return result - result = disnake.utils.find(lambda u: u.name == arg, state._users.values()) + result = disnake.utils.find( + lambda u: u.global_name == argument or u.name == argument, + state._users.values(), + ) if result is None: raise UserNotFound(argument) @@ -1000,7 +1018,8 @@ class clean_content(Converter[str]): fix_channel_mentions: :class:`bool` Whether to clean channel mentions. use_nicknames: :class:`bool` - Whether to use nicknames when transforming mentions. + Whether to use :attr:`nicknames <.Member.nick>` and + :attr:`global names <.Member.global_name>` when transforming mentions. escape_markdown: :class:`bool` Whether to also escape special markdown characters. remove_markdown: :class:`bool` diff --git a/disnake/flags.py b/disnake/flags.py index 5879a01618..3e8ded9acd 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -1136,6 +1136,7 @@ def members(self): - :attr:`User.name` - :attr:`User.avatar` - :attr:`User.discriminator` + - :attr:`User.global_name` For more information go to the :ref:`member intent documentation `. diff --git a/disnake/guild.py b/disnake/guild.py index 29a4f30475..614489d1f6 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -1135,22 +1135,26 @@ def created_at(self) -> datetime.datetime: 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 lookup strategy is as follows (in 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. + 1. Lookup by nickname. + 2. Lookup by global name. + 3. Lookup by username. + + While the migration away from discriminators is still ongoing, + the name can have an optional discriminator argument, e.g. "Jake#0001", + in which case it will be treated as a username + discriminator combo + (note: this only works with usernames, not nicknames). If no member is found, ``None`` is returned. + .. versionchanged:: 2.9 + Now takes :attr:`User.global_name` into account. + Parameters ---------- name: :class:`str` - The name of the member to lookup with an optional discriminator. + The name of the member to lookup (with an optional discriminator). Returns ------- @@ -1158,24 +1162,19 @@ def get_member_named(self, name: str, /) -> Optional[Member]: The member in this guild with the associated name. If not found 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) + username, _, discriminator = name.rpartition("#") + if username and ( + discriminator == "0" or (len(discriminator) == 4 and discriminator.isdecimal()) + ): + # legacy behavior + result = utils.get(self._members.values(), name=username, discriminator=discriminator) if result is not None: return result def pred(m: Member) -> bool: - return m.nick == name or m.name == name + return m.nick == name or m.global_name == name or m.name == name - return utils.find(pred, members) + return utils.find(pred, self._members.values()) def _create_channel( self, @@ -4184,7 +4183,7 @@ async def query_members( ) -> List[Member]: """|coro| - Request members that belong to this guild whose username starts with + Request members that belong to this guild whose name starts with the query given. This is a websocket operation and can be slow. @@ -4196,7 +4195,7 @@ async def query_members( Parameters ---------- query: Optional[:class:`str`] - The string that the username's start with. + The string that the names start with. limit: :class:`int` The maximum number of members to send back. This must be a number between 5 and 100. @@ -4259,7 +4258,7 @@ async def search_members( ): """|coro| - Retrieves members that belong to this guild whose username or nickname starts with + Retrieves members that belong to this guild whose name starts with the query given. Note that unlike :func:`query_members`, this is not a websocket operation, but an HTTP operation. @@ -4271,7 +4270,7 @@ async def search_members( Parameters ---------- query: :class:`str` - The string that the usernames or nicknames start with. + The string that the names start with. limit: :class:`int` The maximum number of members to send back. This must be a number between 1 and 1000. diff --git a/disnake/member.py b/disnake/member.py index df240463c3..96db64f907 100644 --- a/disnake/member.py +++ b/disnake/member.py @@ -230,7 +230,7 @@ class Member(disnake.abc.Messageable, _UserTag): .. describe:: str(x) - Returns the member's name with the discriminator. + Returns the member's username (with discriminator, if not migrated to new system yet). Attributes ---------- @@ -249,6 +249,7 @@ class Member(disnake.abc.Messageable, _UserTag): The guild that the member belongs to. nick: Optional[:class:`str`] The guild specific nickname of the user. + This takes precedence over :attr:`.global_name` and :attr:`.name` when shown. pending: :class:`bool` Whether the member is pending member verification. @@ -278,6 +279,7 @@ class Member(disnake.abc.Messageable, _UserTag): if TYPE_CHECKING: name: str id: int + global_name: Optional[str] bot: bool system: bool created_at: datetime.datetime @@ -345,7 +347,7 @@ def __str__(self) -> str: def __repr__(self) -> str: return ( - f"" ) @@ -449,17 +451,18 @@ def _presence_update( 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._avatar, u.discriminator, u.global_name, u._public_flags) # These keys seem to always be available modified = ( user["username"], user["avatar"], user["discriminator"], + 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._avatar, u.discriminator, u.global_name, u._public_flags = modified # Signal to dispatch on_user_update return to_return, u @@ -483,10 +486,19 @@ def status(self, value: Status) -> None: @property def tag(self) -> str: + """:class:`str`: An alias of :attr:`.discriminator`.""" return self._user.discriminator @property def discriminator(self) -> str: + """:class:`str`:The user's discriminator. + + .. note:: + This is being phased out by Discord; the username system is moving away from ``username#discriminator`` + to users having a globally unique username. + The value of a single zero (``"0"``) indicates that the user has been migrated to the new system. + See the `help article `__ for details. + """ return self._user.discriminator @property @@ -566,11 +578,14 @@ def mention(self) -> str: 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 - is returned instead. + If they have a guild-specific :attr:`nickname <.nick>`, then + that is returned. If not, this is their :attr:`global name <.global_name>` + if set, or their :attr:`username <.name>` otherwise. + + .. versionchanged:: 2.9 + Added :attr:`.global_name`. """ - 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/disnake/team.py b/disnake/team.py index f9b78e0cae..6465128ffe 100644 --- a/disnake/team.py +++ b/disnake/team.py @@ -84,7 +84,7 @@ class TeamMember(BaseUser): .. describe:: str(x) - Returns the team member's name with discriminator. + Returns the team member's username (with discriminator, if not migrated to new system yet). .. versionadded:: 1.3 @@ -95,7 +95,20 @@ 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. + + .. note:: + This is being phased out by Discord; the username system is moving away from ``username#discriminator`` + to users having a globally unique username. + The value of a single zero (``"0"``) indicates that the user has been migrated to the new system. + See the `help article `__ for details. + + global_name: Optional[:class:`str`] + The team members's global display name, if set. + This takes precedence over :attr:`.name` when shown. + + .. versionadded:: 2.9 + avatar: Optional[:class:`str`] The avatar hash the team member has. Could be None. bot: :class:`bool` @@ -118,6 +131,6 @@ def __init__(self, team: Team, state: ConnectionState, data: TeamMemberPayload) 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"<{self.__class__.__name__} id={self.id} name={self.name!r} global_name={self.global_name!r}" + f" discriminator={self.discriminator!r} membership_state={self.membership_state!r}>" ) diff --git a/disnake/types/user.py b/disnake/types/user.py index 8c7abd2de4..ab913f9e82 100644 --- a/disnake/types/user.py +++ b/disnake/types/user.py @@ -2,13 +2,16 @@ from typing import Literal, Optional, TypedDict +from typing_extensions import NotRequired + from .snowflake import Snowflake class PartialUser(TypedDict): id: Snowflake username: str - discriminator: str + discriminator: str # may be removed in future API versions + global_name: NotRequired[Optional[str]] avatar: Optional[str] diff --git a/disnake/user.py b/disnake/user.py index fafad0fb33..8f9333b9cd 100644 --- a/disnake/user.py +++ b/disnake/user.py @@ -42,6 +42,7 @@ class BaseUser(_UserTag): "name", "id", "discriminator", + "global_name", "_avatar", "_banner", "_accent_colour", @@ -55,6 +56,7 @@ class BaseUser(_UserTag): name: str id: int discriminator: str + global_name: Optional[str] bot: bool system: bool _state: ConnectionState @@ -71,12 +73,16 @@ def __init__( def __repr__(self) -> str: return ( - f"" + f"" ) def __str__(self) -> str: - return f"{self.name}#{self.discriminator}" + discriminator = self.discriminator + if discriminator == "0": + return self.name + # legacy behavior + return f"{self.name}#{discriminator}" def __eq__(self, other: Any) -> bool: return isinstance(other, _UserTag) and other.id == self.id @@ -91,6 +97,7 @@ def _update(self, data: Union[UserPayload, PartialUserPayload]) -> None: 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) @@ -105,6 +112,7 @@ def _copy(cls, user: BaseUser) -> Self: 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 @@ -120,6 +128,7 @@ def _to_minimal_user_json(self) -> UserPayload: "id": self.id, "avatar": self._avatar, "discriminator": self.discriminator, + "global_name": self.global_name, "bot": self.bot, "public_flags": self._public_flags, } @@ -142,8 +151,17 @@ def avatar(self) -> Optional[Asset]: @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. + + .. versionchanged:: 2.9 + Added handling for users migrated to the new username system without discriminators. + """ + if self.discriminator == "0": + num = self.id >> 22 + else: + # legacy behavior + num = int(self.discriminator) + return Asset._from_default_avatar(self._state, num % len(DefaultAvatar)) @property def display_avatar(self) -> Asset: @@ -233,11 +251,13 @@ def created_at(self) -> datetime: 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 - is returned instead. + This is their :attr:`global name <.global_name>` if set, + or their :attr:`username <.name>` otherwise. + + .. versionchanged:: 2.9 + Added :attr:`.global_name`. """ - return self.name + return self.global_name or self.name def mentioned_in(self, message: Message) -> bool: """Checks if the user is mentioned in the specified message. @@ -277,7 +297,7 @@ class ClientUser(BaseUser): .. describe:: str(x) - Returns the user's name with discriminator. + Returns the user's username (with discriminator, if not migrated to new system yet). Attributes ---------- @@ -286,7 +306,22 @@ 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. + + .. note:: + This is being phased out by Discord; the username system is moving away from ``username#discriminator`` + to users having a globally unique username. + The value of a single zero (``"0"``) indicates that the user has been migrated to the new system. + See the `help article `__ for details. + + global_name: Optional[:class:`str`] + The user's global display name, if set. + This takes precedence over :attr:`.name` when shown. + + For bots, this is the application name. + + .. versionadded:: 2.9 + bot: :class:`bool` Specifies if the user is a bot account. system: :class:`bool` @@ -319,7 +354,7 @@ def __init__(self, *, state: ConnectionState, data: UserPayload) -> None: def __repr__(self) -> str: return ( - f"" ) @@ -408,7 +443,7 @@ class User(BaseUser, disnake.abc.Messageable): .. describe:: str(x) - Returns the user's name with discriminator. + Returns the user's username (with discriminator, if not migrated to new system yet). Attributes ---------- @@ -417,7 +452,22 @@ class User(BaseUser, disnake.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. + + .. note:: + This is being phased out by Discord; the username system is moving away from ``username#discriminator`` + to users having a globally unique username. + The value of a single zero (``"0"``) indicates that the user has been migrated to the new system. + See the `help article `__ for details. + + global_name: Optional[:class:`str`] + The user's global display name, if set. + This takes precedence over :attr:`.name` when shown. + + For bots, this is the application name. + + .. versionadded:: 2.9 + bot: :class:`bool` Specifies if the user is a bot account. system: :class:`bool` @@ -427,7 +477,10 @@ class User(BaseUser, disnake.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/disnake/webhook/async_.py b/disnake/webhook/async_.py index 1c64327415..edd9ec3dcd 100644 --- a/disnake/webhook/async_.py +++ b/disnake/webhook/async_.py @@ -1210,6 +1210,7 @@ def _as_follower(cls, data, *, channel, user) -> Webhook: "username": user.name, "discriminator": user.discriminator, "id": user.id, + "global_name": user.global_name, "avatar": user._avatar, }, } diff --git a/disnake/widget.py b/disnake/widget.py index 2ecfec0145..d5056d82dc 100644 --- a/disnake/widget.py +++ b/disnake/widget.py @@ -105,16 +105,21 @@ class WidgetMember(BaseUser): .. describe:: str(x) - Returns the widget member's `name#discriminator`. + Returns the widget member's name. Attributes ---------- id: :class:`int` The member's anonymized ID. name: :class:`str` - The member's nickname (if set in the guild) or username. + The member's name. discriminator: :class:`str` The member's anonymized discriminator. + + .. note:: + This is being phased out by Discord; the username system is moving away from ``username#discriminator`` + to users having a globally unique username. + See the `help article `__ for details. status: :class:`Status` The member's status. activity: Optional[Union[:class:`BaseActivity`, :class:`Spotify`]] @@ -286,9 +291,8 @@ class Widget: .. note:: Due to a Discord limitation, if this data is available - the users will be "anonymized" with linear IDs and discriminator - information being incorrect. Likewise, the number of members - retrieved is capped. + the users will be "anonymized" with linear IDs. + Likewise, the number of members retrieved is capped. presence_count: :class:`int` The number of online members in the server. diff --git a/docs/api/events.rst b/docs/api/events.rst index 4727a69ef7..791202d535 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -866,6 +866,7 @@ Members - avatar - discriminator - name + - global_name - public_flags This requires :attr:`Intents.members` to be enabled. diff --git a/docs/api/widgets.rst b/docs/api/widgets.rst index 936d7bc9d7..b0565bed04 100644 --- a/docs/api/widgets.rst +++ b/docs/api/widgets.rst @@ -44,7 +44,7 @@ WidgetMember .. autoclass:: WidgetMember() :members: :inherited-members: - :exclude-members: public_flags, default_avatar, banner, accent_colour, accent_color, colour, color, mention, created_at, mentioned_in + :exclude-members: global_name, public_flags, default_avatar, banner, accent_colour, accent_color, colour, color, mention, created_at, mentioned_in Enumerations ------------ diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index fbc71b4ad3..c54f3b21ae 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -371,8 +371,8 @@ For example, to receive a :class:`Member` you can just pass it as a converter: await ctx.send(f'{member} joined on {member.joined_at}') When this command is executed, it attempts to convert the string given into a :class:`Member` and then passes it as a -parameter for the function. This works by checking if the string is a mention, an ID, a nickname, a username + discriminator, -or just a regular username. The default set of converters have been written to be as easy to use as possible. +parameter for the function. This works by checking if the string is a mention, an ID, a username + discriminator, +a nickname, a global name, or just a regular username. The default set of converters have been written to be as easy to use as possible. A lot of Discord models work out of the gate as a parameter: