diff --git a/changelog/1113.feature.rst b/changelog/1113.feature.rst index effdde8e52..079ad452e1 100644 --- a/changelog/1113.feature.rst +++ b/changelog/1113.feature.rst @@ -2,4 +2,4 @@ Support application subscriptions and one-time purchases (see the :ddocs:`offici - New types: :class:`SKU`, :class:`Entitlement`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. - New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. -- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.create_entitlement`. +- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1186.feature.rst b/changelog/1186.feature.rst index effdde8e52..079ad452e1 100644 --- a/changelog/1186.feature.rst +++ b/changelog/1186.feature.rst @@ -2,4 +2,4 @@ Support application subscriptions and one-time purchases (see the :ddocs:`offici - New types: :class:`SKU`, :class:`Entitlement`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. - New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. -- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.create_entitlement`. +- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. 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/changelog/1200.bugfix.rst b/changelog/1200.bugfix.rst new file mode 100644 index 0000000000..8d8387af1b --- /dev/null +++ b/changelog/1200.bugfix.rst @@ -0,0 +1 @@ +|commands| Fix usage of :attr:`~ext.commands.BucketType.role`\-type cooldowns in threads, which incorrectly operated on a per-channel basis instead. diff --git a/changelog/1216.feature.rst b/changelog/1216.feature.rst new file mode 100644 index 0000000000..b28073e2b4 --- /dev/null +++ b/changelog/1216.feature.rst @@ -0,0 +1 @@ +Add :meth:`Guild.fetch_voice_state` to fetch the :class:`VoiceState` of a member. diff --git a/changelog/1238.feature.rst b/changelog/1238.feature.rst new file mode 100644 index 0000000000..29445fc514 --- /dev/null +++ b/changelog/1238.feature.rst @@ -0,0 +1 @@ +Add support for ``BaseFlags`` to allow comparison with ``flag_values`` and vice versa. diff --git a/changelog/1249.feature.rst b/changelog/1249.feature.rst new file mode 100644 index 0000000000..079ad452e1 --- /dev/null +++ b/changelog/1249.feature.rst @@ -0,0 +1,5 @@ +Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). +- New types: :class:`SKU`, :class:`Entitlement`. +- New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. +- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. 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/client.py b/disnake/client.py index 2c4e2c7da4..990e19c201 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -3147,6 +3147,7 @@ def entitlements( guild: Optional[Snowflake] = None, skus: Optional[Sequence[Snowflake]] = None, exclude_ended: bool = False, + exclude_deleted: bool = True, oldest_first: bool = False, ) -> EntitlementIterator: """Retrieves an :class:`.AsyncIterator` that enables receiving entitlements for the application. @@ -3186,6 +3187,8 @@ def entitlements( The SKUs for which entitlements are retrieved. exclude_ended: :class:`bool` Whether to exclude ended/expired entitlements. Defaults to ``False``. + exclude_deleted: :class:`bool` + Whether to exclude deleted entitlements. Defaults to ``True``. oldest_first: :class:`bool` If set to ``True``, return entries in oldest->newest order. Defaults to ``False``. @@ -3209,9 +3212,40 @@ def entitlements( guild_id=guild.id if guild is not None else None, sku_ids=[sku.id for sku in skus] if skus else None, exclude_ended=exclude_ended, + exclude_deleted=exclude_deleted, oldest_first=oldest_first, ) + async def fetch_entitlement(self, entitlement_id: int, /) -> Entitlement: + """|coro| + + Retrieves a :class:`.Entitlement` for the given ID. + + .. note:: + + This method is an API call. To get the entitlements of the invoking user/guild + in interactions, consider using :attr:`.Interaction.entitlements`. + + .. versionadded:: 2.10 + + Parameters + ---------- + entitlement_id: :class:`int` + The ID of the entitlement to retrieve. + + Raises + ------ + HTTPException + Retrieving the entitlement failed. + + Returns + ------- + :class:`.Entitlement` + The retrieved entitlement. + """ + data = await self.http.get_entitlement(self.application_id, entitlement_id=entitlement_id) + return Entitlement(data=data, state=self._connection) + async def create_entitlement( self, sku: Snowflake, owner: Union[abc.User, Guild] ) -> Entitlement: 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/ext/commands/cooldowns.py b/disnake/ext/commands/cooldowns.py index 354754550a..cda127398a 100644 --- a/disnake/ext/commands/cooldowns.py +++ b/disnake/ext/commands/cooldowns.py @@ -7,8 +7,8 @@ from collections import deque from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Optional -from disnake.abc import PrivateChannel from disnake.enums import Enum +from disnake.member import Member from .errors import MaxConcurrencyReached @@ -47,11 +47,9 @@ def get_key(self, msg: Message) -> Any: elif self is BucketType.category: return (msg.channel.category or msg.channel).id # type: ignore elif self is BucketType.role: - # we return the channel id of a private-channel as there are only roles in guilds - # and that yields the same result as for a guild with only the @everyone role - # NOTE: PrivateChannel doesn't actually have an id attribute but we assume we are - # recieving a DMChannel or GroupChannel which inherit from PrivateChannel and do - return (msg.channel if isinstance(msg.channel, PrivateChannel) else msg.author.top_role).id # type: ignore + # if author is not a Member we are in a private-channel context; returning its id + # yields the same result as for a guild with only the @everyone role + return (msg.author.top_role if isinstance(msg.author, Member) else msg.channel).id def __call__(self, msg: Message) -> Any: return self.get_key(msg) diff --git a/disnake/flags.py b/disnake/flags.py index 74839a3c79..270f3f205d 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -57,6 +57,16 @@ def __init__(self, func: Callable[[Any], int]) -> None: self.__doc__ = func.__doc__ self._parent: Type[T] = MISSING + def __eq__(self, other: Any) -> bool: + if isinstance(other, flag_value): + return self.flag == other.flag + if isinstance(other, BaseFlags): + return self._parent is other.__class__ and self.flag == other.value + return False + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + def __or__(self, other: Union[flag_value[T], T]) -> T: if isinstance(other, BaseFlags): if self._parent is not other.__class__: @@ -150,7 +160,11 @@ def _from_value(cls, value: int) -> Self: return self def __eq__(self, other: Any) -> bool: - return isinstance(other, self.__class__) and self.value == other.value + if isinstance(other, self.__class__): + return self.value == other.value + if isinstance(other, flag_value): + return self.__class__ is other._parent and self.value == other.flag + return False def __ne__(self, other: Any) -> bool: return not self.__eq__(other) @@ -588,6 +602,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 = ..., @@ -680,6 +695,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/guild.py b/disnake/guild.py index 05f47d9720..a0609063bf 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -4673,6 +4673,46 @@ async def fetch_voice_regions(self) -> List[VoiceRegion]: data = await self._state.http.get_guild_voice_regions(self.id) return [VoiceRegion(data=region) for region in data] + async def fetch_voice_state(self, member_id: int, /) -> VoiceState: + """|coro| + + Fetches the :class:`VoiceState` of a member. + + .. note:: + + This method is an API call. For general usage, consider :attr:`Member.voice` instead. + + .. versionadded:: 2.10 + + Parameters + ---------- + member_id: :class:`int` + The ID of the member. + + Raises + ------ + NotFound + The member for which you tried to fetch a voice state is not + connected to a channel in this guild. + Forbidden + You do not have permission to fetch the member's voice state. + HTTPException + Fetching the voice state failed. + + Returns + ------- + :class:`VoiceState` + The voice state of the member. + """ + if member_id == self.me.id: + data = await self._state.http.get_my_voice_state(self.id) + else: + data = await self._state.http.get_voice_state(self.id, member_id) + + channel_id = utils._get_as_snowflake(data, "channel_id") + channel: Optional[VocalGuildChannel] = self.get_channel(channel_id) # type: ignore + return VoiceState(data=data, channel=channel) + async def change_voice_state( self, *, channel: Optional[Snowflake], self_mute: bool = False, self_deaf: bool = False ) -> None: diff --git a/disnake/http.py b/disnake/http.py index 1569fe5715..89dfe30724 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -984,6 +984,17 @@ def change_nickname( } return self.request(r, json=payload, reason=reason) + def get_my_voice_state(self, guild_id: Snowflake) -> Response[voice.GuildVoiceState]: + return self.request(Route("GET", "/guilds/{guild_id}/voice-states/@me", guild_id=guild_id)) + + def get_voice_state( + self, guild_id: Snowflake, user_id: Snowflake + ) -> Response[voice.GuildVoiceState]: + r = Route( + "GET", "/guilds/{guild_id}/voice-states/{user_id}", guild_id=guild_id, user_id=user_id + ) + return self.request(r) + def edit_my_voice_state(self, guild_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: r = Route("PATCH", "/guilds/{guild_id}/voice-states/@me", guild_id=guild_id) return self.request(r, json=payload) @@ -2365,10 +2376,12 @@ def get_entitlements( guild_id: Optional[Snowflake] = None, sku_ids: Optional[SnowflakeList] = None, exclude_ended: bool = False, + exclude_deleted: bool = False, ) -> Response[List[entitlement.Entitlement]]: params: Dict[str, Any] = { "limit": limit, "exclude_ended": int(exclude_ended), + "exclude_deleted": int(exclude_deleted), } if before is not None: params["before"] = before @@ -2386,6 +2399,18 @@ def get_entitlements( ) return self.request(r, params=params) + def get_entitlement( + self, application_id: Snowflake, entitlement_id: int + ) -> Response[entitlement.Entitlement]: + return self.request( + Route( + "GET", + "/applications/{application_id}/entitlements/{entitlement_id}", + application_id=application_id, + entitlement_id=entitlement_id, + ) + ) + def create_test_entitlement( self, application_id: Snowflake, diff --git a/disnake/iterators.py b/disnake/iterators.py index 6d629066af..86c311e39f 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1044,6 +1044,7 @@ def __init__( before: Optional[Union[Snowflake, datetime.datetime]] = None, after: Optional[Union[Snowflake, datetime.datetime]] = None, exclude_ended: bool = False, + exclude_deleted: bool = True, oldest_first: bool = False, ) -> None: if isinstance(before, datetime.datetime): @@ -1059,6 +1060,7 @@ def __init__( self.guild_id: Optional[int] = guild_id self.sku_ids: Optional[List[int]] = sku_ids self.exclude_ended: bool = exclude_ended + self.exclude_deleted: bool = exclude_deleted self.state: ConnectionState = state self.request = state.http.get_entitlements @@ -1116,6 +1118,7 @@ async def _before_strategy(self, retrieve: int) -> List[EntitlementPayload]: user_id=self.user_id, guild_id=self.guild_id, exclude_ended=self.exclude_ended, + exclude_deleted=self.exclude_deleted, ) if len(data): @@ -1133,6 +1136,7 @@ async def _after_strategy(self, retrieve: int) -> List[EntitlementPayload]: user_id=self.user_id, guild_id=self.guild_id, exclude_ended=self.exclude_ended, + exclude_deleted=self.exclude_deleted, ) if len(data): 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 86cbea3e04..c8020ea398 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -23,12 +23,14 @@ ) 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, + MessageReferenceType, MessageType, try_enum, try_enum_to_int, @@ -73,6 +75,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, @@ -97,6 +100,7 @@ "InteractionMetadata", "AuthorizingIntegrationOwners", "RoleSubscriptionData", + "ForwardedMessage", ) @@ -579,12 +583,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. @@ -603,11 +612,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, @@ -615,6 +633,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 @@ -623,6 +642,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") @@ -632,7 +654,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 @@ -641,6 +669,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. @@ -653,6 +687,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), @@ -676,10 +711,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, } @@ -1049,6 +1085,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. @@ -1087,6 +1128,7 @@ class Message(Hashable): "reference", "_interaction", "interaction_metadata", + "message_snapshots", "application", "activity", "stickers", @@ -1200,6 +1242,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]) @@ -1354,7 +1407,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 @@ -2360,13 +2415,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. @@ -2378,10 +2478,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, } @@ -2446,6 +2553,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 ( @@ -2743,3 +2851,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 c7f072727c..c3f8e4d1e9 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] # deprecated diff --git a/docs/api/messages.rst b/docs/api/messages.rst index cc7f3714bd..2e616af310 100644 --- a/docs/api/messages.rst +++ b/docs/api/messages.rst @@ -217,6 +217,14 @@ PollMedia .. autoclass:: PollMedia :members: +ForwardedMessage +~~~~~~~~~~~~~~~~ + +.. attributetable:: ForwardedMessage + +.. autoclass:: ForwardedMessage + :members: + Enumerations ------------ @@ -428,6 +436,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 ------ diff --git a/tests/test_flags.py b/tests/test_flags.py index cb9d64b0ea..575851c26e 100644 --- a/tests/test_flags.py +++ b/tests/test_flags.py @@ -184,6 +184,21 @@ def test__eq__(self) -> None: assert not ins == other assert ins != other + def test__eq__flag_value(self) -> None: + ins = TestFlags(one=True) + other = TestFlags(one=True, two=True) + + assert ins == TestFlags.one + assert TestFlags.one == ins + + assert not ins != TestFlags.one + assert ins != TestFlags.two + + assert other != TestFlags.one + assert other != TestFlags.two + + assert other == TestFlags.three + def test__and__(self) -> None: ins = TestFlags(one=True, two=True) other = TestFlags(one=True, two=True)