diff --git a/CHANGELOG.md b/CHANGELOG.md index 38725e4141..56f1a3b162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#2579](https://github.com/Pycord-Development/pycord/pull/2579)) - Added new `Subscription` object and related methods/events. ([#2564](https://github.com/Pycord-Development/pycord/pull/2564)) +- Added `Message.forward_to`, `Message.snapshots`, and other related attributes. + ([#2598](https://github.com/Pycord-Development/pycord/pull/2598)) - Added the ability to change the API's base URL with `Route.API_BASE_URL`. ([#2714](https://github.com/Pycord-Development/pycord/pull/2714)) - Added the ability to pass a `datetime.time` object to `format_dt` diff --git a/discord/abc.py b/discord/abc.py index 3b490f42b6..c38426f127 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1497,8 +1497,8 @@ async def send( .. versionadded:: 1.4 reference: Union[:class:`~discord.Message`, :class:`~discord.MessageReference`, :class:`~discord.PartialMessage`] - A reference to the :class:`~discord.Message` to which you are replying, this can be created using - :meth:`~discord.Message.to_reference` or passed directly as a :class:`~discord.Message`. You can control + A reference to the :class:`~discord.Message` you are replying to or forwarding, this can be created using + :meth:`~discord.Message.to_reference` or passed directly as a :class:`~discord.Message`. When replying, you can control whether this mentions the author of the referenced message using the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions`` or by setting ``mention_author``. @@ -1589,9 +1589,19 @@ async def send( allowed_mentions = allowed_mentions or AllowedMentions().to_dict() allowed_mentions["replied_user"] = bool(mention_author) + _reference = None if reference is not None: try: - reference = reference.to_message_reference_dict() + _reference = reference.to_message_reference_dict() + from .message import MessageReference + + if not isinstance(reference, MessageReference): + utils.warn_deprecated( + f"Passing {type(reference).__name__} to reference", + "MessageReference", + "2.7", + "3.0", + ) except AttributeError: raise InvalidArgument( "reference parameter must be Message, MessageReference, or" @@ -1641,7 +1651,7 @@ async def send( nonce=nonce, enforce_nonce=enforce_nonce, allowed_mentions=allowed_mentions, - message_reference=reference, + message_reference=_reference, stickers=stickers, components=components, flags=flags.value, @@ -1660,7 +1670,7 @@ async def send( nonce=nonce, enforce_nonce=enforce_nonce, allowed_mentions=allowed_mentions, - message_reference=reference, + message_reference=_reference, stickers=stickers, components=components, flags=flags.value, diff --git a/discord/channel.py b/discord/channel.py index 687baa5e5a..be6c68b38a 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -3412,6 +3412,9 @@ def get_partial_message(self, message_id: int, /) -> PartialMessage: return PartialMessage(channel=self, id=message_id) + def __repr__(self) -> str: + return f"" + def _guild_channel_factory(channel_type: int): value = try_enum(ChannelType, channel_type) diff --git a/discord/enums.py b/discord/enums.py index e1086651e9..01d10275c8 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -76,6 +76,8 @@ "EntitlementOwnerType", "IntegrationType", "InteractionContextType", + "PollLayoutType", + "MessageReferenceType", ) @@ -266,6 +268,12 @@ class MessageType(Enum): stage_raise_hand = 30 stage_topic = 31 guild_application_premium_subscription = 32 + guild_incident_alert_mode_enabled = 36 + guild_incident_alert_mode_disabled = 37 + guild_incident_report_raid = 38 + guild_incident_report_false_alarm = 39 + purchase_notification = 44 + poll_result = 46 class VoiceRegion(Enum): @@ -1055,6 +1063,13 @@ class PollLayoutType(Enum): default = 1 +class MessageReferenceType(Enum): + """The type of the message reference object""" + + default = 0 + forward = 1 + + class SubscriptionStatus(Enum): """The status of a subscription.""" diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index c140586faa..e2be74a21b 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -403,3 +403,9 @@ async def send_help(self, *args: Any) -> Any: @discord.utils.copy_doc(Message.reply) async def reply(self, content: str | None = None, **kwargs: Any) -> Message: return await self.message.reply(content, **kwargs) + + @discord.utils.copy_doc(Message.forward_to) + async def forward_to( + self, channel: discord.abc.Messageable, **kwargs: Any + ) -> Message: + return await self.message.forward_to(channel, **kwargs) diff --git a/discord/flags.py b/discord/flags.py index 7073a56e35..3a4c8d27bc 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -411,6 +411,14 @@ def is_voice_message(self): """ return 8192 + @flag_value + def has_snapshot(self): + """:class:`bool`: Returns ``True`` if this message has a snapshot from message forwarding. + + .. versionadded:: 2.7 + """ + return 1 << 14 + @fill_with_flags() class PublicUserFlags(BaseFlags): diff --git a/discord/message.py b/discord/message.py index 55a6bfd6b4..3523d84f83 100644 --- a/discord/message.py +++ b/discord/message.py @@ -46,7 +46,7 @@ from .components import _component_factory from .embeds import Embed from .emoji import AppEmoji, GuildEmoji -from .enums import ChannelType, MessageType, try_enum +from .enums import ChannelType, MessageReferenceType, MessageType, try_enum from .errors import InvalidArgument from .file import File from .flags import AttachmentFlags, MessageFlags @@ -79,11 +79,13 @@ from .types.member import Member as MemberPayload from .types.member import UserWithMember as UserWithMemberPayload from .types.message import Attachment as AttachmentPayload + from .types.message import ForwardedMessage as ForwardedMessagePayload from .types.message import Message as MessagePayload from .types.message import MessageActivity as MessageActivityPayload from .types.message import MessageApplication as MessageApplicationPayload from .types.message import MessageCall as MessageCallPayload from .types.message import MessageReference as MessageReferencePayload + from .types.message import MessageSnapshot as MessageSnapshotPayload from .types.message import Reaction as ReactionPayload from .types.poll import Poll as PollPayload from .types.snowflake import SnowflakeList @@ -102,6 +104,7 @@ "MessageReference", "MessageCall", "DeletedReferencedMessage", + "ForwardedMessage", ) @@ -478,6 +481,11 @@ class MessageReference: Attributes ---------- + type: Optional[:class:`~discord.MessageReferenceType`] + The type of message reference. If this is not provided, assume the default behavior (i.e., reply). + + .. versionadded:: 2.7 + message_id: Optional[:class:`int`] The id of the message referenced. channel_id: :class:`int` @@ -508,6 +516,7 @@ class MessageReference: "guild_id", "fail_if_not_exists", "resolved", + "type", "_state", ) @@ -518,9 +527,11 @@ def __init__( channel_id: int, guild_id: int | None = None, fail_if_not_exists: bool = True, + type: MessageReferenceType = MessageReferenceType.default, ): self._state: ConnectionState | None = None self.resolved: Message | DeletedReferencedMessage | None = None + self.type: MessageReferenceType = type self.message_id: int | None = message_id self.channel_id: int = channel_id self.guild_id: int | None = guild_id @@ -531,8 +542,12 @@ def with_state( cls: type[MR], state: ConnectionState, data: MessageReferencePayload ) -> MR: self = cls.__new__(cls) + self.type = ( + try_enum(MessageReferenceType, data.get("type")) + or MessageReferenceType.default + ) self.message_id = utils._get_as_snowflake(data, "message_id") - self.channel_id = int(data.pop("channel_id")) + self.channel_id = utils._get_as_snowflake(data, "channel_id") self.guild_id = utils._get_as_snowflake(data, "guild_id") self.fail_if_not_exists = data.get("fail_if_not_exists", True) self._state = state @@ -541,7 +556,11 @@ def with_state( @classmethod def from_message( - cls: type[MR], message: Message, *, fail_if_not_exists: bool = True + cls: type[MR], + message: Message, + *, + fail_if_not_exists: bool = True, + type: MessageReferenceType = MessageReferenceType.default, ) -> MR: """Creates a :class:`MessageReference` from an existing :class:`~discord.Message`. @@ -557,6 +576,11 @@ def from_message( .. versionadded:: 1.7 + type: Optional[:class:`~discord.MessageReferenceType`] + The type of reference to create. Defaults to :attr:`MessageReferenceType.default` (reply). + + .. versionadded:: 2.7 + Returns ------- :class:`MessageReference` @@ -567,6 +591,7 @@ def from_message( 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 @@ -588,7 +613,8 @@ def jump_url(self) -> str: def __repr__(self) -> str: return ( f"" + f" channel_id={self.channel_id!r} guild_id={self.guild_id!r}" + f" type={self.type!r}>" ) def to_dict(self) -> MessageReferencePayload: @@ -596,6 +622,7 @@ def to_dict(self) -> MessageReferencePayload: {"message_id": self.message_id} if self.message_id is not None else {} ) result["channel_id"] = self.channel_id + result["type"] = self.type and self.type.value if self.guild_id is not None: result["guild_id"] = self.guild_id if self.fail_if_not_exists is not None: @@ -633,6 +660,122 @@ def ended_at(self) -> datetime.datetime | None: return self._ended_timestamp +class ForwardedMessage: + """Represents the snapshotted contents from a forwarded message. Forwarded messages are immutable; any updates to the original message will not be reflected. + + .. versionadded:: 2.7 + + Attributes + ---------- + type: :class:`MessageType` + The type of the original message. In most cases this should not be checked, but it is helpful + in cases where it might be a system message for :attr:`system_content`. + original_message: Optional[Union[:class:`Message`, :class:`PartialMessage`]] + The original message that was forwarded, if available. + channel: Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`, :class:`PartialMessageable`] + The :class:`TextChannel` or :class:`Thread` that the original message was sent from. + guild: Optional[Union[:class:`Guild`, :class:`Object`]] + The guild that the original message belonged to, if applicable. + content: :class:`str` + The contents of the original message. + embeds: List[:class:`Embed`] + A list of embeds the original message had. + attachments: List[:class:`Attachment`] + A list of attachments given to the original message. + flags: :class:`MessageFlags` + Extra features of the original message. + mentions: List[Union[:class:`abc.User`, :class:`Object`]] + A list of :class:`Member` that were originally mentioned. + role_mentions: List[Union[:class:`Role`, :class:`Object`]] + A list of :class:`Role` that were originally mentioned. + stickers: List[:class:`StickerItem`] + A list of sticker items given to the original message. + components: List[:class:`Component`] + A list of components in the original message. + """ + + def __init__( + self, + *, + state: ConnectionState, + reference: MessageReference, + data: ForwardedMessagePayload, + ): + self._state: ConnectionState = state + self._reference = reference + self.id: int = reference.message_id + self.channel = state.get_channel(reference.channel_id) or ( + reference.channel_id + and PartialMessageable( + state=state, + id=reference.channel_id, + ) + ) + self.guild = state._get_guild(reference.guild_id) or ( + reference.guild_id and Object(reference.guild_id) + ) + self.original_message = state._get_message(self.id) or ( + self.id and self.channel.get_partial_message(self.id) + ) + 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.flags: MessageFlags = MessageFlags._from_value(data.get("flags", 0)) + self.stickers: list[StickerItem] = [ + StickerItem(data=d, state=state) for d in data.get("sticker_items", []) + ] + self.components: list[Component] = [ + _component_factory(d) for d in data.get("components", []) + ] + self._edited_timestamp: datetime.datetime | None = utils.parse_time( + data["edited_timestamp"] + ) + + @property + def created_at(self) -> datetime.datetime: + """The original message's creation time in UTC.""" + return utils.snowflake_time(self.id) + + @property + def edited_at(self) -> datetime.datetime | None: + """An aware UTC datetime object containing the + edited time of the original message. + """ + return self._edited_timestamp + + def __repr__(self) -> str: + return f"" + + +class MessageSnapshot: + """Represents a message snapshot. + + .. versionadded:: 2.7 + + Attributes + ---------- + message: :class:`ForwardedMessage` + The forwarded message, which includes a minimal subset of fields from the original message. + """ + + def __init__( + self, + *, + state: ConnectionState, + reference: MessageReference, + data: MessageSnapshotPayload, + ): + self._state: ConnectionState = state + self.message: ForwardedMessage | None + if fm := data.get("message"): + self.message = ForwardedMessage(state=state, reference=reference, data=fm) + + def __repr__(self) -> str: + return f"" + + def flatten_handlers(cls): prefix = len("_handle_") handlers = [ @@ -785,6 +928,10 @@ class Message(Hashable): The call information associated with this message, if applicable. .. versionadded:: 2.6 + snapshots: Optional[List[:class:`MessageSnapshots`]] + The snapshots attached to this message, if applicable. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -824,6 +971,7 @@ class Message(Hashable): "thread", "_poll", "call", + "snapshots", ) if TYPE_CHECKING: @@ -904,6 +1052,19 @@ def __init__( # the channel will be the correct type here ref.resolved = self.__class__(channel=chan, data=resolved, state=state) # type: ignore + self.snapshots: list[MessageSnapshot] + try: + self.snapshots = [ + MessageSnapshot( + state=state, + reference=self.reference, + data=ms, + ) + for ms in data["message_snapshots"] + ] + except KeyError: + self.snapshots = [] + from .interactions import InteractionMetadata, MessageInteraction self._interaction: MessageInteraction | None @@ -1272,11 +1433,14 @@ def system_content(self) -> str: regardless of the :attr:`Message.type`. In the case of :attr:`MessageType.default` and :attr:`MessageType.reply`\, - this just returns the regular :attr:`Message.content`. Otherwise, this + this just returns the regular :attr:`Message.content`, and forwarded messages + will display the original message's content from :attr:`Message.snapshots`. Otherwise, this returns an English message denoting the contents of the system message. """ if self.type is MessageType.default: + if self.snapshots: + return self.snapshots[0].message and self.snapshots[0].message.content return self.content if self.type is MessageType.recipient_add: @@ -1929,7 +2093,42 @@ async def reply(self, content: str | None = None, **kwargs) -> Message: you specified both ``file`` and ``files``. """ - return await self.channel.send(content, reference=self, **kwargs) + return await self.channel.send(content, reference=self.to_reference(), **kwargs) + + async def forward_to( + self, channel: MessageableChannel | PartialMessageableChannel, **kwargs + ) -> Message: + """|coro| + + A shortcut method to :meth:`.abc.Messageable.send` to forward the + :class:`.Message` to a channel. + + .. versionadded:: 2.7 + + Parameters + ---------- + channel: Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`, :class:`PartialMessageable`] + The channel to forward this to. + + Returns + ------- + :class:`.Message` + The message that was sent. + + Raises + ------ + ~discord.HTTPException + Sending the message failed. + ~discord.Forbidden + You do not have the proper permissions to send the message. + ~discord.InvalidArgument + The ``files`` list is not of the appropriate size, or + you specified both ``file`` and ``files``. + """ + + return await channel.send( + reference=self.to_reference(type=MessageReferenceType.forward), **kwargs + ) async def end_poll(self) -> Message: """|coro| @@ -1959,7 +2158,9 @@ async def end_poll(self) -> Message: return message - def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference: + def to_reference( + self, *, fail_if_not_exists: bool = True, type: MessageReferenceType = None + ) -> MessageReference: """Creates a :class:`~discord.MessageReference` from the current message. .. versionadded:: 1.6 @@ -1972,6 +2173,11 @@ def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference: .. versionadded:: 1.7 + type: Optional[:class:`~discord.MessageReferenceType`] + The type of message reference. Defaults to a reply. + + .. versionadded:: 2.7 + Returns ------- :class:`~discord.MessageReference` @@ -1979,13 +2185,16 @@ def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference: """ return MessageReference.from_message( - self, fail_if_not_exists=fail_if_not_exists + self, fail_if_not_exists=fail_if_not_exists, type=type ) - def to_message_reference_dict(self) -> MessageReferencePayload: + def to_message_reference_dict( + self, type: MessageReferenceType = None + ) -> MessageReferencePayload: data: MessageReferencePayload = { "message_id": self.id, "channel_id": self.channel.id, + "type": type and type.value, } if self.guild is not None: @@ -2046,6 +2255,7 @@ class PartialMessage(Hashable): clear_reaction = Message.clear_reaction clear_reactions = Message.clear_reactions reply = Message.reply + forward_to = Message.forward_to to_reference = Message.to_reference to_message_reference_dict = Message.to_message_reference_dict diff --git a/discord/types/embed.py b/discord/types/embed.py index 33f5f0d942..7d39c1630d 100644 --- a/discord/types/embed.py +++ b/discord/types/embed.py @@ -76,7 +76,14 @@ class EmbedAuthor(TypedDict, total=False): EmbedType = Literal[ - "rich", "image", "video", "gifv", "article", "link", "auto_moderation_message" + "rich", + "image", + "video", + "gifv", + "article", + "link", + "auto_moderation_message", + "poll_result", ] diff --git a/discord/types/message.py b/discord/types/message.py index 20a54204bf..5baa988cd1 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -99,7 +99,11 @@ class MessageApplication(TypedDict): name: str +MessageReferenceType = Literal[0, 1] + + class MessageReference(TypedDict, total=False): + type: NotRequired[MessageReferenceType] message_id: Snowflake channel_id: Snowflake guild_id: Snowflake @@ -111,6 +115,29 @@ class MessageReference(TypedDict, total=False): ] +class MessageCall(TypedDict): + participants: SnowflakeList + ended_timestamp: NotRequired[str] + + +class ForwardedMessage(TypedDict): + type: MessageType + content: str + embeds: list[Embed] + attachments: list[Attachment] + timestamp: str + edited_timestamp: str | None + flags: NotRequired[int] + mentions: list[UserWithMember] + mention_roles: SnowflakeList + sticker_items: NotRequired[list[StickerItem]] + components: NotRequired[list[Component]] + + +class MessageSnapshot(TypedDict): + message: ForwardedMessage + + class Message(TypedDict): guild_id: NotRequired[Snowflake] member: NotRequired[Member] @@ -144,6 +171,8 @@ class Message(TypedDict): pinned: bool type: MessageType poll: Poll + call: MessageCall + message_snapshots: NotRequired[list[MessageSnapshot]] AllowedMentionType = Literal["roles", "users", "everyone"] @@ -154,8 +183,3 @@ class AllowedMentions(TypedDict): roles: SnowflakeList users: SnowflakeList replied_user: bool - - -class MessageCall(TypedDict): - participants: SnowflakeList - ended_timestamp: NotRequired[str]