Browse Source

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
pull/10109/head
dolfies 2 years ago
committed by GitHub
parent
commit
5b6e6d13b7
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      README.rst
  2. 1
      discord/__init__.py
  3. 27
      discord/abc.py
  4. 253
      discord/channel.py
  5. 9
      discord/client.py
  6. 9
      discord/enums.py
  7. 18
      discord/guild.py
  8. 38
      discord/http.py
  9. 60
      discord/message.py
  10. 102
      discord/raw_models.py
  11. 185
      discord/read_state.py
  12. 29
      discord/scheduled_event.py
  13. 4
      discord/settings.py
  14. 163
      discord/state.py
  15. 64
      discord/threads.py
  16. 1
      discord/types/application.py
  17. 35
      discord/types/gateway.py
  18. 54
      discord/types/read_state.py
  19. 120
      docs/api.rst

1
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

1
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 *

27
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

253
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`.

9
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()

9
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}'

18
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.

38
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

60
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

102
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)

185
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)

29
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

4
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`

163
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

64
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.

1
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):

35
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]

54
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]

120
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 <Client>` 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

Loading…
Cancel
Save