From 7724764ffebebf1584847b49ff6823f6c0402d01 Mon Sep 17 00:00:00 2001 From: blord0 <68508813+blord0@users.noreply.github.com> Date: Fri, 18 Jul 2025 01:41:30 +0100 Subject: [PATCH] Add ability to use primary guild (clan) data for users Co-authored-by: blord0 Co-authored-by: DA344 <108473820+DA-344@users.noreply.github.com> Co-authored-by: dolfies --- discord/__init__.py | 1 + discord/asset.py | 9 ++++ discord/member.py | 16 ++++++- discord/primary_guild.py | 90 ++++++++++++++++++++++++++++++++++++++++ discord/types/user.py | 7 ++++ discord/user.py | 21 +++++++++- docs/api.rst | 8 ++++ 7 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 discord/primary_guild.py diff --git a/discord/__init__.py b/discord/__init__.py index 48fe10925..3e6e1c0e6 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -73,6 +73,7 @@ from .poll import * from .soundboard import * from .subscription import * from .presences import * +from .primary_guild import * class VersionInfo(NamedTuple): diff --git a/discord/asset.py b/discord/asset.py index e3422f311..cbf7dd4b2 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -346,6 +346,15 @@ class Asset(AssetMixin): animated=animated, ) + @classmethod + def _from_primary_guild(cls, state: _State, guild_id: int, icon_hash: str) -> Self: + return cls( + state, + url=f'{cls.BASE}/guild-tag-badges/{guild_id}/{icon_hash}.png?size=64', + key=icon_hash, + animated=False, + ) + def __str__(self) -> str: return self._url diff --git a/discord/member.py b/discord/member.py index ed52600dd..9f6b9daf2 100644 --- a/discord/member.py +++ b/discord/member.py @@ -74,6 +74,7 @@ if TYPE_CHECKING: GuildVoiceState as GuildVoiceStatePayload, VoiceState as VoiceStatePayload, ) + from .primary_guild import PrimaryGuild VocalGuildChannel = Union[VoiceChannel, StageChannel] @@ -309,6 +310,7 @@ class Member(discord.abc.Messageable, _UserTag): accent_colour: Optional[Colour] avatar_decoration: Optional[Asset] avatar_decoration_sku_id: Optional[int] + primary_guild: PrimaryGuild def __init__(self, *, data: MemberWithUserPayload, guild: Guild, state: ConnectionState): self._state: ConnectionState = state @@ -452,9 +454,11 @@ class Member(discord.abc.Messageable, _UserTag): u.global_name, u._public_flags, u._avatar_decoration_data['sku_id'] if u._avatar_decoration_data is not None else None, + u._primary_guild, ) decoration_payload = user.get('avatar_decoration_data') + primary_guild_payload = user.get('primary_guild', None) # These keys seem to always be available modified = ( user['username'], @@ -463,16 +467,26 @@ class Member(discord.abc.Messageable, _UserTag): user.get('global_name'), user.get('public_flags', 0), decoration_payload['sku_id'] if decoration_payload is not None else None, + primary_guild_payload, ) if original != modified: to_return = User._copy(self._user) - u.name, u.discriminator, u._avatar, u.global_name, u._public_flags, u._avatar_decoration_data = ( + ( + u.name, + u.discriminator, + u._avatar, + u.global_name, + u._public_flags, + u._avatar_decoration_data, + u._primary_guild, + ) = ( user['username'], user['discriminator'], user['avatar'], user.get('global_name'), user.get('public_flags', 0), decoration_payload, + primary_guild_payload, ) # Signal to dispatch on_user_update return to_return, u diff --git a/discord/primary_guild.py b/discord/primary_guild.py new file mode 100644 index 000000000..b65275a1f --- /dev/null +++ b/discord/primary_guild.py @@ -0,0 +1,90 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING +from datetime import datetime + +from .asset import Asset +from .utils import snowflake_time, _get_as_snowflake + +if TYPE_CHECKING: + from .state import ConnectionState + from .types.user import PrimaryGuild as PrimaryGuildPayload + from typing_extensions import Self + + +class PrimaryGuild: + """Represents the primary guild identity of a :class:`User` + + .. versionadded:: 2.6 + + Attributes + ----------- + id: Optional[:class:`int`] + The ID of the user's primary guild, if any. + tag: Optional[:class:`str`] + The primary guild's tag. + identity_enabled: Optional[:class:`bool`] + Whether the user has their primary guild publicly displayed. If ``None``, the user has a public guild but has not reaffirmed the guild identity after a change + + .. warning:: + + Users can have their primary guild publicly displayed while still having an :attr:`id` of ``None``. Be careful when checking this attribute! + """ + + __slots__ = ('id', 'identity_enabled', 'tag', '_badge', '_state') + + def __init__(self, *, state: ConnectionState, data: PrimaryGuildPayload) -> None: + self._state = state + self._update(data) + + def _update(self, data: PrimaryGuildPayload): + self.id = _get_as_snowflake(data, 'identity_guild_id') + self.identity_enabled = data['identity_enabled'] + self.tag = data.get('tag', None) + self._badge = data.get('badge') + + @property + def badge(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Returns the primary guild's asset""" + if self._badge is not None and self.id is not None: + return Asset._from_primary_guild(self._state, self.id, self._badge) + return None + + @property + def created_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: Returns the primary guild's creation time in UTC.""" + if self.id is not None: + return snowflake_time(self.id) + return None + + @classmethod + def _default(cls, state: ConnectionState) -> Self: + payload: PrimaryGuildPayload = {"identity_enabled": False} # type: ignore + return cls(state=state, data=payload) + + def __repr__(self) -> str: + return f'' diff --git a/discord/types/user.py b/discord/types/user.py index 1f027ce9d..b2b213ecf 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -54,3 +54,10 @@ class User(PartialUser, total=False): flags: int premium_type: PremiumType public_flags: int + + +class PrimaryGuild(TypedDict): + identity_guild_id: Optional[int] + identity_enabled: Optional[bool] + tag: Optional[str] + badge: Optional[str] diff --git a/discord/user.py b/discord/user.py index c5391372a..636c909f3 100644 --- a/discord/user.py +++ b/discord/user.py @@ -32,6 +32,7 @@ from .colour import Colour from .enums import DefaultAvatar from .flags import PublicUserFlags from .utils import snowflake_time, _bytes_to_base64_data, MISSING, _get_as_snowflake +from .primary_guild import PrimaryGuild if TYPE_CHECKING: from typing_extensions import Self @@ -43,7 +44,12 @@ if TYPE_CHECKING: from .message import Message from .state import ConnectionState from .types.channel import DMChannel as DMChannelPayload - from .types.user import PartialUser as PartialUserPayload, User as UserPayload, AvatarDecorationData + from .types.user import ( + PartialUser as PartialUserPayload, + User as UserPayload, + AvatarDecorationData, + PrimaryGuild as PrimaryGuildPayload, + ) __all__ = ( @@ -71,6 +77,7 @@ class BaseUser(_UserTag): '_public_flags', '_state', '_avatar_decoration_data', + '_primary_guild', ) if TYPE_CHECKING: @@ -86,6 +93,7 @@ class BaseUser(_UserTag): _accent_colour: Optional[int] _public_flags: int _avatar_decoration_data: Optional[AvatarDecorationData] + _primary_guild: Optional[PrimaryGuildPayload] def __init__(self, *, state: ConnectionState, data: Union[UserPayload, PartialUserPayload]) -> None: self._state = state @@ -123,6 +131,7 @@ class BaseUser(_UserTag): self.bot = data.get('bot', False) self.system = data.get('system', False) self._avatar_decoration_data = data.get('avatar_decoration_data') + self._primary_guild = data.get('primary_guild', None) @classmethod def _copy(cls, user: Self) -> Self: @@ -139,6 +148,7 @@ class BaseUser(_UserTag): self._state = user._state self._public_flags = user._public_flags self._avatar_decoration_data = user._avatar_decoration_data + self._primary_guild = user._primary_guild return self @@ -305,6 +315,15 @@ class BaseUser(_UserTag): return self.global_name return self.name + @property + def primary_guild(self) -> PrimaryGuild: + """:class:`PrimaryGuild`: Returns the user's primary guild. + + .. versionadded:: 2.6""" + if self._primary_guild is not None: + return PrimaryGuild(state=self._state, data=self._primary_guild) + return PrimaryGuild._default(self._state) + def mentioned_in(self, message: Message) -> bool: """Checks if the user is mentioned in the specified message. diff --git a/docs/api.rst b/docs/api.rst index c7d9e351f..781a47b24 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5481,6 +5481,14 @@ ClientStatus .. autoclass:: ClientStatus() :members: +PrimaryGuild +~~~~~~~~~~~~ + +.. attributetable:: PrimaryGuild + +.. autoclass:: PrimaryGuild() + :members: + Data Classes --------------