diff --git a/discord/__init__.py b/discord/__init__.py index 39fdb26737ec..f9a7dc475619 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -68,6 +68,7 @@ from .components import * from .threads import * from .automod import * +from .soundboard import * class VersionInfo(NamedTuple): diff --git a/discord/channel.py b/discord/channel.py index 3c93832f3230..77e82efad225 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -47,7 +47,16 @@ import discord.abc from .scheduled_event import ScheduledEvent from .permissions import PermissionOverwrite, Permissions -from .enums import ChannelType, ForumLayoutType, ForumOrderType, PrivacyLevel, try_enum, VideoQualityMode, EntityType +from .enums import ( + ChannelType, + ForumLayoutType, + ForumOrderType, + PrivacyLevel, + try_enum, + VideoQualityMode, + EntityType, + VoiceChannelEffectAnimationType, +) from .mixins import Hashable from . import utils from .utils import MISSING @@ -58,6 +67,9 @@ from .partial_emoji import _EmojiTag, PartialEmoji from .flags import ChannelFlags from .http import handle_message_parameters +from .object import Object +from .soundboard import BaseSoundboardSound +from .utils import snowflake_time __all__ = ( 'TextChannel', @@ -69,6 +81,8 @@ 'ForumChannel', 'GroupChannel', 'PartialMessageable', + 'VoiceChannelEffect', + 'VoiceChannelSoundEffect', ) if TYPE_CHECKING: @@ -76,7 +90,6 @@ from .types.threads import ThreadArchiveDuration from .role import Role - from .object import Object from .member import Member, VoiceState from .abc import Snowflake, SnowflakeTime from .embeds import Embed @@ -99,8 +112,10 @@ GroupDMChannel as GroupChannelPayload, ForumChannel as ForumChannelPayload, ForumTag as ForumTagPayload, + VoiceChannelEffect as VoiceChannelEffectPayload, ) from .types.snowflake import SnowflakeList + from .types.soundboard import BaseSoundboardSound as BaseSoundboardSoundPayload OverwriteKeyT = TypeVar('OverwriteKeyT', Role, BaseUser, Object, Union[Role, Member, Object]) @@ -110,6 +125,143 @@ class ThreadWithMessage(NamedTuple): message: Message +class VoiceChannelEffectAnimation(NamedTuple): + id: int + type: VoiceChannelEffectAnimationType + + +class VoiceChannelSoundEffect(BaseSoundboardSound): + """Represents a Discord voice channel sound effect. + + .. versionadded:: 2.3 + + .. container:: operations + + .. describe:: x == y + + Checks if two sound effects are equal. + + .. describe:: x != y + + Checks if two sound effects are not equal. + + .. describe:: hash(x) + + Returns the sound effect's hash. + + Attributes + ------------ + id: :class:`int` + The ID of the sound. + volume: :class:`float` + The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). + override_path: Optional[:class:`str`] + The override path of the sound (e.g. 'default_quack.mp3'). + """ + + __slots__ = ('_state',) + + def __init__(self, *, state: ConnectionState, id: int, volume: float, override_path: Optional[str]): + self._state: ConnectionState = state + data: BaseSoundboardSoundPayload = { + 'sound_id': id, + 'volume': volume, + 'override_path': override_path, + } + super().__init__(data=data) + + def __repr__(self) -> str: + attrs = [ + ('id', self.id), + ('volume', self.volume), + ] + inner = ' '.join('%s=%r' % t for t in attrs) + return f"<{self.__class__.__name__} {inner}>" + + @property + def created_at(self) -> Optional[datetime.datetime]: + """:class:`datetime.datetime`: Returns the snowflake's creation time in UTC. + Returns ``None`` if it's a default sound.""" + if self.is_default(): + return None + else: + return snowflake_time(self.id) + + async def is_default(self) -> bool: + """|coro| + + Checks if the sound is a default sound. + + Returns + --------- + :class:`bool` + Whether it's a default sound or not. + """ + default_sounds = await self._state.http.get_default_soundboard_sounds() + default_sounds = [int(sound['sound_id']) for sound in default_sounds] + + return self.id in default_sounds + + +class VoiceChannelEffect: + """Represents a Discord voice channel effect. + + .. versionadded:: 2.3 + + Attributes + ------------ + channel: :class:`VoiceChannel` + The channel in which the effect is sent. + user: :class:`Member` + The user who sent the effect. + animation: Optional[:class:`VoiceChannelEffectAnimation`] + The animation the effect has. Returns ``None`` if the effect has no animation. + emoji: Optional[:class:`PartialEmoji`] + The emoji of the effect. + sound: Optional[:class:`VoiceChannelSoundEffect`] + The sound of the effect. Returns ``None`` if it's an emoji effect. + """ + + __slots__ = ('channel', 'user', 'animation', 'emoji', 'sound') + + def __init__(self, *, state: ConnectionState, data: VoiceChannelEffectPayload, guild: Guild): + self.channel: VoiceChannel = guild.get_channel(int(data['channel_id'])) # type: ignore # will always be a VoiceChannel + self.user: Member = guild.get_member(int(data['user_id'])) # type: ignore # will always be a Member + self.animation: Optional[VoiceChannelEffectAnimation] = None + + animation_id = data.get('animation_id') + if animation_id is not None: + animation_type = try_enum(VoiceChannelEffectAnimationType, data['animation_type']) # type: ignore # cannot be None here + self.animation = VoiceChannelEffectAnimation(id=animation_id, type=animation_type) + + emoji = data['emoji'] + self.emoji: Optional[PartialEmoji] = PartialEmoji.from_dict(emoji) if emoji is not None else None + self.sound: Optional[VoiceChannelSoundEffect] = None + + sound_id: Optional[int] = utils._get_as_snowflake(data, 'sound_id') + if sound_id is not None: + sound_volume = data['sound_volume'] # type: ignore # sound_volume cannot be None here + sound_override_path = data.get('sound_override_path') + self.sound = VoiceChannelSoundEffect( + state=state, id=sound_id, volume=sound_volume, override_path=sound_override_path + ) + + def __repr__(self) -> str: + attrs = [ + ('channel', self.channel), + ('user', self.user), + ('animation', self.animation), + ('emoji', self.emoji), + ('sound', self.sound), + ] + inner = ' '.join('%s=%r' % t for t in attrs) + return f"<{self.__class__.__name__} {inner}>" + + def is_sound(self) -> bool: + """:class:`bool`: Whether the effect is a sound or not.""" + return self.sound is not None + + class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): """Represents a Discord guild text channel. diff --git a/discord/client.py b/discord/client.py index 298959b212dd..54c0b240b258 100644 --- a/discord/client.py +++ b/discord/client.py @@ -75,6 +75,7 @@ from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory +from .soundboard import DefaultSoundboardSound if TYPE_CHECKING: from types import TracebackType @@ -2642,6 +2643,26 @@ async def fetch_premium_sticker_packs(self) -> List[StickerPack]: data = await self.http.list_premium_sticker_packs() return [StickerPack(state=self._connection, data=pack) for pack in data['sticker_packs']] + async def fetch_default_soundboard_sounds(self) -> List[DefaultSoundboardSound]: + """|coro| + + Retrieves all default soundboard sounds. + + .. versionadded:: 2.3 + + Raises + ------- + HTTPException + Retrieving the default soundboard sounds failed. + + Returns + --------- + List[:class:`.DefaultSoundboardSound`] + All default soundboard sounds. + """ + data = await self.http.get_default_soundboard_sounds() + return [DefaultSoundboardSound(data=sound) for sound in data] + async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| diff --git a/discord/enums.py b/discord/enums.py index 94ca8c726589..0f18e9381bbe 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -68,6 +68,7 @@ 'AutoModRuleActionType', 'ForumLayoutType', 'ForumOrderType', + 'VoiceChannelEffectAnimationType', ) if TYPE_CHECKING: @@ -757,6 +758,11 @@ class ForumOrderType(Enum): creation_date = 1 +class VoiceChannelEffectAnimationType(Enum): + premium = 0 + normal = 1 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/http.py b/discord/http.py index 22336b3234f4..226de51f397b 100644 --- a/discord/http.py +++ b/discord/http.py @@ -90,6 +90,7 @@ scheduled_event, sticker, welcome_screen, + soundboard, ) from .types.snowflake import Snowflake, SnowflakeList @@ -2370,6 +2371,11 @@ def delete_auto_moderation_rule( reason=reason, ) + # Soundboard + + def get_default_soundboard_sounds(self) -> Response[List[soundboard.SoundboardSound]]: + return self.request(Route('GET', '/soundboard-default-sounds')) + # Misc def application_info(self) -> Response[appinfo.AppInfo]: diff --git a/discord/soundboard.py b/discord/soundboard.py new file mode 100644 index 000000000000..153b164c03f9 --- /dev/null +++ b/discord/soundboard.py @@ -0,0 +1,132 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional +from .mixins import Hashable +from .partial_emoji import PartialEmoji + +if TYPE_CHECKING: + from .types.soundboard import ( + BaseSoundboardSound as BaseSoundboardSoundPayload, + SoundboardSound as SoundboardSoundPayload, + ) + +__all__ = ('DefaultSoundboardSound',) + + +class BaseSoundboardSound(Hashable): + """Represents a generic Discord soundboard sound. + + .. versionadded:: 2.3 + + .. container:: operations + + .. describe:: x == y + + Checks if two sounds are equal. + + .. describe:: x != y + + Checks if two sounds are not equal. + + .. describe:: hash(x) + + Returns the sound's hash. + + Attributes + ------------ + id: :class:`int` + The ID of the sound. + volume: :class:`float` + The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). + override_path: Optional[:class:`str`] + The override path of the sound (e.g. 'default_quack.mp3'). + """ + + __slots__ = ('id', 'volume', 'override_path') + + def __init__(self, *, data: BaseSoundboardSoundPayload): + self.id: int = int(data['sound_id']) + self.volume: float = data['volume'] + self.override_path: Optional[str] = data['override_path'] + + def __eq__(self, other: object) -> bool: + if isinstance(other, self.__class__): + return self.id == other.id + return NotImplemented + + __hash__ = Hashable.__hash__ + + +class DefaultSoundboardSound(BaseSoundboardSound): + """Represents a Discord default soundboard sound. + + .. versionadded:: 2.3 + + .. container:: operations + + .. describe:: x == y + + Checks if two sounds are equal. + + .. describe:: x != y + + Checks if two sounds are not equal. + + .. describe:: hash(x) + + Returns the sound's hash. + + Attributes + ------------ + id: :class:`int` + The ID of the sound. + volume: :class:`float` + The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). + override_path: Optional[:class:`str`] + The override path of the sound (e.g. 'default_quack.mp3'). + name: :class:`str` + The name of the sound. + emoji: :class:`PartialEmoji` + The emoji of the sound. + """ + + __slots__ = ('name', 'emoji') + + def __init__(self, *, data: SoundboardSoundPayload): + self.name: str = data['name'] + self.emoji: PartialEmoji = PartialEmoji(name=data['emoji_name']) + super().__init__(data=data) + + def __repr__(self) -> str: + attrs = [ + ('id', self.id), + ('name', self.name), + ('volume', self.volume), + ('emoji', self.emoji), + ] + inner = ' '.join('%s=%r' % t for t in attrs) + return f"<{self.__class__.__name__} {inner}>" diff --git a/discord/state.py b/discord/state.py index 8b556f28c629..4eabf9501b8c 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1538,6 +1538,14 @@ def parse_voice_state_update(self, data: gw.VoiceStateUpdateEvent) -> None: else: _log.debug('VOICE_STATE_UPDATE referencing an unknown member ID: %s. Discarding.', data['user_id']) + def parse_voice_channel_effect_send(self, data: gw.VoiceChannelEffectSendEvent): + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + effect = VoiceChannelEffect(state=self, data=data, guild=guild) + self.dispatch('voice_channel_effect', effect) + else: + _log.debug('VOICE_CHANNEL_EFFECT_SEND referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_voice_server_update(self, data: gw.VoiceServerUpdateEvent) -> None: key_id = int(data['guild_id']) diff --git a/discord/types/channel.py b/discord/types/channel.py index 421232b45972..aac6ebeaaa74 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -28,6 +28,7 @@ from .user import PartialUser from .snowflake import Snowflake from .threads import ThreadMetadata, ThreadMember, ThreadArchiveDuration, ThreadType +from .emoji import PartialEmoji OverwriteType = Literal[0, 1] @@ -89,6 +90,18 @@ class VoiceChannel(_BaseTextChannel): video_quality_mode: NotRequired[VideoQualityMode] +class VoiceChannelEffect(TypedDict): + guild_id: Snowflake + channel_id: Snowflake + user_id: Snowflake + emoji: Optional[PartialEmoji] + animation_type: NotRequired[int] + animation_id: NotRequired[int] + sound_id: NotRequired[Union[int, str]] + sound_volume: NotRequired[float] + sound_override_path: NotRequired[Optional[str]] + + class CategoryChannel(_BaseGuildChannel): type: Literal[4] diff --git a/discord/types/emoji.py b/discord/types/emoji.py index d54690c14417..85e7097576ca 100644 --- a/discord/types/emoji.py +++ b/discord/types/emoji.py @@ -23,6 +23,7 @@ """ from typing import Optional, TypedDict +from typing_extensions import NotRequired from .snowflake import Snowflake, SnowflakeList from .user import User @@ -30,6 +31,7 @@ class PartialEmoji(TypedDict): id: Optional[Snowflake] name: Optional[str] + animated: NotRequired[bool] class Emoji(PartialEmoji, total=False): diff --git a/discord/types/gateway.py b/discord/types/gateway.py index a87b101f0f2b..6948c7726589 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -30,7 +30,7 @@ from .voice import GuildVoiceState from .integration import BaseIntegration, IntegrationApplication from .role import Role -from .channel import ChannelType, StageInstance +from .channel import ChannelType, StageInstance, VoiceChannelEffect from .interactions import Interaction from .invite import InviteTargetType from .emoji import Emoji, PartialEmoji @@ -311,6 +311,7 @@ class _GuildScheduledEventUsersEvent(TypedDict): GuildScheduledEventUserAdd = GuildScheduledEventUserRemove = _GuildScheduledEventUsersEvent VoiceStateUpdateEvent = GuildVoiceState +VoiceChannelEffectSendEvent = VoiceChannelEffect class VoiceServerUpdateEvent(TypedDict): diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py new file mode 100644 index 000000000000..c13ae984b5ea --- /dev/null +++ b/discord/types/soundboard.py @@ -0,0 +1,39 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from typing import TypedDict, Optional, Union +from .snowflake import Snowflake + + +class BaseSoundboardSound(TypedDict): + sound_id: Union[str, int] + volume: float + override_path: Optional[str] + + +class SoundboardSound(BaseSoundboardSound): + name: str + emoji_id: Optional[Snowflake] + emoji_name: str + user_id: Snowflake diff --git a/docs/api.rst b/docs/api.rst index 316dbda14c48..146cc79739ca 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1388,6 +1388,17 @@ Voice :param after: The voice state after the changes. :type after: :class:`VoiceState` +.. function:: on_voice_channel_effect(effect) + + Called when a :class:`Member` sends a :class:`VoiceChannelEffect` in a voice channel the bot is in. + + This requires :attr:`Intents.voice_states` to be enabled. + + .. versionadded:: 2.3 + + :param effect: The effect that is sent. + :type effect: :class:`VoiceChannelEffect` + .. _discord-api-utils: Utility Functions @@ -3325,6 +3336,21 @@ of :class:`enum.Enum`. Sort forum posts by creation time (from most recent to oldest). +.. class:: VoiceChannelEffectAnimationType + + Represents the animation type of a voice channel effect. + + .. versionadded:: 2.3 + + .. attribute:: premium + + A fun animation, sent by a Nitro subscriber. + + .. attribute:: normal + + The standard animation. + + .. _discord-api-audit-logs: Audit Log Data @@ -4398,6 +4424,35 @@ VoiceChannel :members: :inherited-members: +.. attributetable:: VoiceChannelEffect + +.. autoclass:: VoiceChannelEffect() + :members: + :inherited-members: + +.. class:: VoiceChannelEffectAnimation + + A namedtuple which represents a voice channel effect animation. + + .. versionadded:: 2.3 + + .. attribute:: id + + The ID of the animation. + + :type: :class:`int` + .. attribute:: type + + The type of the animation. + + :type: :class:`VoiceChannelEffectAnimationType` + +.. attributetable:: VoiceChannelSoundEffect + +.. autoclass:: VoiceChannelSoundEffect() + :members: + :inherited-members: + StageChannel ~~~~~~~~~~~~~ @@ -4564,6 +4619,14 @@ GuildSticker .. autoclass:: GuildSticker() :members: +DefaultSoundboardSound +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: DefaultSoundboardSound + +.. autoclass:: DefaultSoundboardSound() + :members: + ShardInfo ~~~~~~~~~~~