diff --git a/changelog/993.feature.rst b/changelog/993.feature.rst new file mode 100644 index 0000000000..38905a2a9e --- /dev/null +++ b/changelog/993.feature.rst @@ -0,0 +1,4 @@ +Support voice channel effect events. +- New events: :func:`on_voice_channel_effect`, :func:`on_raw_voice_channel_effect`. +- New types: :class:`VoiceChannelEffect`, :class:`RawVoiceChannelEffectEvent`. +- New enum: :class:`VoiceChannelEffectAnimationType`. diff --git a/disnake/channel.py b/disnake/channel.py index 980a5ea1ce..a1a37a057f 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -35,6 +35,7 @@ ThreadLayout, ThreadSortOrder, VideoQualityMode, + VoiceChannelEffectAnimationType, try_enum, try_enum_to_int, ) @@ -50,6 +51,7 @@ from .utils import MISSING __all__ = ( + "VoiceChannelEffect", "TextChannel", "VoiceChannel", "StageChannel", @@ -90,6 +92,7 @@ ) from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDurationLiteral + from .types.voice import VoiceChannelEffect as VoiceChannelEffectPayload from .ui.action_row import Components, MessageUIComponent from .ui.view import View from .user import BaseUser, ClientUser, User @@ -97,6 +100,51 @@ from .webhook import Webhook +class VoiceChannelEffect: + """An effect sent by a member in a voice channel. + + Different sets of attributes will be present, depending on the type of effect. + + .. versionadded:: 2.10 + + Attributes + ---------- + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`]] + The emoji, for emoji reaction effects. + animation_type: Optional[:class:`VoiceChannelEffectAnimationType`] + The emoji animation type, for emoji reaction effects. + animation_id: Optional[:class:`int`] + The emoji animation ID, for emoji reaction effects. + """ + + __slots__ = ( + "emoji", + "animation_type", + "animation_id", + ) + + def __init__(self, *, data: VoiceChannelEffectPayload, state: ConnectionState) -> None: + self.emoji: Optional[Union[Emoji, PartialEmoji]] = None + if emoji_data := data.get("emoji"): + emoji = state._get_emoji_from_data(emoji_data) + if isinstance(emoji, str): + emoji = PartialEmoji(name=emoji) + self.emoji = emoji + + self.animation_type = ( + try_enum(VoiceChannelEffectAnimationType, value) + if (value := data.get("animation_type")) is not None + else None + ) + self.animation_id: Optional[int] = utils._get_as_snowflake(data, "animation_id") + + def __repr__(self) -> str: + return ( + f"" + ) + + async def _single_delete_strategy(messages: Iterable[Message]) -> None: for m in messages: await m.delete() diff --git a/disnake/enums.py b/disnake/enums.py index 6f81211156..5c917911f0 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -72,6 +72,7 @@ "SKUType", "EntitlementType", "PollLayoutType", + "VoiceChannelEffectAnimationType", ) @@ -1160,6 +1161,19 @@ class Event(Enum): """Called when a `Member` changes their `VoiceState`. Represents the :func:`on_voice_state_update` event. """ + voice_channel_effect = "voice_channel_effect" + """Called when a `Member` sends an effect in a voice channel the bot is connected to. + Represents the :func:`on_voice_channel_effect` event. + + .. versionadded:: 2.10 + """ + raw_voice_channel_effect = "raw_voice_channel_effect" + """Called when a `Member` sends an effect in a voice channel the bot is connected to, + regardless of the member cache. + Represents the :func:`on_raw_voice_channel_effect` event. + + .. versionadded:: 2.10 + """ stage_instance_create = "stage_instance_create" """Called when a `StageInstance` is created for a `StageChannel`. Represents the :func:`on_stage_instance_create` event. @@ -1385,6 +1399,11 @@ class PollLayoutType(Enum): default = 1 +class VoiceChannelEffectAnimationType(Enum): + premium = 0 + basic = 1 + + T = TypeVar("T") diff --git a/disnake/flags.py b/disnake/flags.py index 406095a6d2..da3cba6904 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -1267,6 +1267,8 @@ def voice_states(self): This corresponds to the following events: - :func:`on_voice_state_update` + - :func:`on_voice_channel_effect` + - :func:`on_raw_voice_channel_effect` This also corresponds to the following attributes and classes in terms of cache: diff --git a/disnake/raw_models.py b/disnake/raw_models.py index 48b8dab56d..5e1ab017f0 100644 --- a/disnake/raw_models.py +++ b/disnake/raw_models.py @@ -9,6 +9,7 @@ from .utils import _get_as_snowflake, get_slots if TYPE_CHECKING: + from .channel import VoiceChannelEffect from .member import Member from .message import Message from .partial_emoji import PartialEmoji @@ -29,6 +30,7 @@ PresenceUpdateEvent, ThreadDeleteEvent, TypingStartEvent, + VoiceChannelEffectSendEvent, ) from .user import User @@ -48,6 +50,7 @@ "RawGuildMemberRemoveEvent", "RawPresenceUpdateEvent", "RawPollVoteActionEvent", + "RawVoiceChannelEffectEvent", ) @@ -534,3 +537,39 @@ def __init__(self, data: PresenceUpdateEvent) -> None: self.user_id: int = int(data["user"]["id"]) self.guild_id: int = int(data["guild_id"]) self.data: PresenceUpdateEvent = data + + +class RawVoiceChannelEffectEvent(_RawReprMixin): + """Represents the event payload for an :func:`on_raw_voice_channel_effect` event. + + .. versionadded:: 2.10 + + Attributes + ---------- + channel_id: :class:`int` + The ID of the channel where the effect was sent. + guild_id: :class:`int` + The ID of the guild where the effect was sent. + user_id: :class:`int` + The ID of the user who sent the effect. + effect: :class:`VoiceChannelEffect` + The effect that was sent. + cached_member: Optional[:class:`Member`] + The member who sent the effect, if they could be found in the internal cache. + """ + + __slots__ = ( + "channel_id", + "guild_id", + "user_id", + "effect", + "cached_member", + ) + + def __init__(self, data: VoiceChannelEffectSendEvent, effect: VoiceChannelEffect) -> None: + self.channel_id: int = int(data["channel_id"]) + self.guild_id: int = int(data["guild_id"]) + self.user_id: int = int(data["user_id"]) + self.effect: VoiceChannelEffect = effect + + self.cached_member: Optional[Member] = None diff --git a/disnake/state.py b/disnake/state.py index a467ed6b0c..c3263976c6 100644 --- a/disnake/state.py +++ b/disnake/state.py @@ -42,6 +42,7 @@ StageChannel, TextChannel, VoiceChannel, + VoiceChannelEffect, _guild_channel_factory, _threaded_channel_factory, ) @@ -79,6 +80,7 @@ RawThreadDeleteEvent, RawThreadMemberRemoveEvent, RawTypingEvent, + RawVoiceChannelEffectEvent, ) from .role import Role from .stage_instance import StageInstance @@ -1809,7 +1811,6 @@ def parse_voice_state_update(self, data: gateway.VoiceStateUpdateEvent) -> None: if flags.voice: if channel_id is None and flags._voice_only and member.id != self_id: # Only remove from cache if we only have the voice flag enabled - # Member doesn't meet the Snowflake protocol currently guild._remove_member(member) elif channel_id is not None: guild._add_member(member) @@ -1831,6 +1832,25 @@ def parse_voice_server_update(self, data: gateway.VoiceServerUpdateEvent) -> Non logging_coroutine(coro, info="Voice Protocol voice server update handler") ) + def parse_voice_channel_effect_send(self, data: gateway.VoiceChannelEffectSendEvent) -> None: + guild = self._get_guild(int(data["guild_id"])) + if guild is None: + _log.debug( + "VOICE_CHANNEL_EFFECT_SEND referencing an unknown guild ID: %s. Discarding.", + data["guild_id"], + ) + return + + effect = VoiceChannelEffect(data=data, state=self) + raw = RawVoiceChannelEffectEvent(data, effect) + + channel = guild.get_channel(raw.channel_id) + raw.cached_member = member = guild.get_member(raw.user_id) + self.dispatch("raw_voice_channel_effect", raw) + + if channel and member: + self.dispatch("voice_channel_effect", channel, member, effect) + # FIXME: this should be refactored. The `GroupChannel` path will never be hit, # `raw.timestamp` exists so no need to parse it twice, and `.get_user` should be used before falling back def parse_typing_start(self, data: gateway.TypingStartEvent) -> None: diff --git a/disnake/types/gateway.py b/disnake/types/gateway.py index 2926786e5d..736634c944 100644 --- a/disnake/types/gateway.py +++ b/disnake/types/gateway.py @@ -25,7 +25,7 @@ from .sticker import GuildSticker from .threads import Thread, ThreadMember, ThreadMemberWithPresence, ThreadType from .user import AvatarDecorationData, User -from .voice import GuildVoiceState, SupportedModes +from .voice import GuildVoiceState, SupportedModes, VoiceChannelEffect class SessionStartLimit(TypedDict): @@ -612,6 +612,13 @@ class VoiceServerUpdateEvent(TypedDict): endpoint: Optional[str] +# https://discord.com/developers/docs/topics/gateway-events#voice-channel-effect-send +class VoiceChannelEffectSendEvent(VoiceChannelEffect): + channel_id: Snowflake + guild_id: Snowflake + user_id: Snowflake + + # https://discord.com/developers/docs/topics/gateway-events#typing-start class TypingStartEvent(TypedDict): channel_id: Snowflake diff --git a/disnake/types/voice.py b/disnake/types/voice.py index 8942db224e..4ad9bc36b9 100644 --- a/disnake/types/voice.py +++ b/disnake/types/voice.py @@ -4,6 +4,7 @@ from typing_extensions import NotRequired +from .emoji import PartialEmoji from .member import MemberWithUser from .snowflake import Snowflake @@ -12,6 +13,8 @@ "aead_xchacha20_poly1305_rtpsize", ] +VoiceChannelEffectAnimationType = Literal[0, 1] + class _VoiceState(TypedDict): user_id: Snowflake @@ -57,3 +60,9 @@ class VoiceReady(TypedDict): port: int modes: List[SupportedModes] heartbeat_interval: int + + +class VoiceChannelEffect(TypedDict, total=False): + emoji: Optional[PartialEmoji] + animation_type: Optional[VoiceChannelEffectAnimationType] + animation_id: int diff --git a/docs/api/events.rst b/docs/api/events.rst index fb0059f1fa..c5683e456c 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1048,6 +1048,36 @@ Voice :param after: The voice state after the changes. :type after: :class:`VoiceState` +.. function:: on_voice_channel_effect(channel, member, effect) + + Called when a :class:`Member` sends an effect in a voice channel the bot is connected to. + + This requires :attr:`Intents.voice_states` and :attr:`Intents.members` to be enabled. + + If the member is not found in the internal member cache, then this + event will not be called. Consider using :func:`on_raw_voice_channel_effect` instead. + + .. versionadded:: 2.10 + + :param channel: The voice channel where the effect was sent. + :type channel: :class:`VoiceChannel` + :param member: The member that sent the effect. + :type member: :class:`Member` + :param effect: The effect that was sent. + :type effect: :class:`VoiceChannelEffect` + +.. function:: on_raw_voice_channel_effect(payload) + + Called when a :class:`Member` sends an effect in a voice channel the bot is connected to. + Unlike :func:`on_voice_channel_effect`, this is called regardless of the member cache. + + This requires :attr:`Intents.voice_states` to be enabled. + + .. versionadded:: 2.10 + + :param payload: The raw event payload data. + :type payload: :class:`RawVoiceChannelEffectEvent` + Interactions ~~~~~~~~~~~~ diff --git a/docs/api/voice.rst b/docs/api/voice.rst index 457473a926..e56d1a2eb9 100644 --- a/docs/api/voice.rst +++ b/docs/api/voice.rst @@ -101,10 +101,43 @@ VoiceRegion .. autoclass:: VoiceRegion() :members: +VoiceChannelEffect +~~~~~~~~~~~~~~~~~~ + +.. attributetable:: VoiceChannelEffect + +.. autoclass:: VoiceChannelEffect() + :members: + +RawVoiceChannelEffectEvent +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: RawVoiceChannelEffectEvent + +.. autoclass:: RawVoiceChannelEffectEvent() + :members: + Enumerations ------------ +VoiceChannelEffectAnimationType +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. class:: VoiceChannelEffectAnimationType + + The type of an emoji reaction effect animation in a voice channel. + + .. versionadded:: 2.10 + + .. attribute:: premium + + A fun animation, sent by a Nitro subscriber. + + .. attribute:: basic + + A standard animation. + PartyType ~~~~~~~~~ @@ -168,3 +201,5 @@ Events ------ - :func:`on_voice_state_update(member, before, after) ` +- :func:`on_voice_channel_effect(channel, member, effect) ` +- :func:`on_raw_voice_channel_effect(payload) `