From e59e4d01d07c8b45b6bad8ac4c2f7df9f30c0491 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:05:54 +0100 Subject: [PATCH 1/8] feat: add on_raw_member_update event --- CHANGELOG.md | 2 ++ discord/member.py | 10 +++++---- discord/raw_models.py | 27 +++++++++++++++++++++++ discord/state.py | 48 +++++++++++++++++++++++++---------------- discord/types/member.py | 21 ++++++++++++++++-- 5 files changed, 84 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5696f04d6..5694d6243a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ 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. ([#3011](https://github.com/Pycord-Development/pycord/pull/3011)) ### Changed diff --git a/discord/member.py b/discord/member.py index 72cbb912d9..40318748cb 100644 --- a/discord/member.py +++ b/discord/member.py @@ -64,6 +64,7 @@ from .types.member import Member as MemberPayload from .types.member import MemberWithUser as MemberWithUserPayload from .types.member import UserWithMember as UserWithMemberPayload + from .types.member import MemberUpdateEvent as MemberUpdateEventPayload from .types.user import User as UserPayload from .types.voice import GuildVoiceState as GuildVoiceStatePayload from .types.voice import VoiceState as VoiceStatePayload @@ -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..e1aebba8e8 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -68,6 +68,7 @@ TypingEvent, VoiceChannelStatusUpdateEvent, ) + from .types.member import MemberUpdateEvent from .user import User @@ -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..65d9349208 100644 --- a/discord/state.py +++ b/discord/state.py @@ -90,6 +90,7 @@ from .types.sticker import GuildSticker as GuildStickerPayload from .types.user import User as UserPayload from .voice_client import VoiceClient + from .types.member import MemberUpdateEvent T = TypeVar("T") CS = TypeVar("CS", bound="ConnectionState") @@ -910,9 +911,11 @@ def parse_message_poll_vote_add(self, data) -> None: if answer.id in counts: counts[answer.id].count += 1 else: - counts[answer.id] = PollAnswerCount( - {"id": answer.id, "count": 1, "me_voted": False} - ) + counts[answer.id] = PollAnswerCount({ + "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) if answer is not None: @@ -1322,7 +1325,7 @@ 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"]) @@ -1333,29 +1336,38 @@ 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 not self.member_cache_flags.joined: + old_member = guild._members.pop(user_id, None) + else: + old_member = guild.get_member(user_id) + + old_member = Member._copy(old_member) if old_member is not None else None + + if old_member is not None: + new_member = old_member + new_member._update(data) + + # handle user_update if necessary + user_update = old_member._update_inner_user(user) if user_update: self.dispatch("user_update", user_update[0], user_update[1]) - self.dispatch("member_update", old_member, member) + self.dispatch("member_update", old_member, new_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 + if self.member_cache_flags.joined: # Force an update on the inner user if necessary - user_update = member._update_inner_user(user) + user_update = new_member._update_inner_user(user) if user_update: self.dispatch("user_update", user_update[0], user_update[1]) - guild._add_member(member) - _log.debug( - "GUILD_MEMBER_UPDATE referencing an unknown member ID: %s. Discarding.", - user_id, - ) + print("adding new member to cache:", new_member) + guild._add_member(new_member) + + raw = RawMemberUpdateEvent(data, new_member) + raw.cached_member = old_member + self.dispatch("raw_member_update", raw) 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] From 1808959c556d03224fe806679debf4e8869de92e Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:10:20 +0100 Subject: [PATCH 2/8] docs: document new payload and event --- discord/flags.py | 1 + docs/api/events.rst | 13 +++++++++++++ 2 files changed, 14 insertions(+) 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/docs/api/events.rst b/docs/api/events.rst index 45d8c1e279..7064d5637e 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.4 + + :param payload: The raw event payload data. + :type payload: :class:`RawMemberUpdateEvent` \ No newline at end of file From e13a20a83d14ea80dfd78a0b69d989bcdc117d3b Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:12:21 +0100 Subject: [PATCH 3/8] docs: correct pr number --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5694d6243a..7783da67cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ These changes are available on the `master` branch, but have not yet been releas `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. ([#3011](https://github.com/Pycord-Development/pycord/pull/3011)) + regardless of cache status. ([#3012](https://github.com/Pycord-Development/pycord/pull/3012)) ### Changed From b7ed1e1f2bb59726e19189e844468a5757423052 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 29 Nov 2025 18:13:13 +0000 Subject: [PATCH 4/8] style(pre-commit): auto fixes from pre-commit.com hooks --- CHANGELOG.md | 5 +++-- discord/member.py | 2 +- discord/raw_models.py | 2 +- discord/state.py | 14 ++++++++------ docs/api/events.rst | 2 +- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7783da67cd..8fe074b19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +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)) +- 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/member.py b/discord/member.py index 40318748cb..95b8d2d300 100644 --- a/discord/member.py +++ b/discord/member.py @@ -62,9 +62,9 @@ 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.member import MemberUpdateEvent as MemberUpdateEventPayload from .types.user import User as UserPayload from .types.voice import GuildVoiceState as GuildVoiceStatePayload from .types.voice import VoiceState as VoiceStatePayload diff --git a/discord/raw_models.py b/discord/raw_models.py index e1aebba8e8..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, ) @@ -68,7 +69,6 @@ TypingEvent, VoiceChannelStatusUpdateEvent, ) - from .types.member import MemberUpdateEvent from .user import User diff --git a/discord/state.py b/discord/state.py index 65d9349208..46e60a7fef 100644 --- a/discord/state.py +++ b/discord/state.py @@ -85,12 +85,12 @@ 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 from .types.user import User as UserPayload from .voice_client import VoiceClient - from .types.member import MemberUpdateEvent T = TypeVar("T") CS = TypeVar("CS", bound="ConnectionState") @@ -911,11 +911,13 @@ def parse_message_poll_vote_add(self, data) -> None: if answer.id in counts: counts[answer.id].count += 1 else: - counts[answer.id] = PollAnswerCount({ - "id": answer.id, - "count": 1, - "me_voted": False, - }) + counts[answer.id] = PollAnswerCount( + { + "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) if answer is not None: diff --git a/docs/api/events.rst b/docs/api/events.rst index 7064d5637e..8eb8a9851b 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1507,4 +1507,4 @@ Soundboard Sound .. versionadded:: 2.4 :param payload: The raw event payload data. - :type payload: :class:`RawMemberUpdateEvent` \ No newline at end of file + :type payload: :class:`RawMemberUpdateEvent` From 036d568ae53f6984781f0d2ef4c746764524e7a3 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:13:18 +0100 Subject: [PATCH 5/8] docs: correct versionadded string --- docs/api/events.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/events.rst b/docs/api/events.rst index 7064d5637e..e7bf197859 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1504,7 +1504,7 @@ Soundboard Sound This requires :attr:`Intents.members` to be enabled. - .. versionadded:: 2.4 + .. versionadded:: 2.7 :param payload: The raw event payload data. :type payload: :class:`RawMemberUpdateEvent` \ No newline at end of file From fe5fc97c3a46c44d2ce74267df7ea1346eb1fcc8 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 29 Nov 2025 22:52:00 +0100 Subject: [PATCH 6/8] docs: correct versionadded string Co-authored-by: Paillat Signed-off-by: Soheab <33902984+Soheab@users.noreply.github.com> --- docs/api/events.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/events.rst b/docs/api/events.rst index 8eb8a9851b..d7c31c8e52 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1504,7 +1504,7 @@ Soundboard Sound This requires :attr:`Intents.members` to be enabled. - .. versionadded:: 2.4 + .. versionadded:: 2.7 :param payload: The raw event payload data. :type payload: :class:`RawMemberUpdateEvent` From b288f617fd16bc80589f1ee7d44e8354ee7c762e Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 29 Nov 2025 22:52:17 +0100 Subject: [PATCH 7/8] chore: remove debug print Co-authored-by: DA344 <108473820+DA-344@users.noreply.github.com> Signed-off-by: Soheab <33902984+Soheab@users.noreply.github.com> --- discord/state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/state.py b/discord/state.py index 46e60a7fef..a513f67dc7 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1364,7 +1364,6 @@ def parse_guild_member_update(self, data: MemberUpdateEvent) -> None: if user_update: self.dispatch("user_update", user_update[0], user_update[1]) - print("adding new member to cache:", new_member) guild._add_member(new_member) raw = RawMemberUpdateEvent(data, new_member) From 0c9677ac0315405ed8258e665ed7e171a21c95c0 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:02:30 +0100 Subject: [PATCH 8/8] refactor: parsing logic --- discord/state.py | 48 +++++++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/discord/state.py b/discord/state.py index a513f67dc7..07009969c0 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1329,8 +1329,6 @@ def parse_guild_member_remove(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.", @@ -1338,38 +1336,42 @@ def parse_guild_member_update(self, data: MemberUpdateEvent) -> None: ) return - if not self.member_cache_flags.joined: - old_member = guild._members.pop(user_id, None) - else: - old_member = guild.get_member(user_id) + user = data["user"] + user_id = int(user["id"]) - old_member = Member._copy(old_member) if old_member is not None else None + # 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 + ) + # Always create or update the member object if old_member is not None: + old_member._update(data) new_member = old_member - new_member._update(data) + else: + new_member = Member(guild=guild, data=data, state=self) # type: ignore - # handle user_update if necessary - user_update = old_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) - self.dispatch("member_update", old_member, new_member) + # 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: - new_member = Member(guild=guild, data=data, state=self) # type: ignore + user_update = new_member._update_inner_user(user) - if self.member_cache_flags.joined: - # Force an update on the inner user if necessary - user_update = new_member._update_inner_user(user) - if user_update: - self.dispatch("user_update", user_update[0], user_update[1]) + 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) - raw = RawMemberUpdateEvent(data, new_member) - raw.cached_member = old_member - self.dispatch("raw_member_update", raw) - def parse_guild_emojis_update(self, data) -> None: guild = self._get_guild(int(data["guild_id"])) if guild is None: