From 5b6e6d13b73f5ec230153f15c85ad88873458298 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 22 Apr 2023 21:53:03 -0400 Subject: [PATCH] Implement read states (#498) * Base read states * Base read states * Models and helpers * Increase mention counter * Rest of the ack events * Finishing touches and doccs * Fix RawUserFeatureAckEvent docstring * Add top-level mention count property * Add ack shortfall note * Expose and document ReadStateType * Add Client.read_states getter * Update scheduled event read state badge count * Update README --- README.rst | 1 + discord/__init__.py | 1 + discord/abc.py | 27 +++- discord/channel.py | 253 +++++++++++++++++++++++++++++++++++ discord/client.py | 9 ++ discord/enums.py | 9 ++ discord/guild.py | 18 +++ discord/http.py | 38 ++++-- discord/message.py | 60 ++++++++- discord/raw_models.py | 102 ++++++++++++-- discord/read_state.py | 185 +++++++++++++++++++++++++ discord/scheduled_event.py | 29 +++- discord/settings.py | 4 +- discord/state.py | 163 +++++++++++++++++++++- discord/threads.py | 64 +++++++++ discord/types/application.py | 1 + discord/types/gateway.py | 35 ++++- discord/types/read_state.py | 54 ++++++++ docs/api.rst | 120 ++++++++++++++++- 19 files changed, 1140 insertions(+), 33 deletions(-) create mode 100644 discord/read_state.py create mode 100644 discord/types/read_state.py diff --git a/README.rst b/README.rst index e931a5f59..e2871916f 100644 --- a/README.rst +++ b/README.rst @@ -40,6 +40,7 @@ Key Features - Implements vast amounts of the user account-specific API. For a non-exhaustive list: * Sessions + * Read states * Connections * Relationships * Protobuf user settings diff --git a/discord/__init__.py b/discord/__init__.py index 426e7c338..fe9abca66 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -62,6 +62,7 @@ from .player import * from .profile import * from .promotions import * from .raw_models import * +from .read_state import * from .reaction import * from .relationship import * from .role import * diff --git a/discord/abc.py b/discord/abc.py index e697f391b..333594eba 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1438,7 +1438,7 @@ class GuildChannel: await self._state.http.bulk_channel_update(self.guild.id, payload, reason=reason) - async def create_invite( # TODO: add validate + async def create_invite( self, *, reason: Optional[str] = None, @@ -1936,6 +1936,8 @@ class Messageable: Marks every message in this channel as read. + .. versionadded:: 1.9 + Raises ------- ~discord.HTTPException @@ -1944,11 +1946,34 @@ class Messageable: channel = await self._get_channel() await self._state.http.ack_message(channel.id, channel.last_message_id or utils.time_snowflake(utils.utcnow())) + async def unack(self, *, mention_count: Optional[int] = None) -> None: + """|coro| + + Marks every message in this channel as unread. + This manually sets the read state to a message ID of 0. + + .. versionadded:: 2.1 + + Parameters + ----------- + mention_count: Optional[:class:`int`] + The mention count to set the channel read state to. + + Raises + ------- + ~discord.HTTPException + Unacking the channel failed. + """ + channel = await self._get_channel() + await self._state.http.ack_message(channel.id, 0, manual=True, mention_count=mention_count) + async def ack_pins(self) -> None: """|coro| Marks a channel's pins as viewed. + .. versionadded:: 1.9 + Raises ------- ~discord.HTTPException diff --git a/discord/channel.py b/discord/channel.py index 34033edd5..9bbb93700 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -92,6 +92,7 @@ if TYPE_CHECKING: from .user import BaseUser, ClientUser, User from .guild import Guild, GuildChannel as GuildChannelType from .settings import ChannelSettings + from .read_state import ReadState from .types.channel import ( TextChannel as TextChannelPayload, NewsChannel as NewsChannelPayload, @@ -273,6 +274,14 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): """:class:`bool`: Checks if the channel is a news channel.""" return self._type == ChannelType.news.value + @property + def read_state(self) -> ReadState: + """:class:`ReadState`: Returns the read state for this channel. + + .. versionadded:: 2.1 + """ + return self._state.get_read_state(self.id) + @property def last_message(self) -> Optional[Message]: """Retrieves the last message from this channel in cache. @@ -294,6 +303,61 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): """ return self._state._get_message(self.last_message_id) if self.last_message_id else None + @property + def acked_message_id(self) -> int: + """:class:`int`: The last message ID that the user has acknowledged. + It may *not* point to an existing or valid message. + + .. versionadded:: 2.1 + """ + return self.read_state.last_acked_id + + @property + def acked_message(self) -> Optional[Message]: + """Retrieves the last message that the user has acknowledged in cache. + + The message might not be valid or point to an existing message. + + .. versionadded:: 2.1 + + .. admonition:: Reliable Fetching + :class: helpful + + For a slightly more reliable method of fetching the + last acknowledged message, consider using either :meth:`history` + or :meth:`fetch_message` with the :attr:`acked_message_id` + attribute. + + Returns + --------- + Optional[:class:`Message`] + The last acknowledged message in this channel or ``None`` if not found. + """ + acked_message_id = self.acked_message_id + if acked_message_id is None: + return + + # We need to check if the message is in the same channel + message = self._state._get_message(acked_message_id) + if message and message.channel.id == self.id: + return message + + @property + def acked_pin_timestamp(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: When the channel's pins were last acknowledged. + + .. versionadded:: 2.1 + """ + return self.read_state.last_pin_timestamp + + @property + def mention_count(self) -> int: + """:class:`int`: Returns how many unread mentions the user has in this channel. + + .. versionadded:: 2.1 + """ + return self.read_state.badge_count + @overload async def edit(self) -> Optional[TextChannel]: ... @@ -988,6 +1052,14 @@ class VocalGuildChannel(discord.abc.Messageable, discord.abc.Connectable, discor base.value &= ~denied.value return base + @property + def read_state(self) -> ReadState: + """:class:`ReadState`: Returns the read state for this channel. + + .. versionadded:: 2.1 + """ + return self._state.get_read_state(self.id) + @property def last_message(self) -> Optional[Message]: """Retrieves the last message from this channel in cache. @@ -1011,6 +1083,61 @@ class VocalGuildChannel(discord.abc.Messageable, discord.abc.Connectable, discor """ return self._state._get_message(self.last_message_id) if self.last_message_id else None + @property + def acked_message_id(self) -> int: + """:class:`int`: The last message ID that the user has acknowledged. + It may *not* point to an existing or valid message. + + .. versionadded:: 2.1 + """ + return self.read_state.last_acked_id + + @property + def acked_message(self) -> Optional[Message]: + """Retrieves the last message that the user has acknowledged in cache. + + The message might not be valid or point to an existing message. + + .. versionadded:: 2.1 + + .. admonition:: Reliable Fetching + :class: helpful + + For a slightly more reliable method of fetching the + last acknowledged message, consider using either :meth:`history` + or :meth:`fetch_message` with the :attr:`acked_message_id` + attribute. + + Returns + --------- + Optional[:class:`Message`] + The last acknowledged message in this channel or ``None`` if not found. + """ + acked_message_id = self.acked_message_id + if acked_message_id is None: + return + + # We need to check if the message is in the same channel + message = self._state._get_message(acked_message_id) + if message and message.channel.id == self.id: + return message + + @property + def acked_pin_timestamp(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: When the channel's pins were last acknowledged. + + .. versionadded:: 2.1 + """ + return self.read_state.last_pin_timestamp + + @property + def mention_count(self) -> int: + """:class:`int`: Returns how many unread mentions the user has in this channel. + + .. versionadded:: 2.1 + """ + return self.read_state.badge_count + def get_partial_message(self, message_id: int, /) -> PartialMessage: """Creates a :class:`PartialMessage` from the message ID. @@ -2911,6 +3038,14 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc.Pr """ return f'https://discord.com/channels/@me/{self.id}' + @property + def read_state(self) -> ReadState: + """:class:`ReadState`: Returns the read state for this channel. + + .. versionadded:: 2.1 + """ + return self._state.get_read_state(self.id) + @property def last_message(self) -> Optional[Message]: """Retrieves the last message from this channel in cache. @@ -2932,6 +3067,61 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc.Pr """ return self._state._get_message(self.last_message_id) if self.last_message_id else None + @property + def acked_message_id(self) -> int: + """:class:`int`: The last message ID that the user has acknowledged. + It may *not* point to an existing or valid message. + + .. versionadded:: 2.1 + """ + return self.read_state.last_acked_id + + @property + def acked_message(self) -> Optional[Message]: + """Retrieves the last message that the user has acknowledged in cache. + + The message might not be valid or point to an existing message. + + .. versionadded:: 2.1 + + .. admonition:: Reliable Fetching + :class: helpful + + For a slightly more reliable method of fetching the + last acknowledged message, consider using either :meth:`history` + or :meth:`fetch_message` with the :attr:`acked_message_id` + attribute. + + Returns + --------- + Optional[:class:`Message`] + The last acknowledged message in this channel or ``None`` if not found. + """ + acked_message_id = self.acked_message_id + if acked_message_id is None: + return + + # We need to check if the message is in the same channel + message = self._state._get_message(acked_message_id) + if message and message.channel.id == self.id: + return message + + @property + def acked_pin_timestamp(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: When the channel's pins were last acknowledged. + + .. versionadded:: 2.1 + """ + return self.read_state.last_pin_timestamp + + @property + def mention_count(self) -> int: + """:class:`int`: Returns how many unread mentions the user has in this channel. + + .. versionadded:: 2.1 + """ + return self.read_state.badge_count + @property def requested_at(self) -> Optional[datetime.datetime]: """Optional[:class:`datetime.datetime`]: Returns the message request's creation time in UTC, if applicable. @@ -3310,6 +3500,14 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc """ return f'https://discord.com/channels/@me/{self.id}' + @property + def read_state(self) -> ReadState: + """:class:`ReadState`: Returns the read state for this channel. + + .. versionadded:: 2.1 + """ + return self._state.get_read_state(self.id) + @property def last_message(self) -> Optional[Message]: """Retrieves the last message from this channel in cache. @@ -3331,6 +3529,61 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc """ return self._state._get_message(self.last_message_id) if self.last_message_id else None + @property + def acked_message_id(self) -> int: + """:class:`int`: The last message ID that the user has acknowledged. + It may *not* point to an existing or valid message. + + .. versionadded:: 2.1 + """ + return self.read_state.last_acked_id + + @property + def acked_message(self) -> Optional[Message]: + """Retrieves the last message that the user has acknowledged in cache. + + The message might not be valid or point to an existing message. + + .. versionadded:: 2.1 + + .. admonition:: Reliable Fetching + :class: helpful + + For a slightly more reliable method of fetching the + last acknowledged message, consider using either :meth:`history` + or :meth:`fetch_message` with the :attr:`acked_message_id` + attribute. + + Returns + --------- + Optional[:class:`Message`] + The last acknowledged message in this channel or ``None`` if not found. + """ + acked_message_id = self.acked_message_id + if acked_message_id is None: + return + + # We need to check if the message is in the same channel + message = self._state._get_message(acked_message_id) + if message and message.channel.id == self.id: + return message + + @property + def acked_pin_timestamp(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: When the channel's pins were last acknowledged. + + .. versionadded:: 2.1 + """ + return self.read_state.last_pin_timestamp + + @property + def mention_count(self) -> int: + """:class:`int`: Returns how many unread mentions the user has in this channel. + + .. versionadded:: 2.1 + """ + return self.read_state.badge_count + def permissions_for(self, obj: Snowflake, /) -> Permissions: """Handles permission resolution for a :class:`User`. diff --git a/discord/client.py b/discord/client.py index 6f22f611c..437c06011 100644 --- a/discord/client.py +++ b/discord/client.py @@ -105,6 +105,7 @@ if TYPE_CHECKING: from .enums import PaymentGateway, RequiredActionType from .metadata import MetadataObject from .permissions import Permissions + from .read_state import ReadState from .types.snowflake import Snowflake as _Snowflake PrivateChannel = Union[DMChannel, GroupChannel] @@ -495,6 +496,14 @@ class Client: """ return utils.SequenceProxy(self._connection.pending_payments.values()) + @property + def read_states(self) -> List[ReadState]: + """List[:class:`.ReadState`]: The read states that the connected client has. + + .. versionadded:: 2.1 + """ + return [read_state for group in self._connection._read_states.values() for read_state in group.values()] + def is_ready(self) -> bool: """:class:`bool`: Specifies if the client's internal cache is ready for use.""" return self._ready is not MISSING and self._ready.is_set() diff --git a/discord/enums.py b/discord/enums.py index 7b0dc2239..c2cf7a5f5 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -113,6 +113,7 @@ __all__ = ( 'AutoModRuleActionType', 'ForumLayoutType', 'ForumOrderType', + 'ReadStateType', ) if TYPE_CHECKING: @@ -1496,6 +1497,14 @@ class ForumOrderType(Enum): creation_date = 1 +class ReadStateType(Enum): + channel = 0 + scheduled_events = 1 + notification_center = 2 + guild_home = 3 + onboarding = 4 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/guild.py b/discord/guild.py index 69da799de..7dd87cb11 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -74,6 +74,7 @@ from .enums import ( AutoModRuleEventType, ForumOrderType, ForumLayoutType, + ReadStateType, ) from .mixins import Hashable from .user import User @@ -134,6 +135,7 @@ if TYPE_CHECKING: from .types.widget import EditWidgetSettings from .types.oauth2 import OAuth2Guild as OAuth2GuildPayload from .message import EmojiInputType, Message + from .read_state import ReadState VocalGuildChannel = Union[VoiceChannel, StageChannel] GuildChannel = Union[VocalGuildChannel, ForumChannel, TextChannel, CategoryChannel] @@ -1142,6 +1144,22 @@ class Guild(Hashable): """ return utils.SequenceProxy(self._scheduled_events.values()) + @property + def scheduled_events_read_state(self) -> ReadState: + """:class:`ReadState`: Returns the read state representing the guild's scheduled events. + + .. versionadded:: 2.1 + """ + return self._state.get_read_state(self.id, ReadStateType.scheduled_events) + + @property + def acked_scheduled_event(self) -> Optional[ScheduledEvent]: + """Optional[:class:`ScheduledEvent`]: Returns the last scheduled event that the user has acknowledged. + + .. versionadded:: 2.1 + """ + return self._scheduled_events.get(self.scheduled_events_read_state.last_acked_id) + def get_scheduled_event(self, scheduled_event_id: int, /) -> Optional[ScheduledEvent]: """Returns a scheduled event with the given ID. diff --git a/discord/http.py b/discord/http.py index d79794876..4a7d8233a 100644 --- a/discord/http.py +++ b/discord/http.py @@ -100,6 +100,7 @@ if TYPE_CHECKING: payments, profile, promotions, + read_state, template, role, user, @@ -1104,20 +1105,33 @@ class HTTPClient: def send_typing(self, channel_id: Snowflake) -> Response[None]: return self.request(Route('POST', '/channels/{channel_id}/typing', channel_id=channel_id)) - async def ack_message(self, channel_id: Snowflake, message_id: Snowflake): # TODO: response type (simple) + async def ack_message( + self, channel_id: Snowflake, message_id: Snowflake, *, manual: bool = False, mention_count: Optional[int] = None + ) -> None: r = Route('POST', '/channels/{channel_id}/messages/{message_id}/ack', channel_id=channel_id, message_id=message_id) - payload = {'token': self.ack_token} + payload = {} + if manual: + payload['manual'] = True + else: + payload['token'] = self.ack_token + if mention_count is not None: + payload['mention_count'] = mention_count - data = await self.request(r, json=payload) - self.ack_token = data['token'] + data: read_state.AcknowledgementToken = await self.request(r, json=payload) + self.ack_token = data.get('token') if data else None - def unack_message(self, channel_id: Snowflake, message_id: Snowflake, *, mention_count: int = 0) -> Response[None]: - r = Route('POST', '/channels/{channel_id}/messages/{message_id}/ack', channel_id=channel_id, message_id=message_id) - payload = {'manual': True, 'mention_count': mention_count} + def ack_guild_feature( + self, guild_id: Snowflake, type: int, entity_id: Snowflake + ) -> Response[read_state.AcknowledgementToken]: + return self.request( + Route('POST', '/guilds/{guild_id}/ack/{type}/{entity_id}', guild_id=guild_id, type=type, entity_id=entity_id), + json={}, + ) - return self.request(r, json=payload) + def ack_user_feature(self, type: int, entity_id: Snowflake) -> Response[read_state.AcknowledgementToken]: + return self.request(Route('POST', '/users/@me/{type}/{entity_id}/ack', type=type, entity_id=entity_id), json={}) - def ack_messages(self, read_states) -> Response[None]: # TODO: type and implement + def ack_bulk(self, read_states: List[read_state.BulkReadState]) -> Response[None]: payload = {'read_states': read_states} return self.request(Route('POST', '/read-states/ack-bulk'), json=payload) @@ -1125,8 +1139,10 @@ class HTTPClient: def ack_guild(self, guild_id: Snowflake) -> Response[None]: return self.request(Route('POST', '/guilds/{guild_id}/ack', guild_id=guild_id)) - def unack_something(self, channel_id: Snowflake) -> Response[None]: # TODO: research - return self.request(Route('DELETE', '/channels/{channel_id}/messages/ack', channel_id=channel_id)) + def delete_read_state(self, channel_id: Snowflake, type: int) -> Response[None]: + payload = {'version': 2, 'read_state_type': type} # Read state protocol version 2 + + return self.request(Route('DELETE', '/channels/{channel_id}/messages/ack', channel_id=channel_id), json=payload) def delete_message( self, channel_id: Snowflake, message_id: Snowflake, *, reason: Optional[str] = None diff --git a/discord/message.py b/discord/message.py index ff2651582..820b3f924 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1116,17 +1116,49 @@ class PartialMessage(Hashable): ) return Thread(guild=self.guild, state=self._state, data=data) - async def ack(self) -> None: + async def ack(self, *, manual: bool = False, mention_count: Optional[int] = None) -> None: """|coro| Marks this message as read. + Parameters + ----------- + manual: :class:`bool` + Whether to manually set the channel read state to this message. + + .. versionadded:: 2.1 + mention_count: Optional[:class:`int`] + The mention count to set the channel read state to. Only applicable for + manual acknowledgements. + + .. versionadded:: 2.1 + Raises ------- HTTPException Acking failed. """ - await self._state.http.ack_message(self.channel.id, self.id) + await self._state.http.ack_message(self.channel.id, self.id, manual=manual, mention_count=mention_count) + + async def unack(self, *, mention_count: Optional[int] = None) -> None: + """|coro| + + Marks this message as unread. + This manually sets the read state to the current message's ID - 1. + + .. versionadded:: 2.1 + + Parameters + ----------- + mention_count: Optional[:class:`int`] + The mention count to set the channel read state to. + + Raises + ------- + HTTPException + Unacking failed. + """ + await self._state.http.ack_message(self.channel.id, self.id - 1, manual=True, mention_count=mention_count) @overload async def reply( @@ -1795,6 +1827,22 @@ class Message(PartialMessage, Hashable): self.guild = new_guild self.channel = new_channel # type: ignore # Not all "GuildChannel" are messageable at the moment + def _is_self_mentioned(self) -> bool: + state = self._state + guild = self.guild + channel = self.channel + settings = guild.notification_settings if guild else state.client.notification_settings + + if channel.type in (ChannelType.private, ChannelType.group) and not settings.muted and not channel.notification_settings.muted: # type: ignore + return True + if state.user in self.mentions: + return True + if self.mention_everyone and not settings.suppress_everyone: + return True + if guild and guild.me and not settings.suppress_roles and guild.me.mentioned_in(self): + return True + return False + @utils.cached_slot_property('_cs_raw_mentions') def raw_mentions(self) -> List[int]: """List[:class:`int`]: A property that returns an array of user IDs matched with @@ -1912,6 +1960,14 @@ class Message(PartialMessage, Hashable): MessageType.thread_starter_message, ) + def is_acked(self) -> bool: + """:class:`bool`: Whether the message has been marked as read. + + .. versionadded:: 2.1 + """ + read_state = self._state.get_read_state(self.channel.id) + return read_state.last_acked_id >= self.id if read_state.last_acked_id else False + @utils.cached_slot_property('_cs_system_content') def system_content(self) -> str: r""":class:`str`: A property that returns the content that is rendered diff --git a/discord/raw_models.py b/discord/raw_models.py index 2dbbb8b4e..79841309d 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -24,27 +24,31 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Set, List, Tuple, Union +from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union -from .enums import ChannelType, try_enum +from .enums import ChannelType, ReadStateType, try_enum if TYPE_CHECKING: + from .guild import Guild + from .member import Member + from .message import Message + from .partial_emoji import PartialEmoji + from .state import ConnectionState + from .threads import Thread from .types.gateway import ( - MessageDeleteEvent, + IntegrationDeleteEvent, + MessageAckEvent, MessageDeleteBulkEvent as BulkMessageDeleteEvent, + MessageDeleteEvent, MessageReactionAddEvent, - MessageReactionRemoveEvent, MessageReactionRemoveAllEvent as ReactionClearEvent, MessageReactionRemoveEmojiEvent as ReactionClearEmojiEvent, + MessageReactionRemoveEvent, MessageUpdateEvent, - IntegrationDeleteEvent, + NonChannelAckEvent, ThreadDeleteEvent, ThreadMembersUpdate, ) - from .message import Message - from .partial_emoji import PartialEmoji - from .member import Member - from .threads import Thread ReactionActionEvent = Union[MessageReactionAddEvent, MessageReactionRemoveEvent] @@ -59,6 +63,9 @@ __all__ = ( 'RawIntegrationDeleteEvent', 'RawThreadDeleteEvent', 'RawThreadMembersUpdate', + 'RawMessageAckEvent', + 'RawUserFeatureAckEvent', + 'RawGuildFeatureAckEvent', ) @@ -342,3 +349,80 @@ class RawThreadMembersUpdate(_RawReprMixin): self.guild_id: int = int(data['guild_id']) self.member_count: int = int(data['member_count']) self.data: ThreadMembersUpdate = data + + +class RawMessageAckEvent(_RawReprMixin): + """Represents the event payload for a :func:`on_raw_message_ack` event. + + .. versionadded:: 2.1 + + Attributes + ---------- + channel_id: :class:`int` + The channel ID of the read state. + message_id: :class:`int` + The message ID that was acknowledged. + cached_message: Optional[:class:`Message`] + The cached message, if found in the internal message cache. + manual: :class:`bool` + Whether the read state was manually set to this message. + mention_count: :class:`int` + The new mention count for the read state. + """ + + __slots__ = ('message_id', 'channel_id', 'cached_message', 'manual', 'mention_count') + + def __init__(self, data: MessageAckEvent) -> None: + self.message_id: int = int(data['message_id']) + self.channel_id: int = int(data['channel_id']) + self.cached_message: Optional[Message] = None + self.manual: bool = data.get('manual', False) + self.mention_count: int = data.get('mention_count', 0) + + +class RawUserFeatureAckEvent(_RawReprMixin): + """Represents the event payload for a :func:`on_user_feature_ack` event. + + .. versionadded:: 2.1 + + Attributes + ---------- + type: :class:`ReadStateType` + The type of the feature that was acknowledged. + entity_id: :class:`int` + The ID of the entity that was acknowledged. + """ + + __slots__ = ('type', 'entity_id') + + def __init__(self, data: NonChannelAckEvent) -> None: + self.type: ReadStateType = try_enum(ReadStateType, data['ack_type']) + self.entity_id: int = int(data['entity_id']) + + +class RawGuildFeatureAckEvent(RawUserFeatureAckEvent): + """Represents the event payload for a :func:`on_guild_feature_ack` event. + + .. versionadded:: 2.1 + + Attributes + ---------- + guild_id: :class:`int` + The guild ID of the feature that was acknowledged. + type: :class:`ReadStateType` + The type of the feature that was acknowledged. + entity_id: :class:`int` + The ID of the entity that was acknowledged. + """ + + __slots__ = ('guild_id', '_state') + + def __init__(self, data: NonChannelAckEvent, state: ConnectionState) -> None: + self._state: ConnectionState = state + self.guild_id: int = int(data['resource_id']) + super().__init__(data) + + @property + def guild(self) -> Guild: + """:class:`Guild`: The guild that the feature was acknowledged in.""" + return self._state._get_or_create_unavailable_guild(self.guild_id) diff --git a/discord/read_state.py b/discord/read_state.py new file mode 100644 index 000000000..68a39f5b7 --- /dev/null +++ b/discord/read_state.py @@ -0,0 +1,185 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 TYPE_CHECKING, Optional, Union + +from .enums import ReadStateType, try_enum +from .utils import parse_time + +if TYPE_CHECKING: + from datetime import datetime + + from typing_extensions import Self + + from .abc import MessageableChannel + from .guild import Guild + from .state import ConnectionState + from .user import ClientUser + from .types.read_state import ReadState as ReadStatePayload + +# fmt: off +__all__ = ( + 'ReadState', +) +# fmt: on + + +class ReadState: + """Represents the read state of a resource. + + This is a purposefuly very low-level object. + + .. container:: operations + + .. describe:: x == y + + Checks if two read states are equal. + + .. describe:: x != y + + Checks if two read states are not equal. + + .. describe:: hash(x) + + Returns the read state's hash. + + .. versionadded:: 2.1 + + Attributes + ----------- + id: :class:`int` + The ID of the resource. + type: :class:`ReadStateType` + The type of the read state. + last_acked_id: :class:`int` + The ID of the last acknowledged resource (e.g. message) in the read state. + It may *not* point to an existing or valid resource. + acked_pin_timestamp: Optional[:class:`datetime.datetime`] + When the channel's pins were last acknowledged. + badge_count: :class:`int` + The number of badges in the read state (e.g. mentions). + """ + + __slots__ = ( + 'id', + 'type', + 'last_acked_id', + 'acked_pin_timestamp', + 'badge_count', + 'last_viewed', + '_flags', + '_last_entity_id', + '_state', + ) + + def __init__(self, *, state: ConnectionState, data: ReadStatePayload): + self._state = state + + self.id: int = int(data['id']) + self.type: ReadStateType = try_enum(ReadStateType, data.get('read_state_type', 0)) + self._last_entity_id: Optional[int] = None + self._update(data) + + def _update(self, data: ReadStatePayload): + self.last_acked_id: int = int(data.get('last_acked_id', data.get('last_message_id', 0))) + self.acked_pin_timestamp: Optional[datetime] = parse_time(data.get('last_pin_timestamp')) + self.badge_count: int = int(data.get('badge_count', data.get('mention_count', 0))) + self.last_viewed: Optional[datetime] = parse_time(data.get('last_viewed')) + self._flags: int = data.get('flags') or 0 + + def __eq__(self, other: object) -> bool: + if isinstance(other, ReadState): + return other.id == self.id and other.type == self.type + return False + + def __ne__(self, other: object) -> bool: + if isinstance(other, ReadState): + return other.id != self.id or other.type != self.type + return True + + def __hash__(self) -> int: + return (self.id * self.type.value) >> 22 + + @classmethod + def default(cls, id: int, type: ReadStateType, *, state: ConnectionState) -> Self: + self = cls.__new__(cls) + self._state = state + self.id = id + self.type = type + self._last_entity_id = None + self.last_acked_id = 0 + self.acked_pin_timestamp = None + self.badge_count = 0 + return self + + @property + def resource(self) -> Optional[Union[ClientUser, Guild, MessageableChannel]]: + """Optional[Union[:class:`ClientUser`, :class:`Guild`, :class:`TextChannel`, :class:`StageChannel`, :class:`VoiceChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`, :class:`PartialMessageable`]]: The entity associated with the read state.""" + state = self._state + + if self.type == ReadStateType.channel: + return state._get_or_create_partial_messageable(self.id) # type: ignore + elif self.type in (ReadStateType.scheduled_events, ReadStateType.guild_home, ReadStateType.onboarding): + return state._get_or_create_unavailable_guild(self.id) + elif self.type == ReadStateType.notification_center and self.id == state.self_id: + return state.user + + @property + def last_entity_id(self) -> int: + """:class:`int`: The ID of the last resource (e.g. message) in the read state. + It may *not* point to an existing or valid resource. + """ + if self._last_entity_id is not None: + return self._last_entity_id + resource = self.resource + if not resource: + return 0 + + if self.type == ReadStateType.channel: + return resource.last_message_id or 0 # type: ignore + elif self.type == ReadStateType.scheduled_events: + return max(resource.scheduled_events, key=lambda e: e.id).id # type: ignore + return 0 + + @property + def last_pin_timestamp(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: When the last pinned message was pinned in the channel.""" + if self.resource and hasattr(self.resource, 'last_pin_timestamp'): + return self.resource.last_pin_timestamp # type: ignore + + async def delete(self): + """|coro| + + Deletes the read state. + + Raises + ------- + HTTPException + Deleting the read state failed. + """ + state = self._state + await state.http.delete_read_state(self.id, self.type.value) + state.remove_read_state(self) diff --git a/discord/scheduled_event.py b/discord/scheduled_event.py index a2a1a1d17..4ffe80b74 100644 --- a/discord/scheduled_event.py +++ b/discord/scheduled_event.py @@ -28,7 +28,7 @@ from datetime import datetime from typing import TYPE_CHECKING, AsyncIterator, Dict, Optional, Union, overload, Literal from .asset import Asset -from .enums import EventStatus, EntityType, PrivacyLevel, try_enum +from .enums import EventStatus, EntityType, PrivacyLevel, ReadStateType, try_enum from .mixins import Hashable from .object import Object, OLDEST_OBJECT from .utils import parse_time, _get_as_snowflake, _bytes_to_base64_data, MISSING @@ -184,6 +184,14 @@ class ScheduledEvent(Hashable): """:class:`str`: The url for the scheduled event.""" return f'https://discord.com/events/{self.guild_id}/{self.id}' + def is_acked(self) -> bool: + """:class:`bool`: Whether the scheduled event has been marked as read. + + .. versionadded:: 2.1 + """ + read_state = self._state.get_read_state(self.guild_id, ReadStateType.scheduled_events) + return read_state.last_acked_id >= self.id if read_state.last_acked_id else False + async def __modify_status(self, status: EventStatus, reason: Optional[str], /) -> ScheduledEvent: payload = {'status': status.value} data = await self._state.http.edit_scheduled_event(self.guild_id, self.id, **payload, reason=reason) @@ -700,6 +708,25 @@ class ScheduledEvent(Hashable): """ await self._state.http.delete_scheduled_event_user(self.guild_id, self.id) + async def ack(self) -> None: + """|coro| + + Marks this scheduled event as read. + + .. note:: + + This sets the last acknowledged scheduled event to this event, + which will mark acknowledged events created after this one as unread. + + .. versionadded:: 2.1 + + Raises + ------- + HTTPException + Acking failed. + """ + await self._state.http.ack_guild_feature(self.guild_id, ReadStateType.scheduled_events.value, self.id) + def _add_user(self, user: User) -> None: self._users[user.id] = user diff --git a/discord/settings.py b/discord/settings.py index b9437584e..5aa586d6a 100644 --- a/discord/settings.py +++ b/discord/settings.py @@ -1925,8 +1925,6 @@ class LegacyUserSettings: class MuteConfig: """An object representing an object's mute status. - .. versionadded:: 2.0 - .. container:: operations .. describe:: x == y @@ -1945,6 +1943,8 @@ class MuteConfig: Returns the mute status as an int. + .. versionadded:: 2.0 + Attributes ---------- muted: :class:`bool` diff --git a/discord/state.py b/discord/state.py index 2ef3b418d..a22b6c8f0 100644 --- a/discord/state.py +++ b/discord/state.py @@ -67,7 +67,9 @@ from .relationship import Relationship, FriendSuggestion from .role import Role from .enums import ( ChannelType, + MessageType, PaymentSourceType, + ReadStateType, RelationshipType, RequiredActionType, Status, @@ -94,6 +96,7 @@ from .guild_premium import PremiumGuildSubscriptionSlot from .library import LibraryApplication from .automod import AutoModRule, AutoModAction from .audit_logs import AuditLogEntry +from .read_state import ReadState if TYPE_CHECKING: from typing_extensions import Self @@ -612,6 +615,9 @@ class ConnectionState: self._stickers: Dict[int, GuildSticker] = {} self._guilds: Dict[int, Guild] = {} + self._read_states: Dict[int, Dict[int, ReadState]] = {} + self.read_state_version: int = 0 + self._calls: Dict[int, Call] = {} self._call_message_cache: Dict[int, Message] = {} # Hopefully this won't be a memory leak self._voice_clients: Dict[int, VoiceProtocol] = {} @@ -948,6 +954,8 @@ class ConnectionState: # Before parsing, we wait for READY_SUPPLEMENTAL # This has voice state objects, as well as an initial member cache self._ready_data = data + # Clear the ACK token + self.http.ack_token = None def parse_ready_supplemental(self, extra_data: gw.ReadySupplementalEvent) -> None: if self._ready_task is not None: @@ -972,10 +980,10 @@ class ConnectionState: extra_data['merged_presences'].get('guilds', []), ): for presence in merged_presences: - presence['user'] = {'id': presence['user_id']} # type: ignore # :( + presence['user'] = {'id': presence['user_id']} # type: ignore if 'properties' in guild_data: - guild_data.update(guild_data.pop('properties')) # type: ignore # :( + guild_data.update(guild_data.pop('properties')) # type: ignore voice_states = guild_data.setdefault('voice_states', []) voice_states.extend(guild_extra.get('voice_states', [])) @@ -1026,6 +1034,13 @@ class ConnectionState: pm['recipients'] = [temp_users[int(u_id)] for u_id in pm.pop('recipient_ids')] self._add_private_channel(factory(me=user, data=pm, state=self)) # type: ignore + # Read state parsing + read_states = data.get('read_state', {}) + for read_state in read_states['entries']: + item = ReadState(state=self, data=read_state) + self.store_read_state(item) + self.read_state_version = read_states.get('version', 0) + # Extras self.analytics_token = data.get('analytics_token') self.preferred_regions = data.get('geo_ordered_rtc_regions', ['us-central']) @@ -1094,10 +1109,21 @@ class ConnectionState: self._messages.append(message) if message.call is not None: self._call_message_cache[message.id] = message - if channel: channel.last_message_id = message.id # type: ignore + read_state = self.get_read_state(channel.id) + if message.author.id == self.self_id: + # Implicitly mark our own messages as read + read_state.last_acked_id = message.id + if ( + not message.author.is_blocked() + and not (channel.type == ChannelType.group and message.type == MessageType.recipient_remove) + and message._is_self_mentioned() + ): + # Increment mention count if applicable + read_state.badge_count += 1 + def parse_message_delete(self, data: gw.MessageDeleteEvent) -> None: raw = RawMessageDeleteEvent(data) found = self._get_message(raw.message_id) @@ -1136,6 +1162,28 @@ class ConnectionState: else: self.dispatch('raw_message_edit', raw) + def parse_message_ack(self, data: gw.MessageAckEvent) -> None: + self.read_state_version = data.get('version', self.read_state_version) + channel_id = int(data['channel_id']) + channel = self.get_channel(channel_id) + if channel is None: + _log.debug('MESSAGE_ACK referencing an unknown channel ID: %s. Discarding.', channel_id) + return + + raw = RawMessageAckEvent(data) + message_id = int(data['message_id']) + message = self._get_message(message_id) + raw.cached_message = message + + read_state = self.get_read_state(channel_id) + read_state.last_acked_id = message_id + if 'mention_count' in data: + read_state.badge_count = data['mention_count'] + + self.dispatch('raw_message_ack', raw) + if message is not None: + self.dispatch('message_ack', message, raw.manual) + def parse_message_reaction_add(self, data: gw.MessageReactionAddEvent) -> None: emoji = data['emoji'] emoji_id = utils._get_as_snowflake(emoji, 'id') @@ -1264,6 +1312,8 @@ class ConnectionState: self.dispatch('user_update', user_update[0], user_update[1]) def parse_user_update(self, data: gw.UserUpdateEvent) -> None: + # Clear the ACK toke + self.http.ack_token = None if self.user: self.user._full_update(data) @@ -1321,6 +1371,14 @@ class ConnectionState: self.required_action = required_action self.dispatch('required_action_update', required_action) + def parse_user_non_channel_ack(self, data: gw.NonChannelAckEvent) -> None: + self.read_state_version = data.get('version', self.read_state_version) + + raw = RawUserFeatureAckEvent(data) + read_state = self.get_read_state(self.self_id, raw.type) # type: ignore + read_state.last_acked_id = int(data['entity_id']) + self.dispatch('user_feature_ack', raw) + def parse_user_connections_update(self, data: Union[gw.ConnectionEvent, gw.PartialConnectionEvent]) -> None: self.dispatch('connections_update') @@ -1501,6 +1559,11 @@ class ConnectionState: self._remove_private_channel(channel) self.dispatch('private_channel_delete', channel) + # Nuke read state + read_state = self.get_read_state(channel_id) + if read_state is not None: + self.remove_read_state(read_state) + def parse_channel_update(self, data: gw.ChannelUpdateEvent) -> None: channel_type = try_enum(ChannelType, data.get('type')) channel_id = int(data['id']) @@ -1574,6 +1637,23 @@ class ConnectionState: else: self.dispatch('guild_channel_pins_update', channel, last_pin) + def parse_channel_pins_ack(self, data: gw.ChannelPinsAckEvent) -> None: + self.read_state_version = data.get('version', self.read_state_version) + channel_id = int(data['channel_id']) + channel = self.get_channel(channel_id) + if channel is None: + _log.debug('CHANNEL_PINS_ACK referencing an unknown channel ID: %s. Discarding.', channel_id) + return + + read_state = self.get_read_state(channel_id) + last_pin = utils.parse_time(data.get('last_pin')) + read_state.acked_pin_timestamp = last_pin + + if channel.guild is None: + self.dispatch('private_channel_pins_ack', channel, last_pin) + else: + self.dispatch('guild_channel_pins_ack', channel, last_pin) + def parse_channel_recipient_add(self, data) -> None: channel = self._get_private_channel(int(data['channel_id'])) user = self.store_user(data['user']) @@ -1643,6 +1723,11 @@ class ConnectionState: guild._remove_thread(thread) self.dispatch('thread_delete', thread) + # Nuke read state + read_state = self.get_read_state(raw.thread_id) + if read_state is not None: + self.remove_read_state(read_state) + def parse_thread_list_sync(self, data: gw.ThreadListSyncEvent) -> None: guild_id = int(data['guild_id']) guild: Optional[Guild] = self._get_guild(guild_id) @@ -2274,9 +2359,33 @@ class ConnectionState: (msg for msg in self._messages if msg.guild != guild), maxlen=self.max_messages ) + # Nuke all read states + for state_type in (ReadStateType.scheduled_events, ReadStateType.guild_home, ReadStateType.onboarding): + read_state = self.get_read_state(guild.id, state_type, if_exists=True) + if read_state is not None: + self.remove_read_state(read_state) + self._remove_guild(guild) self.dispatch('guild_remove', guild) + def parse_guild_feature_ack(self, data: gw.NonChannelAckEvent) -> None: + self.read_state_version = data.get('version', self.read_state_version) + guild = self._get_guild(int(data['resource_id'])) + if guild is None: + _log.debug('GUILD_FEATURE_ACK referencing an unknown guild ID: %s. Discarding.', data['resource_id']) + return + + raw = RawGuildFeatureAckEvent(data, self) + read_state = self.get_read_state(guild.id, raw.type) + read_state.last_acked_id = int(data['entity_id']) + self.dispatch('guild_feature_ack', raw) + + # Rich events here + if read_state.type == ReadStateType.scheduled_events: + event = guild.get_scheduled_event(read_state.last_acked_id) + if event is not None: + self.dispatch('scheduled_event_ack', event) + def parse_guild_ban_add(self, data: gw.GuildBanAddEvent) -> None: guild = self._get_guild(int(data['guild_id'])) if guild is not None: @@ -2444,6 +2553,14 @@ class ConnectionState: scheduled_event = ScheduledEvent(state=self, data=data) guild._scheduled_events[scheduled_event.id] = scheduled_event self.dispatch('scheduled_event_create', scheduled_event) + + read_state = self.get_read_state(guild.id, ReadStateType.scheduled_events) + if scheduled_event.creator_id == self.self_id: + # Implicitly ack created events + read_state.last_acked_id = scheduled_event.id + if not guild.notification_settings.mute_scheduled_events: + # Increment badge count if we're not muted + read_state.badge_count += 1 else: _log.debug('SCHEDULED_EVENT_CREATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) @@ -2722,6 +2839,12 @@ class ConnectionState: if channel is not None: return channel + def _get_or_create_partial_messageable(self, id: Optional[int]) -> Optional[Union[Channel, Thread]]: + if id is None: + return None + + return self.get_channel(id) or PartialMessageable(state=self, id=id) + def create_message( self, *, @@ -2821,6 +2944,40 @@ class ConnectionState: self._presences[user_id] = presence return presence + @overload + def get_read_state(self, id: int, type: ReadStateType = ..., *, if_exists: Literal[False] = ...) -> ReadState: + ... + + @overload + def get_read_state(self, id: int, type: ReadStateType = ..., *, if_exists: Literal[True]) -> Optional[ReadState]: + ... + + def get_read_state( + self, id: int, type: ReadStateType = ReadStateType.channel, *, if_exists: bool = False + ) -> Optional[ReadState]: + try: + return self._read_states[type.value][id] + except KeyError: + if not if_exists: + # Create and store a default read state + state = ReadState.default(id, type, state=self) + self.store_read_state(state) + return state + + def remove_read_state(self, read_state: ReadState) -> None: + try: + group = self._read_states[read_state.type.value] + except KeyError: + return + group.pop(read_state.id, None) + + def store_read_state(self, read_state: ReadState): + try: + group = self._read_states[read_state.type.value] + except KeyError: + group = self._read_states[read_state.type.value] = {} + group[read_state.id] = read_state + @utils.cached_property def premium_subscriptions_application(self) -> PartialApplication: # Hardcoded application for premium subscriptions, highly unlikely to change diff --git a/discord/threads.py b/discord/threads.py index 054552be6..cc17a86d9 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -60,6 +60,7 @@ if TYPE_CHECKING: from .abc import Snowflake, SnowflakeTime from .role import Role from .state import ConnectionState + from .read_state import ReadState class Thread(Messageable, Hashable): @@ -291,6 +292,14 @@ class Thread(Messageable, Hashable): return tags + @property + def read_state(self) -> ReadState: + """:class:`ReadState`: Returns the read state for this channel. + + .. versionadded:: 2.1 + """ + return self._state.get_read_state(self.id) + @property def starter_message(self) -> Optional[Message]: """Returns the thread starter message from the cache. @@ -327,6 +336,61 @@ class Thread(Messageable, Hashable): """ return self._state._get_message(self.last_message_id) if self.last_message_id else None + @property + def acked_message_id(self) -> int: + """:class:`int`: The last message ID that the user has acknowledged. + It may *not* point to an existing or valid message. + + .. versionadded:: 2.1 + """ + return self.read_state.last_acked_id + + @property + def acked_message(self) -> Optional[Message]: + """Retrieves the last message that the user has acknowledged in cache. + + The message might not be valid or point to an existing message. + + .. versionadded:: 2.1 + + .. admonition:: Reliable Fetching + :class: helpful + + For a slightly more reliable method of fetching the + last acknowledged message, consider using either :meth:`history` + or :meth:`fetch_message` with the :attr:`acked_message_id` + attribute. + + Returns + --------- + Optional[:class:`Message`] + The last acknowledged message in this channel or ``None`` if not found. + """ + acked_message_id = self.acked_message_id + if acked_message_id is None: + return + + # We need to check if the message is in the same channel + message = self._state._get_message(acked_message_id) + if message and message.channel.id == self.id: + return message + + @property + def acked_pin_timestamp(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: When the channel's pins were last acknowledged. + + .. versionadded:: 2.1 + """ + return self.read_state.last_pin_timestamp + + @property + def mention_count(self) -> int: + """:class:`int`: Returns how many unread mentions the user has in this channel. + + .. versionadded:: 2.1 + """ + return self.read_state.badge_count + @property def category(self) -> Optional[CategoryChannel]: """The category channel the parent channel belongs to, if applicable. diff --git a/discord/types/application.py b/discord/types/application.py index f169fed9f..1ab5e9b69 100644 --- a/discord/types/application.py +++ b/discord/types/application.py @@ -80,6 +80,7 @@ class PartialApplication(BaseApplication): embedded_activity_config: NotRequired[EmbeddedActivityConfig] guild: NotRequired[PartialGuild] install_params: NotRequired[ApplicationInstallParams] + deeplink_uri: NotRequired[str] class ApplicationDiscoverability(TypedDict): diff --git a/discord/types/gateway.py b/discord/types/gateway.py index d624b0255..18257d297 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -22,6 +22,8 @@ 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 List, Literal, Optional, TypedDict, Union from typing_extensions import NotRequired, Required @@ -49,6 +51,7 @@ from .payments import Payment from .entitlements import Entitlement, GatewayGift from .library import LibraryApplication from .audit_log import AuditLogEntry +from .read_state import ReadState, ReadStateType class UserPresenceUpdateEvent(TypedDict): @@ -103,6 +106,7 @@ class ReadyEvent(ResumedEvent): merged_members: List[List[MemberWithUser]] pending_payments: NotRequired[List[Payment]] private_channels: List[Union[DMChannel, GroupDMChannel]] + read_state: VersionedReadState relationships: List[Relationship] resume_gateway_url: str required_action: NotRequired[str] @@ -112,8 +116,7 @@ class ReadyEvent(ResumedEvent): shard: NotRequired[ShardInfo] user: User user_guild_settings: dict - user_settings: NotRequired[dict] - user_settings_proto: str + user_settings_proto: NotRequired[str] users: List[PartialUser] v: int @@ -129,6 +132,12 @@ class ReadySupplementalEvent(TypedDict): merged_presences: MergedPresences +class VersionedReadState(TypedDict): + entries: List[ReadState] + version: int + partial: bool + + NoEvent = Literal[None] MessageCreateEvent = Message @@ -224,6 +233,28 @@ class ChannelPinsUpdateEvent(TypedDict): last_pin_timestamp: NotRequired[Optional[str]] +class ChannelPinsAckEvent(TypedDict): + channel_id: Snowflake + timestamp: str + version: int + + +class MessageAckEvent(TypedDict): + channel_id: Snowflake + message_id: Snowflake + manual: NotRequired[bool] + mention_count: NotRequired[int] + ack_type: NotRequired[ReadStateType] + version: int + + +class NonChannelAckEvent(TypedDict): + entity_id: Snowflake + resource_id: Snowflake + ack_type: int + version: int + + class ThreadCreateEvent(Thread, total=False): newly_created: bool members: List[ThreadMember] diff --git a/discord/types/read_state.py b/discord/types/read_state.py new file mode 100644 index 000000000..28a83009a --- /dev/null +++ b/discord/types/read_state.py @@ -0,0 +1,54 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 Literal, Optional, TypedDict +from typing_extensions import NotRequired + +from .snowflake import Snowflake + +ReadStateType = Literal[0, 1, 2, 3, 4] + + +class ReadState(TypedDict): + id: Snowflake + read_state_type: NotRequired[ReadStateType] + last_message_id: NotRequired[Snowflake] + last_acked_id: NotRequired[Snowflake] + last_pin_timestamp: NotRequired[str] + mention_count: NotRequired[int] + badge_count: NotRequired[int] + flags: NotRequired[int] + # last_viewed: NotRequired[Optional[str]] + + +class BulkReadState(TypedDict): + channel_id: Snowflake + message_id: Snowflake + read_state_type: ReadStateType + + +class AcknowledgementToken(TypedDict): + token: Optional[str] diff --git a/docs/api.rst b/docs/api.rst index 034bf2e23..f47539830 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -453,6 +453,17 @@ Client :param action: The action required. If ``None``, then no further action is required. :type action: Optional[:class:`RequiredActionType`] +.. function:: on_user_feature_ack(payload) + + Called when a user-specific feature is acknowledged. + + This is a purposefully low-level event. Richer events are dispatched separately. + + .. versionadded:: 2.1 + + :param payload: The raw event payload data. + :type payload: :class:`RawUserFeatureAckEvent` + Billing ~~~~~~~ @@ -879,6 +890,17 @@ Guilds :param invite: The invite that was deleted. :type invite: :class:`Invite` +.. function:: on_guild_feature_ack(payload) + + Called when a :class:`Guild` feature is acknowledged. + + This is a purposefully low-level event. Richer events such as + :func:`on_scheduled_event_ack` are dispatched separately. + + .. versionadded:: 2.1 + + :param payload: The raw event payload data. + :type payload: :class:`RawGuildFeatureAckEvent` Integrations ~~~~~~~~~~~~~ @@ -1123,6 +1145,26 @@ Messages :param messages: The messages that have been deleted. :type messages: List[:class:`Message`] +.. function:: on_message_ack(message, manual) + + Called when a message is marked as read. If the message is not found in the + internal message cache, or the message ID is not real, then this event will not be called. + + If this occurs increase the :class:`max_messages ` parameter + or use the :func:`on_raw_message_ack` event instead. + + .. note:: + + Messages sent by the current user are automatically marked as read, + but this event will not dispatch. + + .. versionadded:: 2.1 + + :param message: The message that has been marked as read. + :type message: :class:`Message` + :param manual: Whether the channel read state was manually set to this message. + :type manual: :class:`bool` + .. function:: on_raw_message_edit(payload) Called when a message is edited. Unlike :func:`on_message_edit`, this is called @@ -1167,6 +1209,19 @@ Messages :param payload: The raw event payload data. :type payload: :class:`RawBulkMessageDeleteEvent` +.. function:: on_raw_message_ack(payload) + + Called when a message is marked as read. Unlike :func:`on_message_ack`, this is + called regardless of the message being in the internal message cache or not. + + If the message is found in the message cache, + it can be accessed via :attr:`RawMessageAckEvent.cached_message` + + .. versionadded:: 2.1 + + :param payload: The raw event payload data. + :type payload: :class:`RawMessageAckEvent` + .. function:: on_recent_mention_delete(message) Called when a message you were mentioned in in the last week is acknowledged and deleted. @@ -1362,6 +1417,20 @@ Scheduled Events :param user_id: The ID of the user that was added or removed. :type user_id: :class:`int` +.. function:: on_scheduled_event_ack(event) + + Called when a scheduled event is marked as read. + + .. note:: + + Scheduled events created by the current user are automatically marked as read, + but this event will not dispatch. + + .. versionadded:: 2.1 + + :param event: The scheduled event that was marked as read. + :type event: :class:`ScheduledEvent` + Stages ~~~~~~~ @@ -5531,7 +5600,6 @@ of :class:`enum.Enum`. The rule will timeout a user. - .. class:: ForumLayoutType Represents how a forum's posts are layed out in the client. @@ -5550,7 +5618,6 @@ of :class:`enum.Enum`. Displays posts as a collection of tiles. - .. class:: ForumOrderType Represents how a forum's posts are sorted in the client. @@ -5565,6 +5632,32 @@ of :class:`enum.Enum`. Sort forum posts by creation time (from most recent to oldest). +.. class:: ReadStateType + + Represents the type of a read state. + + .. versionadded:: 2.1 + + .. attribute:: channel + + Represents a regular, channel-bound read state for messages. + + .. attribute:: scheduled_events + + Represents a guild-bound read state for scheduled events. Only one exists per guild. + + .. attribute:: notification_center + + Represents a global read state for the notification center. Only one exists. + + .. attribute:: guild_home + + Represents a guild-bound read state for guild home. Only one exists per guild. + + .. attribute:: onboarding + + Represents a guild-bound read state for guild onboarding. Only one exists per guild. + .. _discord-api-audit-logs: @@ -6854,6 +6947,14 @@ Metadata :members: :inherited-members: +ReadState +~~~~~~~~~ + +.. attributetable:: ReadState + +.. autoclass:: ReadState() + :members: + Asset ~~~~~ @@ -7469,6 +7570,21 @@ RawEvent .. autoclass:: RawThreadDeleteEvent() :members: +.. attributetable:: RawMessageAckEvent + +.. autoclass:: RawMessageAckEvent() + :members: + +.. attributetable:: RawUserFeatureAckEvent + +.. autoclass:: RawUserFeatureAckEvent() + :members: + +.. attributetable:: RawGuildFeatureAckEvent + +.. autoclass:: RawGuildFeatureAckEvent() + :members: + .. _discord_api_data: Data Classes