diff --git a/discord/enums.py b/discord/enums.py index 1312f0320..5a361b225 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -220,6 +220,12 @@ class ChannelType(Enum): return self.name +class MessageReferenceType(Enum): + default = 0 + reply = 0 + forward = 1 + + class MessageType(Enum): default = 0 recipient_add = 1 diff --git a/discord/message.py b/discord/message.py index cefec9778..44025f460 100644 --- a/discord/message.py +++ b/discord/message.py @@ -32,6 +32,7 @@ from os import PathLike from typing import ( Dict, TYPE_CHECKING, + Literal, Sequence, Union, List, @@ -49,7 +50,7 @@ from .asset import Asset from .reaction import Reaction from .emoji import Emoji from .partial_emoji import PartialEmoji -from .enums import InteractionType, MessageType, ChannelType, try_enum +from .enums import InteractionType, MessageReferenceType, MessageType, ChannelType, try_enum from .errors import HTTPException from .components import _component_factory from .embeds import Embed @@ -72,6 +73,7 @@ if TYPE_CHECKING: Message as MessagePayload, Attachment as AttachmentPayload, MessageReference as MessageReferencePayload, + MessageSnapshot as MessageSnapshotPayload, MessageApplication as MessageApplicationPayload, MessageActivity as MessageActivityPayload, RoleSubscriptionData as RoleSubscriptionDataPayload, @@ -111,6 +113,7 @@ __all__ = ( 'PartialMessage', 'MessageInteraction', 'MessageReference', + 'MessageSnapshot', 'DeletedReferencedMessage', 'MessageApplication', 'RoleSubscriptionInfo', @@ -464,6 +467,133 @@ class DeletedReferencedMessage: return self._parent.guild_id +class MessageSnapshot: + """Represents a message snapshot attached to a forwarded message. + + .. versionadded:: 2.5 + + Attributes + ----------- + type: :class:`MessageType` + The type of the forwarded message. + content: :class:`str` + The actual contents of the forwarded message. + embeds: List[:class:`Embed`] + A list of embeds the forwarded message has. + attachments: List[:class:`Attachment`] + A list of attachments given to the forwarded message. + created_at: :class:`datetime.datetime` + The forwarded message's time of creation. + flags: :class:`MessageFlags` + Extra features of the the message snapshot. + stickers: List[:class:`StickerItem`] + A list of sticker items given to the message. + components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`]] + A list of components in the message. + """ + + __slots__ = ( + '_cs_raw_channel_mentions', + '_cs_cached_message', + '_cs_raw_mentions', + '_cs_raw_role_mentions', + '_edited_timestamp', + 'attachments', + 'content', + 'embeds', + 'flags', + 'created_at', + 'type', + 'stickers', + 'components', + '_state', + ) + + @classmethod + def _from_value( + cls, + state: ConnectionState, + message_snapshots: Optional[List[Dict[Literal['message'], MessageSnapshotPayload]]], + ) -> List[Self]: + if not message_snapshots: + return [] + + return [cls(state, snapshot['message']) for snapshot in message_snapshots] + + def __init__(self, state: ConnectionState, data: MessageSnapshotPayload): + self.type: MessageType = try_enum(MessageType, data['type']) + self.content: str = data['content'] + self.embeds: List[Embed] = [Embed.from_dict(a) for a in data['embeds']] + self.attachments: List[Attachment] = [Attachment(data=a, state=state) for a in data['attachments']] + self.created_at: datetime.datetime = utils.parse_time(data['timestamp']) + self._edited_timestamp: Optional[datetime.datetime] = utils.parse_time(data['edited_timestamp']) + self.flags: MessageFlags = MessageFlags._from_value(data.get('flags', 0)) + self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('stickers_items', [])] + + self.components: List[MessageComponentType] = [] + for component_data in data.get('components', []): + component = _component_factory(component_data) + if component is not None: + self.components.append(component) + + self._state: ConnectionState = state + + def __repr__(self) -> str: + name = self.__class__.__name__ + return f'<{name} type={self.type!r} created_at={self.created_at!r} flags={self.flags!r}>' + + @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 + the syntax of ``<@user_id>`` in the message content. + + This allows you to receive the user IDs of mentioned users + even in a private message context. + """ + return [int(x) for x in re.findall(r'<@!?([0-9]{15,20})>', self.content)] + + @utils.cached_slot_property('_cs_raw_channel_mentions') + def raw_channel_mentions(self) -> List[int]: + """List[:class:`int`]: A property that returns an array of channel IDs matched with + the syntax of ``<#channel_id>`` in the message content. + """ + return [int(x) for x in re.findall(r'<#([0-9]{15,20})>', self.content)] + + @utils.cached_slot_property('_cs_raw_role_mentions') + def raw_role_mentions(self) -> List[int]: + """List[:class:`int`]: A property that returns an array of role IDs matched with + the syntax of ``<@&role_id>`` in the message content. + """ + return [int(x) for x in re.findall(r'<@&([0-9]{15,20})>', self.content)] + + @utils.cached_slot_property('_cs_cached_message') + def cached_message(self) -> Optional[Message]: + """Optional[:class:`Message`]: Returns the cached message this snapshot points to, if any.""" + state = self._state + return ( + utils.find( + lambda m: ( + m.created_at == self.created_at + and m.edited_at == self.edited_at + and m.content == self.content + and m.embeds == self.embeds + and m.components == self.components + and m.stickers == self.stickers + and m.attachments == self.attachments + and m.flags == self.flags + ), + reversed(state._messages), + ) + if state._messages + else None + ) + + @property + def edited_at(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: An aware UTC datetime object containing the edited time of the forwarded message.""" + return self._edited_timestamp + + class MessageReference: """Represents a reference to a :class:`~discord.Message`. @@ -474,6 +604,10 @@ class MessageReference: Attributes ----------- + type: :class:`MessageReferenceType` + The type of message reference. + + .. versionadded:: 2.5 message_id: Optional[:class:`int`] The id of the message referenced. channel_id: :class:`int` @@ -498,10 +632,19 @@ class MessageReference: .. versionadded:: 1.6 """ - __slots__ = ('message_id', 'channel_id', 'guild_id', 'fail_if_not_exists', 'resolved', '_state') + __slots__ = ('type', 'message_id', 'channel_id', 'guild_id', 'fail_if_not_exists', 'resolved', '_state') - def __init__(self, *, message_id: int, channel_id: int, guild_id: Optional[int] = None, fail_if_not_exists: bool = True): + def __init__( + self, + *, + message_id: int, + channel_id: int, + guild_id: Optional[int] = None, + fail_if_not_exists: bool = True, + type: MessageReferenceType = MessageReferenceType.reply, + ): self._state: Optional[ConnectionState] = None + self.type: MessageReferenceType = type self.resolved: Optional[Union[Message, DeletedReferencedMessage]] = None self.message_id: Optional[int] = message_id self.channel_id: int = channel_id @@ -511,6 +654,7 @@ class MessageReference: @classmethod def with_state(cls, state: ConnectionState, data: MessageReferencePayload) -> Self: self = cls.__new__(cls) + self.type = try_enum(MessageReferenceType, data.get('type', 0)) self.message_id = utils._get_as_snowflake(data, 'message_id') self.channel_id = int(data['channel_id']) self.guild_id = utils._get_as_snowflake(data, 'guild_id') @@ -520,7 +664,13 @@ class MessageReference: return self @classmethod - def from_message(cls, message: PartialMessage, *, fail_if_not_exists: bool = True) -> Self: + def from_message( + cls, + message: PartialMessage, + *, + fail_if_not_exists: bool = True, + type: MessageReferenceType = MessageReferenceType.reply, + ) -> Self: """Creates a :class:`MessageReference` from an existing :class:`~discord.Message`. .. versionadded:: 1.6 @@ -534,6 +684,10 @@ class MessageReference: if the message no longer exists or Discord could not fetch the message. .. versionadded:: 1.7 + type: :class:`~discord.MessageReferenceType` + The type of message reference this is. + + .. versionadded:: 2.5 Returns ------- @@ -545,6 +699,7 @@ class MessageReference: channel_id=message.channel.id, guild_id=getattr(message.guild, 'id', None), fail_if_not_exists=fail_if_not_exists, + type=type, ) self._state = message._state return self @@ -567,7 +722,9 @@ class MessageReference: return f'' def to_dict(self) -> MessageReferencePayload: - result: Dict[str, Any] = {'message_id': self.message_id} if self.message_id is not None else {} + result: Dict[str, Any] = ( + {'type': self.type.value, 'message_id': self.message_id} if self.message_id is not None else {} + ) result['channel_id'] = self.channel_id if self.guild_id is not None: result['guild_id'] = self.guild_id @@ -1699,7 +1856,12 @@ class PartialMessage(Hashable): return Message(state=self._state, channel=self.channel, data=data) - def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference: + def to_reference( + self, + *, + fail_if_not_exists: bool = True, + type: MessageReferenceType = MessageReferenceType.reply, + ) -> MessageReference: """Creates a :class:`~discord.MessageReference` from the current message. .. versionadded:: 1.6 @@ -1711,6 +1873,10 @@ class PartialMessage(Hashable): if the message no longer exists or Discord could not fetch the message. .. versionadded:: 1.7 + type: :class:`MessageReferenceType` + The type of message reference. + + .. versionadded:: 2.5 Returns --------- @@ -1718,7 +1884,44 @@ class PartialMessage(Hashable): The reference to this message. """ - return MessageReference.from_message(self, fail_if_not_exists=fail_if_not_exists) + return MessageReference.from_message(self, fail_if_not_exists=fail_if_not_exists, type=type) + + async def forward( + self, + destination: MessageableChannel, + *, + fail_if_not_exists: bool = True, + ) -> Message: + """|coro| + + Forwards this message to a channel. + + .. versionadded:: 2.5 + + Parameters + ---------- + destination: :class:`~discord.abc.Messageable` + The channel to forward this message to. + fail_if_not_exists: :class:`bool` + Whether replying using the message reference should raise :class:`HTTPException` + if the message no longer exists or Discord could not fetch the message. + + Raises + ------ + ~discord.HTTPException + Forwarding the message failed. + + Returns + ------- + :class:`.Message` + The message sent to the channel. + """ + reference = self.to_reference( + fail_if_not_exists=fail_if_not_exists, + type=MessageReferenceType.forward, + ) + ret = await destination.send(reference=reference) + return ret def to_message_reference_dict(self) -> MessageReferencePayload: data: MessageReferencePayload = { @@ -1881,6 +2084,10 @@ class Message(PartialMessage, Hashable): purchase_notification: Optional[:class:`PurchaseNotification`] The data of the purchase notification that prompted this :attr:`MessageType.purchase_notification` message. + .. versionadded:: 2.5 + message_snapshots: List[:class:`MessageSnapshot`] + The message snapshots attached to this message. + .. versionadded:: 2.5 """ @@ -1920,6 +2127,7 @@ class Message(PartialMessage, Hashable): 'poll', 'call', 'purchase_notification', + 'message_snapshots', ) if TYPE_CHECKING: @@ -1958,6 +2166,7 @@ class Message(PartialMessage, Hashable): self.position: Optional[int] = data.get('position') self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id') self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])] + self.message_snapshots: List[MessageSnapshot] = MessageSnapshot._from_value(state, data.get('message_snapshots')) self.poll: Optional[Poll] = None try: diff --git a/discord/types/message.py b/discord/types/message.py index c1972541a..24f6065d1 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -102,7 +102,11 @@ class MessageApplication(TypedDict): cover_image: NotRequired[str] +MessageReferenceType = Literal[0, 1] + + class MessageReference(TypedDict, total=False): + type: MessageReferenceType message_id: Snowflake channel_id: Required[Snowflake] guild_id: Snowflake @@ -173,6 +177,20 @@ MessageType = Literal[ ] +class MessageSnapshot(TypedDict): + type: MessageType + content: str + embeds: List[Embed] + attachments: List[Attachment] + timestamp: str + edited_timestamp: Optional[str] + flags: NotRequired[int] + mentions: List[UserWithMember] + mention_roles: SnowflakeList + stickers_items: NotRequired[List[StickerItem]] + components: NotRequired[List[Component]] + + class Message(PartialMessage): id: Snowflake author: User diff --git a/docs/api.rst b/docs/api.rst index 6f728d56b..338368910 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3810,6 +3810,24 @@ of :class:`enum.Enum`. The subscription is inactive and not being charged. +.. class:: MessageReferenceType + + Represents the type of a message reference. + + .. versionadded:: 2.5 + + .. attribute:: reply + + A message reply. + + .. attribute:: forward + + A forwarded message. + + .. attribute:: default + + An alias for :attr:`.reply`. + .. _discord-api-audit-logs: Audit Log Data @@ -5353,6 +5371,14 @@ PollAnswer .. _discord_api_data: +MessageSnapshot +~~~~~~~~~~~~~~~~~ + +.. attributetable:: MessageSnapshot + +.. autoclass:: MessageSnapshot + :members: + Data Classes --------------