diff --git a/CHANGELOG.md b/CHANGELOG.md index c5696f04d6..8fe074b19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ These changes are available on the `master` branch, but have not yet been releas - Added `Attachment.read_chunked` and added optional `chunksize` argument to `Attachment.save` for retrieving attachments in chunks. ([#2956](https://github.com/Pycord-Development/pycord/pull/2956)) +- Added a new event called `on_raw_member_update` that is dispatched when a member is + updated, regardless of cache status. + ([#3012](https://github.com/Pycord-Development/pycord/pull/3012)) ### Changed diff --git a/discord/flags.py b/discord/flags.py index cb2059e866..53c1ccd98b 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -715,6 +715,7 @@ def members(self): - :func:`on_raw_member_remove` - :func:`on_member_update` - :func:`on_user_update` + - :func:`on_raw_member_update` This also corresponds to the following attributes and classes in terms of cache: diff --git a/discord/member.py b/discord/member.py index 72cbb912d9..95b8d2d300 100644 --- a/discord/member.py +++ b/discord/member.py @@ -62,6 +62,7 @@ from .state import ConnectionState from .types.activity import PartialPresenceUpdate from .types.member import Member as MemberPayload + from .types.member import MemberUpdateEvent as MemberUpdateEventPayload from .types.member import MemberWithUser as MemberWithUserPayload from .types.member import UserWithMember as UserWithMemberPayload from .types.user import User as UserPayload @@ -424,27 +425,28 @@ async def _get_channel(self): ch = await self.create_dm() return ch - def _update(self, data: MemberPayload) -> None: + def _update(self, data: MemberPayload | MemberUpdateEventPayload) -> None: # the nickname change is optional, # if it isn't in the payload then it didn't change try: - self.nick = data["nick"] + self.nick = data["nick"] # type: ignore # handled by the type-except except KeyError: pass try: - self.pending = data["pending"] + self.pending = data["pending"] # type: ignore # handled by the type-except except KeyError: pass self.premium_since = utils.parse_time(data.get("premium_since")) - self._roles = utils.SnowflakeList(map(int, data["roles"])) + self._roles = utils.SnowflakeList(map(int, data["roles"])) # type: ignore # the API is the same self._avatar = data.get("avatar") self._banner = data.get("banner") self.communication_disabled_until = utils.parse_time( data.get("communication_disabled_until") ) self.flags = MemberFlags._from_value(data.get("flags", 0)) + self.joined_at = utils.parse_time(data.get("joined_at")) or self.joined_at def _presence_update( self, data: PartialPresenceUpdate, user: UserPayload diff --git a/discord/raw_models.py b/discord/raw_models.py index 86635b90e8..d84eb4f0b5 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -47,6 +47,7 @@ from .state import ConnectionState from .threads import Thread from .types.channel import VoiceChannelEffectSendEvent as VoiceChannelEffectSend + from .types.member import MemberUpdateEvent from .types.raw_models import ( AuditLogEntryEvent, ) @@ -90,6 +91,7 @@ "RawVoiceChannelStatusUpdateEvent", "RawMessagePollVoteEvent", "RawSoundboardSoundDeleteEvent", + "RawMemberUpdateEvent", ) @@ -870,3 +872,28 @@ def __init__(self, data: PartialSoundboardSound) -> None: self.sound_id: int = int(data["sound_id"]) self.guild_id: int = int(data["guild_id"]) self.data: PartialSoundboardSound = data + + +class RawMemberUpdateEvent(_RawReprMixin): + """Represents the payload for a :func:`on_raw_member_update` event. + + .. versionadded:: 2.7 + + Attributes + ---------- + data: :class:`dict` + The raw data sent by the `gateway `_ + cached_member: Optional[:class:`Member`] + The cached member, if found in the internal member cache. + member: :class:`Member` + The new member object after the update. + """ + + __slots__ = ("guild_id", "user_id", "data", "cached_member", "member") + + def __init__(self, data: MemberUpdateEvent, member: Member) -> None: + self.guild_id: int = int(data["guild_id"]) + self.user_id: int = int(data["user"]["id"]) + self.data: MemberUpdateEvent = data + self.cached_member: Member | None = None + self.member: Member = member diff --git a/discord/state.py b/discord/state.py index 8222f5fbe5..07009969c0 100644 --- a/discord/state.py +++ b/discord/state.py @@ -85,6 +85,7 @@ from .types.channel import DMChannel as DMChannelPayload from .types.emoji import Emoji as EmojiPayload from .types.guild import Guild as GuildPayload + from .types.member import MemberUpdateEvent from .types.message import Message as MessagePayload from .types.poll import Poll as PollPayload from .types.sticker import GuildSticker as GuildStickerPayload @@ -911,7 +912,11 @@ def parse_message_poll_vote_add(self, data) -> None: counts[answer.id].count += 1 else: counts[answer.id] = PollAnswerCount( - {"id": answer.id, "count": 1, "me_voted": False} + { + "id": answer.id, + "count": 1, + "me_voted": False, + } ) if poll is not None and user is not None: answer = poll.get_answer(raw.answer_id) @@ -1322,10 +1327,8 @@ def parse_guild_member_remove(self, data) -> None: ) self.dispatch("raw_member_remove", raw) - def parse_guild_member_update(self, data) -> None: + def parse_guild_member_update(self, data: MemberUpdateEvent) -> None: guild = self._get_guild(int(data["guild_id"])) - user = data["user"] - user_id = int(user["id"]) if guild is None: _log.debug( "GUILD_MEMBER_UPDATE referencing an unknown guild ID: %s. Discarding.", @@ -1333,29 +1336,41 @@ def parse_guild_member_update(self, data) -> None: ) return - member = guild.get_member(user_id) - if member is not None: - old_member = Member._copy(member) - member._update(data) - user_update = member._update_inner_user(user) - if user_update: - self.dispatch("user_update", user_update[0], user_update[1]) + user = data["user"] + user_id = int(user["id"]) + + # Try to get the old member from cache + old_member: Member | None = guild.get_member(user_id) + old_member_copy: Member | None = ( + Member._copy(old_member) if old_member is not None else None + ) - self.dispatch("member_update", old_member, member) + # Always create or update the member object + if old_member is not None: + old_member._update(data) + new_member = old_member else: - if self.member_cache_flags.joined: - member = Member(data=data, guild=guild, state=self) + new_member = Member(guild=guild, data=data, state=self) # type: ignore - # Force an update on the inner user if necessary - user_update = member._update_inner_user(user) - if user_update: - self.dispatch("user_update", user_update[0], user_update[1]) + raw = RawMemberUpdateEvent(data, new_member) + raw.cached_member = old_member_copy + self.dispatch("raw_member_update", raw) - guild._add_member(member) - _log.debug( - "GUILD_MEMBER_UPDATE referencing an unknown member ID: %s. Discarding.", - user_id, - ) + # Update the user cache if needed + user_update = None + if old_member_copy is not None: + user_update = old_member_copy._update_inner_user(user) + else: + user_update = new_member._update_inner_user(user) + + if user_update: + self.dispatch("user_update", user_update[0], user_update[1]) + + if old_member_copy is not None: + self.dispatch("member_update", old_member_copy, new_member) + else: + if self.member_cache_flags.joined: + guild._add_member(new_member) def parse_guild_emojis_update(self, data) -> None: guild = self._get_guild(int(data["guild_id"])) diff --git a/discord/types/member.py b/discord/types/member.py index 618bb13efe..dbf8cb8d9c 100644 --- a/discord/types/member.py +++ b/discord/types/member.py @@ -23,9 +23,9 @@ DEALINGS IN THE SOFTWARE. """ -from typing import TypedDict +from typing import NotRequired, TypedDict -from .snowflake import SnowflakeList +from .snowflake import Snowflake, SnowflakeList from .user import User @@ -66,3 +66,20 @@ class MemberWithUser(_OptionalMemberWithUser): class UserWithMember(User, total=False): member: _OptionalMemberWithUser + + +class MemberUpdateEvent(TypedDict): + guild_id: Snowflake + user: User + roles: list[Snowflake] + nick: NotRequired[str | None] + avatar: NotRequired[str | None] + banner: NotRequired[str | None] + joined_at: NotRequired[str | None] + premium_since: NotRequired[str | None] + deaf: NotRequired[bool | None] + mute: NotRequired[bool | None] + pending: NotRequired[bool | None] + communication_disabled_until: NotRequired[str | None] + flags: NotRequired[int | None] + avatar_decoration_data: NotRequired[dict | None] diff --git a/docs/api/events.rst b/docs/api/events.rst index 45d8c1e279..d7c31c8e52 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1495,3 +1495,16 @@ Soundboard Sound :param sound: The soundboard sound that was created. :type sound: :class:`SoundboardSound` + +.. function:: on_raw_member_update(payload) + + Called when a :class:`Member` updates their profile. + Unlike :func:`on_member_update`, this is called regardless of the + state of the internal member cache. + + This requires :attr:`Intents.members` to be enabled. + + .. versionadded:: 2.7 + + :param payload: The raw event payload data. + :type payload: :class:`RawMemberUpdateEvent`