From 08b2beaeda7c07d74c926179d7639fd40c9a9123 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Wed, 11 Dec 2024 19:11:40 +0100 Subject: [PATCH] feat(message): implement message forwarding (#1188) Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Co-authored-by: shiftinv <8530778+shiftinv@users.noreply.github.com> --- changelog/1187.feature.rst | 1 + disnake/abc.py | 6 + disnake/enums.py | 8 ++ disnake/flags.py | 11 ++ disnake/member.py | 8 +- disnake/message.py | 276 +++++++++++++++++++++++++++++++++++-- disnake/types/message.py | 25 ++++ docs/api/messages.rst | 25 ++++ 8 files changed, 348 insertions(+), 12 deletions(-) create mode 100644 changelog/1187.feature.rst diff --git a/changelog/1187.feature.rst b/changelog/1187.feature.rst new file mode 100644 index 0000000000..07c8ec9f02 --- /dev/null +++ b/changelog/1187.feature.rst @@ -0,0 +1 @@ +Add support for message forwarding. New :class:`ForwardedMessage`, new enum :class:`MessageReferenceType`, new method :func:`Message.forward`, edited :class:`MessageReference` to support message forwarding. diff --git a/disnake/abc.py b/disnake/abc.py index 051931b346..a80952f536 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -1604,6 +1604,12 @@ async def send( .. versionadded:: 1.6 + .. note:: + + Passing a :class:`.Message` or :class:`.PartialMessage` will only allow replies. To forward a message + you must explicitly transform the message to a :class:`.MessageReference` using :meth:`.Message.to_reference` and specify the :class:`.MessageReferenceType`, + or use :meth:`.Message.forward`. + mention_author: Optional[:class:`bool`] If set, overrides the :attr:`.AllowedMentions.replied_user` attribute of ``allowed_mentions``. diff --git a/disnake/enums.py b/disnake/enums.py index 1cf075c85f..ddbc2c42bb 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -74,6 +74,7 @@ "EntitlementType", "PollLayoutType", "VoiceChannelEffectAnimationType", + "MessageReferenceType", ) @@ -1415,6 +1416,13 @@ class VoiceChannelEffectAnimationType(Enum): basic = 1 +class MessageReferenceType(Enum): + default = 0 + """A standard message reference used in message replies.""" + forward = 1 + """Reference used to point to a message at a point in time (forward).""" + + T = TypeVar("T") diff --git a/disnake/flags.py b/disnake/flags.py index 6b2c24e71c..41c319cfc9 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -600,6 +600,7 @@ def __init__( crossposted: bool = ..., ephemeral: bool = ..., failed_to_mention_roles_in_thread: bool = ..., + has_snapshot: bool = ..., has_thread: bool = ..., is_crossposted: bool = ..., is_voice_message: bool = ..., @@ -692,6 +693,16 @@ def is_voice_message(self): """ return 1 << 13 + @flag_value + def has_snapshot(self): + """:class:`bool`: Returns ``True`` if the message is a forward message. + + Messages with this flag will have only the forward data, and no other content. + + .. versionadded:: 2.10 + """ + return 1 << 14 + class PublicUserFlags(BaseFlags): """Wraps up the Discord User Public flags. diff --git a/disnake/member.py b/disnake/member.py index 7ce63f8c9e..c8484bb9e7 100644 --- a/disnake/member.py +++ b/disnake/member.py @@ -387,11 +387,15 @@ def _update_from_message(self, data: MemberPayload) -> None: @classmethod def _try_upgrade( - cls, *, data: UserWithMemberPayload, guild: Guild, state: ConnectionState + cls, + *, + data: Union[UserPayload, UserWithMemberPayload], + guild: Guild, + state: ConnectionState, ) -> Union[User, Self]: # A User object with a 'member' key try: - member_data = data.pop("member") + member_data = data.pop("member") # type: ignore except KeyError: return state.create_user(data) else: diff --git a/disnake/message.py b/disnake/message.py index fc9c93539c..d7442e84db 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -23,10 +23,18 @@ ) from . import utils +from .channel import PartialMessageable from .components import ActionRow, MessageComponent, _component_factory from .embeds import Embed from .emoji import Emoji -from .enums import ChannelType, InteractionType, MessageType, try_enum, try_enum_to_int +from .enums import ( + ChannelType, + InteractionType, + MessageReferenceType, + MessageType, + try_enum, + try_enum_to_int, +) from .errors import HTTPException from .file import File from .flags import AttachmentFlags, MessageFlags @@ -65,6 +73,7 @@ from .types.member import Member as MemberPayload, UserWithMember as UserWithMemberPayload from .types.message import ( Attachment as AttachmentPayload, + ForwardedMessage as ForwardedMessagePayload, Message as MessagePayload, MessageActivity as MessageActivityPayload, MessageApplication as MessageApplicationPayload, @@ -87,6 +96,7 @@ "InteractionReference", "DeletedReferencedMessage", "RoleSubscriptionData", + "ForwardedMessage", ) @@ -569,12 +579,17 @@ class MessageReference: Attributes ---------- + type: :class:`MessageReferenceType` + The type of the message reference. + + .. versionadded:: 2.10 + message_id: Optional[:class:`int`] - The ID of the message referenced. + The ID of the message referenced/forwarded. channel_id: :class:`int` - The channel ID of the message referenced. + The channel ID of the message referenced/forwarded. guild_id: Optional[:class:`int`] - The guild ID of the message referenced. + The guild ID of the message referenced/forwarded. fail_if_not_exists: :class:`bool` Whether replying to the referenced message should raise :class:`HTTPException` if the message no longer exists or Discord could not fetch the message. @@ -593,11 +608,20 @@ 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, *, + type: MessageReferenceType = MessageReferenceType.default, message_id: int, channel_id: int, guild_id: Optional[int] = None, @@ -605,6 +629,7 @@ def __init__( ) -> None: self._state: Optional[ConnectionState] = None self.resolved: Optional[Union[Message, DeletedReferencedMessage]] = None + self.type: MessageReferenceType = type self.message_id: Optional[int] = message_id self.channel_id: int = channel_id self.guild_id: Optional[int] = guild_id @@ -613,6 +638,9 @@ def __init__( @classmethod def with_state(cls, state: ConnectionState, data: MessageReferencePayload) -> Self: self = cls.__new__(cls) + # if the type is not present in the message reference object returned by the API + # we assume automatically that it's a DEFAULT (aka message reply) message reference + 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") @@ -622,7 +650,13 @@ def with_state(cls, state: ConnectionState, data: MessageReferencePayload) -> Se return self @classmethod - def from_message(cls, message: Message, *, fail_if_not_exists: bool = True) -> Self: + def from_message( + cls, + message: Message, + *, + type: MessageReferenceType = MessageReferenceType.default, + fail_if_not_exists: bool = True, + ) -> Self: """Creates a :class:`MessageReference` from an existing :class:`~disnake.Message`. .. versionadded:: 1.6 @@ -631,6 +665,12 @@ def from_message(cls, message: Message, *, fail_if_not_exists: bool = True) -> S ---------- message: :class:`~disnake.Message` The message to be converted into a reference. + type: :class:`MessageReferenceType` + The type of the message reference. This is used to control whether to reply to + or forward a message. Defaults to replying. + + .. versionadded:: 2.10 + fail_if_not_exists: :class:`bool` Whether replying to the referenced message should raise :class:`HTTPException` if the message no longer exists or Discord could not fetch the message. @@ -643,6 +683,7 @@ def from_message(cls, message: Message, *, fail_if_not_exists: bool = True) -> S A reference to the message. """ self = cls( + type=type, message_id=message.id, channel_id=message.channel.id, guild_id=getattr(message.guild, "id", None), @@ -666,10 +707,11 @@ def jump_url(self) -> str: return f"https://discord.com/channels/{guild_id}/{self.channel_id}/{self.message_id}" def __repr__(self) -> str: - return f"" + return f"" def to_dict(self) -> MessageReferencePayload: result: MessageReferencePayload = { + "type": self.type.value, "channel_id": self.channel_id, "fail_if_not_exists": self.fail_if_not_exists, } @@ -929,6 +971,11 @@ class Message(Hashable): .. versionadded:: 2.0 + message_snapshots: list[:class:`ForwardedMessage`] + A list of forwarded messages. + + .. versionadded:: 2.10 + guild: Optional[:class:`Guild`] The guild that the message belongs to, if applicable. @@ -966,6 +1013,7 @@ class Message(Hashable): "reactions", "reference", "interaction", + "message_snapshots", "application", "activity", "stickers", @@ -1074,6 +1122,17 @@ def __init__( # the channel will be the correct type here ref.resolved = self.__class__(channel=chan, data=resolved, state=state) # type: ignore + _ref = data.get("message_reference", {}) + self.message_snapshots: List[ForwardedMessage] = [ + ForwardedMessage( + state=self._state, + channel_id=utils._get_as_snowflake(_ref, "channel_id"), + guild_id=utils._get_as_snowflake(_ref, "guild_id"), + data=a["message"], + ) + for a in data.get("message_snapshots", []) + ] + for handler in ("author", "member", "mentions", "mention_roles"): try: getattr(self, f"_handle_{handler}")(data[handler]) @@ -1228,7 +1287,9 @@ def _handle_member(self, member: MemberPayload) -> None: # TODO: consider adding to cache here self.author = Member._from_message(message=self, data=member) - def _handle_mentions(self, mentions: List[UserWithMemberPayload]) -> None: + def _handle_mentions( + self, mentions: Union[List[UserPayload], List[UserWithMemberPayload]] + ) -> None: self.mentions = r = [] guild = self.guild state = self._state @@ -2221,13 +2282,58 @@ async def reply( reference = self return await self.channel.send(content, reference=reference, **kwargs) - def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference: + async def forward( + self, + channel: MessageableChannel, + ) -> Message: + """|coro| + + A shortcut method to :meth:`.abc.Messageable.send` to forward a + :class:`.Message`. + + .. versionadded:: 2.10 + + Parameters + ---------- + channel: Union[:class:`TextChannel`, :class:`VoiceChannel`, :class:`StageChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`, :class:`PartialMessageable`] + The channel where the message should be forwarded to. + + Raises + ------ + HTTPException + Sending the message failed. + Forbidden + You do not have the proper permissions to send the message. + + Returns + ------- + :class:`.Message` + The message that was sent. + """ + reference = self.to_reference( + type=MessageReferenceType.forward, + fail_if_not_exists=False, + ) + return await channel.send(reference=reference) + + def to_reference( + self, + *, + type: MessageReferenceType = MessageReferenceType.default, + fail_if_not_exists: bool = True, + ) -> MessageReference: """Creates a :class:`~disnake.MessageReference` from the current message. .. versionadded:: 1.6 Parameters ---------- + type: :class:`MessageReferenceType` + The type of the message reference. This is used to control whether to reply to + or forward a message. Defaults to replying. + + .. versionadded:: 2.10 + 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. @@ -2239,10 +2345,17 @@ def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference: :class:`~disnake.MessageReference` The reference to this message. """ - return MessageReference.from_message(self, fail_if_not_exists=fail_if_not_exists) + return MessageReference.from_message( + self, + type=type, + fail_if_not_exists=fail_if_not_exists, + ) def to_message_reference_dict(self) -> MessageReferencePayload: data: MessageReferencePayload = { + # defaulting to REPLY when implicitly transforming a Message or + # PartialMessage object to a MessageReference + "type": 0, "message_id": self.id, "channel_id": self.channel.id, } @@ -2307,6 +2420,7 @@ class PartialMessage(Hashable): reply = Message.reply to_reference = Message.to_reference to_message_reference_dict = Message.to_message_reference_dict + forward = Message.forward def __init__(self, *, channel: MessageableChannel, id: int) -> None: if channel.type not in ( @@ -2604,3 +2718,145 @@ async def edit( components=components, delete_after=delete_after, ) + + +class ForwardedMessage: + """Represents a forwarded :class:`Message`. + + .. versionadded:: 2.10 + + Attributes + ---------- + type: :class:`MessageType` + The type of message. + content: :class:`str` + The actual contents of the message. + embeds: List[:class:`Embed`] + A list of embeds the message has. + channel_id: :class:`int` + The ID of the channel where the message was forwarded from. + attachments: List[:class:`Attachment`] + A list of attachments given to a message. + flags: :class:`MessageFlags` + Extra features of the message. + mentions: List[:class:`abc.User`] + A list of :class:`Member` that were mentioned. If the message is in a private message + then the list will be of :class:`User` instead. For messages that are not of type + :attr:`MessageType.default`\\, this array can be used to aid in system messages. + For more information, see :attr:`Message.system_content`. + + .. warning:: + + The order of the mentions list is not in any particular order so you should + not rely on it. This is a Discord limitation, not one with the library. + role_mentions: List[:class:`Role`] + A list of :class:`Role` that were mentioned. If the message is in a private message + then the list is always empty. + stickers: List[:class:`StickerItem`] + A list of sticker items given to the message. + components: List[:class:`Component`] + A list of components in the message. + guild_id: Optional[:class:`int`] + The guild ID where the message was forwarded from, if applicable. + """ + + __slots__ = ( + "_state", + "type", + "content", + "embeds", + "channel_id", + "attachments", + "_timestamp", + "_edited_timestamp", + "flags", + "mentions", + "role_mentions", + "stickers", + "components", + "guild_id", + ) + + def __init__( + self, + *, + state: ConnectionState, + channel_id: Optional[int], + guild_id: Optional[int], + data: ForwardedMessagePayload, + ) -> None: + self._state = state + 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"]] + # should never be None in message_reference(s) that are forwarding + self.channel_id: int = channel_id # type: ignore + self.attachments: List[Attachment] = [ + Attachment(data=a, state=state) for a in data["attachments"] + ] + self._timestamp: 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("sticker_items", []) + ] + self.components = [ + _component_factory(d, type=ActionRow[MessageComponent]) + for d in data.get("components", []) + ] + self.guild_id = guild_id + + self.mentions: List[Union[User, Member]] = [] + if self.guild is None: + self.mentions = [state.store_user(m) for m in data["mentions"]] + else: + for mention in filter(None, data["mentions"]): + id_search = int(mention["id"]) + member = self.guild.get_member(id_search) + if member is not None: + self.mentions.append(member) + else: + self.mentions.append( + Member._try_upgrade(data=mention, guild=self.guild, state=state) + ) + + self.role_mentions: List[Role] = [] + if self.guild is not None: + for role_id in map(int, data.get("mention_roles", [])): + role = self.guild.get_role(role_id) + if role is not None: + self.role_mentions.append(role) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}>" + + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`disnake.Guild`]: The guild where the message was forwarded from, if applicable. + This could be ``None`` if the guild is not cached. + """ + return self._state._get_guild(self.guild_id) + + @property + def channel(self) -> Optional[Union[GuildChannel, Thread, PartialMessageable]]: + """Optional[Union[:class:`TextChannel`, :class:`VoiceChannel`, :class:`StageChannel`, :class:`Thread`, :class:`PartialMessageable`]]: + The channel that the message was forwarded from. This could be ``None`` if the channel is not cached or a + :class:`disnake.PartialMessageable` if the ``guild`` is not cached or if the message forwarded is not coming from a guild (e.g DMs). + """ + if self.guild: + channel = self.guild.get_channel_or_thread(self.channel_id) + else: + channel = PartialMessageable(state=self._state, id=self.channel_id) + return channel + + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: The message's creation time in UTC.""" + return self._timestamp + + @property + def edited_at(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: An aware UTC datetime object containing the edited time of the message.""" + return self._edited_timestamp diff --git a/disnake/types/message.py b/disnake/types/message.py index 1859985460..a6c3f63cb8 100644 --- a/disnake/types/message.py +++ b/disnake/types/message.py @@ -65,13 +65,37 @@ class MessageApplication(TypedDict): cover_image: NotRequired[str] +MessageReferenceType = Literal[0, 1] + + class MessageReference(TypedDict): + type: MessageReferenceType message_id: NotRequired[Snowflake] channel_id: Snowflake guild_id: NotRequired[Snowflake] fail_if_not_exists: NotRequired[bool] +class ForwardedMessage(TypedDict): + type: MessageType + content: str + embeds: List[Embed] + attachments: List[Attachment] + timestamp: str + edited_timestamp: Optional[str] + flags: NotRequired[int] + mentions: Union[List[User], List[UserWithMember]] + # apparently mention_roles list is not sent if the msg + # is not forwarded in the same guild + mention_roles: NotRequired[SnowflakeList] + sticker_items: NotRequired[List[StickerItem]] + components: NotRequired[List[Component]] + + +class MessageSnapshot(TypedDict): + message: ForwardedMessage + + class RoleSubscriptionData(TypedDict): role_subscription_listing_id: Snowflake tier_name: str @@ -108,6 +132,7 @@ class Message(TypedDict): application: NotRequired[MessageApplication] application_id: NotRequired[Snowflake] message_reference: NotRequired[MessageReference] + message_snapshots: NotRequired[List[MessageSnapshot]] flags: NotRequired[int] referenced_message: NotRequired[Optional[Message]] interaction: NotRequired[InteractionMessageReference] diff --git a/docs/api/messages.rst b/docs/api/messages.rst index 89f92f52f7..ba6c5bb332 100644 --- a/docs/api/messages.rst +++ b/docs/api/messages.rst @@ -209,6 +209,14 @@ PollMedia .. autoclass:: PollMedia :members: +ForwardedMessage +~~~~~~~~~~~~~~~~ + +.. attributetable:: ForwardedMessage + +.. autoclass:: ForwardedMessage + :members: + Enumerations ------------ @@ -420,6 +428,23 @@ PollLayoutType The default poll layout type. +MessageReferenceType +~~~~~~~~~~~~~~~~~~~~ + +.. class:: MessageReferenceType + + Specifies the type of :class:`MessageReference`. This can be used to determine + if a message is e.g. a reply or a forwarded message. + + .. versionadded:: 2.10 + + .. attribute:: default + + A standard message reference used in message replies. + + .. attribute:: forward + + Reference used to point to a message at a point in time (forward). Events ------