diff --git a/discord/__init__.py b/discord/__init__.py index 2cf64c93470d..780460dc51f6 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -70,6 +70,7 @@ from .threads import * from .automod import * from .poll import * +from .soundboard import * class VersionInfo(NamedTuple): diff --git a/discord/audit_logs.py b/discord/audit_logs.py index fc1bc298b602..59d563829257 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -235,6 +235,10 @@ def _transform_automod_actions(entry: AuditLogEntry, data: List[AutoModerationAc return [AutoModRuleAction.from_data(action) for action in data] +def _transform_default_emoji(entry: AuditLogEntry, data: str) -> PartialEmoji: + return PartialEmoji(name=data) + + E = TypeVar('E', bound=enums.Enum) @@ -341,6 +345,8 @@ class AuditLogChanges: 'available_tags': (None, _transform_forum_tags), 'flags': (None, _transform_overloaded_flags), 'default_reaction_emoji': (None, _transform_default_reaction), + 'emoji_name': ('emoji', _transform_default_emoji), + 'user_id': ('user', _transform_member_id) } # fmt: on diff --git a/discord/channel.py b/discord/channel.py index 55b25a03c4ca..37f0ab6fa718 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,8 @@ from .partial_emoji import _EmojiTag, PartialEmoji from .flags import ChannelFlags from .http import handle_message_parameters +from .object import Object +from .soundboard import BaseSoundboardSound, SoundboardDefaultSound __all__ = ( 'TextChannel', @@ -69,6 +80,8 @@ 'ForumChannel', 'GroupChannel', 'PartialMessageable', + 'VoiceChannelEffect', + 'VoiceChannelSoundEffect', ) if TYPE_CHECKING: @@ -76,7 +89,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 @@ -100,8 +112,11 @@ ForumChannel as ForumChannelPayload, MediaChannel as MediaChannelPayload, ForumTag as ForumTagPayload, + VoiceChannelEffect as VoiceChannelEffectPayload, ) from .types.snowflake import SnowflakeList + from .types.soundboard import BaseSoundboardSound as BaseSoundboardSoundPayload + from .soundboard import SoundboardSound OverwriteKeyT = TypeVar('OverwriteKeyT', Role, BaseUser, Object, Union[Role, Member, Object]) @@ -111,6 +126,121 @@ class ThreadWithMessage(NamedTuple): message: Message +class VoiceChannelEffectAnimation(NamedTuple): + id: int + type: VoiceChannelEffectAnimationType + + +class VoiceChannelSoundEffect(BaseSoundboardSound): + """Represents a Discord voice channel sound effect. + + .. versionadded:: 2.5 + + .. 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%). + """ + + __slots__ = ('_state',) + + def __init__(self, *, state: ConnectionState, id: int, volume: float): + data: BaseSoundboardSoundPayload = { + 'sound_id': id, + 'volume': volume, + } + super().__init__(state=state, data=data) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} id={self.id} volume={self.volume}>" + + @property + def created_at(self) -> Optional[datetime.datetime]: + """Optional[: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 utils.snowflake_time(self.id) + + def is_default(self) -> bool: + """:class:`bool`: Whether it's a default sound or not.""" + # if it's smaller than the Discord Epoch it cannot be a snowflake + return self.id < utils.DISCORD_EPOCH + + +class VoiceChannelEffect: + """Represents a Discord voice channel effect. + + .. versionadded:: 2.5 + + Attributes + ------------ + channel: :class:`VoiceChannel` + The channel in which the effect is sent. + user: Optional[:class:`Member`] + The user who sent the effect. ``None`` if not found in cache. + 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: Optional[Member] = guild.get_member(int(data['user_id'])) + 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.get('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.get('sound_volume') or 0.0 + self.sound = VoiceChannelSoundEffect(state=state, id=sound_id, volume=sound_volume) + + 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. @@ -1456,6 +1586,35 @@ async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optiona # the payload will always be the proper channel payload return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore + async def send_sound(self, sound: Union[SoundboardSound, SoundboardDefaultSound], /) -> None: + """|coro| + + Sends a soundboard sound for this channel. + + You must have :attr:`~Permissions.speak` and :attr:`~Permissions.use_soundboard` to do this. + Additionally, you must have :attr:`~Permissions.use_external_sounds` if the sound is from + a different guild. + + .. versionadded:: 2.5 + + Parameters + ----------- + sound: Union[:class:`SoundboardSound`, :class:`SoundboardDefaultSound`] + The sound to send for this channel. + + Raises + ------- + Forbidden + You do not have permissions to send a sound for this channel. + HTTPException + Sending the sound failed. + """ + payload = {'sound_id': sound.id} + if not isinstance(sound, SoundboardDefaultSound) and self.guild.id != sound.guild.id: + payload['source_guild_id'] = sound.guild.id + + await self._state.http.send_soundboard_sound(self.id, **payload) + class StageChannel(VocalGuildChannel): """Represents a Discord guild stage channel. diff --git a/discord/client.py b/discord/client.py index 2ca8c2ae0c6b..30c3a1c6f9c6 100644 --- a/discord/client.py +++ b/discord/client.py @@ -77,6 +77,7 @@ from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory +from .soundboard import SoundboardDefaultSound, SoundboardSound if TYPE_CHECKING: from types import TracebackType @@ -383,6 +384,14 @@ def stickers(self) -> Sequence[GuildSticker]: """ return self._connection.stickers + @property + def soundboard_sounds(self) -> List[SoundboardSound]: + """List[:class:`.SoundboardSound`]: The soundboard sounds that the connected client has. + + .. versionadded:: 2.5 + """ + return self._connection.soundboard_sounds + @property def cached_messages(self) -> Sequence[Message]: """Sequence[:class:`.Message`]: Read-only list of messages the connected client has cached. @@ -1109,6 +1118,23 @@ def get_sticker(self, id: int, /) -> Optional[GuildSticker]: """ return self._connection.get_sticker(id) + def get_soundboard_sound(self, id: int, /) -> Optional[SoundboardSound]: + """Returns a soundboard sound with the given ID. + + .. versionadded:: 2.5 + + Parameters + ---------- + id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`.SoundboardSound`] + The soundboard sound or ``None`` if not found. + """ + return self._connection.get_soundboard_sound(id) + def get_all_channels(self) -> Generator[GuildChannel, None, None]: """A generator that retrieves every :class:`.abc.GuildChannel` the client can 'access'. @@ -2964,6 +2990,26 @@ async def fetch_premium_sticker_pack(self, sticker_pack_id: int, /) -> StickerPa data = await self.http.get_sticker_pack(sticker_pack_id) return StickerPack(state=self._connection, data=data) + async def fetch_soundboard_default_sounds(self) -> List[SoundboardDefaultSound]: + """|coro| + + Retrieves all default soundboard sounds. + + .. versionadded:: 2.5 + + Raises + ------- + HTTPException + Retrieving the default soundboard sounds failed. + + Returns + --------- + List[:class:`.SoundboardDefaultSound`] + All default soundboard sounds. + """ + data = await self.http.get_soundboard_default_sounds() + return [SoundboardDefaultSound(state=self._connection, 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 eaf8aef5e058..9000c8c04576 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -74,6 +74,7 @@ 'EntitlementType', 'EntitlementOwnerType', 'PollLayoutType', + 'VoiceChannelEffectAnimationType', ) @@ -377,6 +378,9 @@ class AuditLogAction(Enum): thread_update = 111 thread_delete = 112 app_command_permission_update = 121 + soundboard_sound_create = 130 + soundboard_sound_update = 131 + soundboard_sound_delete = 132 automod_rule_create = 140 automod_rule_update = 141 automod_rule_delete = 142 @@ -447,6 +451,9 @@ def category(self) -> Optional[AuditLogActionCategory]: AuditLogAction.automod_timeout_member: None, AuditLogAction.creator_monetization_request_created: None, AuditLogAction.creator_monetization_terms_accepted: None, + AuditLogAction.soundboard_sound_create: AuditLogActionCategory.create, + AuditLogAction.soundboard_sound_update: AuditLogActionCategory.update, + AuditLogAction.soundboard_sound_delete: AuditLogActionCategory.delete, } # fmt: on return lookup[self] @@ -835,6 +842,11 @@ class ReactionType(Enum): burst = 1 +class VoiceChannelEffectAnimationType(Enum): + premium = 0 + basic = 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/flags.py b/discord/flags.py index 583f98c347eb..abe77f3c2670 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -871,34 +871,52 @@ def bans(self): @alias_flag_value def emojis(self): - """:class:`bool`: Alias of :attr:`.emojis_and_stickers`. + """:class:`bool`: Alias of :attr:`.expressions`. .. versionchanged:: 2.0 Changed to an alias. """ return 1 << 3 - @flag_value + @alias_flag_value def emojis_and_stickers(self): - """:class:`bool`: Whether guild emoji and sticker related events are enabled. + """:class:`bool`: Alias of :attr:`.expressions`. .. versionadded:: 2.0 + .. versionchanged:: 2.5 + Changed to an alias. + """ + return 1 << 3 + + @flag_value + def expressions(self): + """:class:`bool`: Whether guild emoji, sticker, and soundboard sound related events are enabled. + + .. versionadded:: 2.5 + This corresponds to the following events: - :func:`on_guild_emojis_update` - :func:`on_guild_stickers_update` + - :func:`on_soundboard_sound_create` + - :func:`on_soundboard_sound_update` + - :func:`on_soundboard_sound_delete` This also corresponds to the following attributes and classes in terms of cache: - :class:`Emoji` - :class:`GuildSticker` + - :class:`SoundboardSound` - :meth:`Client.get_emoji` - :meth:`Client.get_sticker` + - :meth:`Client.get_soundboard_sound` - :meth:`Client.emojis` - :meth:`Client.stickers` + - :meth:`Client.soundboard_sounds` - :attr:`Guild.emojis` - :attr:`Guild.stickers` + - :attr:`Guild.soundboard_sounds` """ return 1 << 3 diff --git a/discord/gateway.py b/discord/gateway.py index b8936bf5708b..e6fb7d8bfac5 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -295,19 +295,19 @@ class DiscordWebSocket: # fmt: off DEFAULT_GATEWAY = yarl.URL('wss://gateway.discord.gg/') - DISPATCH = 0 - HEARTBEAT = 1 - IDENTIFY = 2 - PRESENCE = 3 - VOICE_STATE = 4 - VOICE_PING = 5 - RESUME = 6 - RECONNECT = 7 - REQUEST_MEMBERS = 8 - INVALIDATE_SESSION = 9 - HELLO = 10 - HEARTBEAT_ACK = 11 - GUILD_SYNC = 12 + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + PRESENCE = 3 + VOICE_STATE = 4 + VOICE_PING = 5 + RESUME = 6 + RECONNECT = 7 + REQUEST_MEMBERS = 8 + INVALIDATE_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + GUILD_SYNC = 12 # fmt: on def __init__(self, socket: aiohttp.ClientWebSocketResponse, *, loop: asyncio.AbstractEventLoop) -> None: diff --git a/discord/guild.py b/discord/guild.py index 9bdcda129b11..5fd60a853fa6 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -94,6 +94,7 @@ from .welcome_screen import WelcomeScreen, WelcomeChannel from .automod import AutoModRule, AutoModTrigger, AutoModRuleAction from .partial_emoji import _EmojiTag, PartialEmoji +from .soundboard import SoundboardSound __all__ = ( @@ -328,6 +329,7 @@ class Guild(Hashable): '_safety_alerts_channel_id', 'max_stage_video_users', '_incidents_data', + '_soundboard_sounds', ) _PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = { @@ -345,6 +347,7 @@ def __init__(self, *, data: GuildPayload, state: ConnectionState) -> None: self._threads: Dict[int, Thread] = {} self._stage_instances: Dict[int, StageInstance] = {} self._scheduled_events: Dict[int, ScheduledEvent] = {} + self._soundboard_sounds: Dict[int, SoundboardSound] = {} self._state: ConnectionState = state self._member_count: Optional[int] = None self._from_data(data) @@ -390,6 +393,12 @@ def _filter_threads(self, channel_ids: Set[int]) -> Dict[int, Thread]: del self._threads[k] return to_remove + def _add_soundboard_sound(self, sound: SoundboardSound, /) -> None: + self._soundboard_sounds[sound.id] = sound + + def _remove_soundboard_sound(self, sound: SoundboardSound, /) -> None: + self._soundboard_sounds.pop(sound.id, None) + def __str__(self) -> str: return self.name or '' @@ -547,6 +556,11 @@ def _from_data(self, guild: GuildPayload) -> None: scheduled_event = ScheduledEvent(data=s, state=self._state) self._scheduled_events[scheduled_event.id] = scheduled_event + if 'soundboard_sounds' in guild: + for s in guild['soundboard_sounds']: + soundboard_sound = SoundboardSound(guild=self, data=s, state=self._state) + self._add_soundboard_sound(soundboard_sound) + @property def channels(self) -> Sequence[GuildChannel]: """Sequence[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild.""" @@ -996,6 +1010,37 @@ def get_scheduled_event(self, scheduled_event_id: int, /) -> Optional[ScheduledE """ return self._scheduled_events.get(scheduled_event_id) + @property + def soundboard_sounds(self) -> Sequence[SoundboardSound]: + """Sequence[:class:`SoundboardSound`]: Returns a sequence of the guild's soundboard sounds. + + .. versionadded:: 2.5 + """ + return utils.SequenceProxy(self._soundboard_sounds.values()) + + def get_soundboard_sound(self, sound_id: int, /) -> Optional[SoundboardSound]: + """Returns a soundboard sound with the given ID. + + .. versionadded:: 2.5 + + Parameters + ----------- + sound_id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`SoundboardSound`] + The soundboard sound or ``None`` if not found. + """ + return self._soundboard_sounds.get(sound_id) + + def _resolve_soundboard_sound(self, id: Optional[int], /) -> Optional[SoundboardSound]: + if id is None: + return + + return self._soundboard_sounds.get(id) + @property def owner(self) -> Optional[Member]: """Optional[:class:`Member`]: The member that owns the guild.""" @@ -4496,3 +4541,130 @@ def is_raid_detected(self) -> bool: return False return self.raid_detected_at > utils.utcnow() + + async def fetch_soundboard_sound(self, sound_id: int, /) -> SoundboardSound: + """|coro| + + Retrieves a :class:`SoundboardSound` with the specified ID. + + .. versionadded:: 2.5 + + .. note:: + + Using this, in order to receive :attr:`SoundboardSound.user`, you must have :attr:`~Permissions.create_expressions` + or :attr:`~Permissions.manage_expressions`. + + .. note:: + + This method is an API call. For general usage, consider :attr:`get_soundboard_sound` instead. + + Raises + ------- + NotFound + The sound requested could not be found. + HTTPException + Retrieving the sound failed. + + Returns + -------- + :class:`SoundboardSound` + The retrieved sound. + """ + data = await self._state.http.get_soundboard_sound(self.id, sound_id) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def fetch_soundboard_sounds(self) -> List[SoundboardSound]: + """|coro| + + Retrieves a list of all soundboard sounds for the guild. + + .. versionadded:: 2.5 + + .. note:: + + Using this, in order to receive :attr:`SoundboardSound.user`, you must have :attr:`~Permissions.create_expressions` + or :attr:`~Permissions.manage_expressions`. + + .. note:: + + This method is an API call. For general usage, consider :attr:`soundboard_sounds` instead. + + Raises + ------- + HTTPException + Retrieving the sounds failed. + + Returns + -------- + List[:class:`SoundboardSound`] + The retrieved soundboard sounds. + """ + data = await self._state.http.get_soundboard_sounds(self.id) + return [SoundboardSound(guild=self, state=self._state, data=sound) for sound in data['items']] + + async def create_soundboard_sound( + self, + *, + name: str, + sound: bytes, + volume: float = 1, + emoji: Optional[EmojiInputType] = None, + reason: Optional[str] = None, + ) -> SoundboardSound: + """|coro| + + Creates a :class:`SoundboardSound` for the guild. + You must have :attr:`Permissions.create_expressions` to do this. + + .. versionadded:: 2.5 + + Parameters + ---------- + name: :class:`str` + The name of the sound. Must be between 2 and 32 characters. + sound: :class:`bytes` + The :term:`py:bytes-like object` representing the sound data. + Only MP3 and OGG sound files that don't exceed the duration of 5.2s are supported. + volume: :class:`float` + The volume of the sound. Must be between 0 and 1. Defaults to ``1``. + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]] + The emoji of the sound. + reason: Optional[:class:`str`] + The reason for creating the sound. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to create a soundboard sound. + HTTPException + Creating the soundboard sound failed. + + Returns + ------- + :class:`SoundboardSound` + The newly created soundboard sound. + """ + payload: Dict[str, Any] = { + 'name': name, + 'sound': utils._bytes_to_base64_data(sound, audio=True), + 'volume': volume, + 'emoji_id': None, + 'emoji_name': None, + } + + if emoji is not None: + if isinstance(emoji, _EmojiTag): + partial_emoji = emoji._to_partial() + elif isinstance(emoji, str): + partial_emoji = PartialEmoji.from_str(emoji) + else: + partial_emoji = None + + if partial_emoji is not None: + if partial_emoji.id is None: + payload['emoji_name'] = partial_emoji.name + else: + payload['emoji_id'] = partial_emoji.id + + data = await self._state.http.create_soundboard_sound(self.id, reason=reason, **payload) + return SoundboardSound(guild=self, state=self._state, data=data) diff --git a/discord/http.py b/discord/http.py index 6230f9b1da16..24605c4fcd9c 100644 --- a/discord/http.py +++ b/discord/http.py @@ -93,6 +93,7 @@ sku, poll, voice, + soundboard, ) from .types.snowflake import Snowflake, SnowflakeList @@ -2515,6 +2516,78 @@ def delete_entitlement(self, application_id: Snowflake, entitlement_id: Snowflak ), ) + # Soundboard + + def get_soundboard_default_sounds(self) -> Response[List[soundboard.SoundboardDefaultSound]]: + return self.request(Route('GET', '/soundboard-default-sounds')) + + def get_soundboard_sound(self, guild_id: Snowflake, sound_id: Snowflake) -> Response[soundboard.SoundboardSound]: + return self.request( + Route('GET', '/guilds/{guild_id}/soundboard-sounds/{sound_id}', guild_id=guild_id, sound_id=sound_id) + ) + + def get_soundboard_sounds(self, guild_id: Snowflake) -> Response[Dict[str, List[soundboard.SoundboardSound]]]: + return self.request(Route('GET', '/guilds/{guild_id}/soundboard-sounds', guild_id=guild_id)) + + def create_soundboard_sound( + self, guild_id: Snowflake, *, reason: Optional[str], **payload: Any + ) -> Response[soundboard.SoundboardSound]: + valid_keys = ( + 'name', + 'sound', + 'volume', + 'emoji_id', + 'emoji_name', + ) + + payload = {k: v for k, v in payload.items() if k in valid_keys and v is not None} + + return self.request( + Route('POST', '/guilds/{guild_id}/soundboard-sounds', guild_id=guild_id), json=payload, reason=reason + ) + + def edit_soundboard_sound( + self, guild_id: Snowflake, sound_id: Snowflake, *, reason: Optional[str], **payload: Any + ) -> Response[soundboard.SoundboardSound]: + valid_keys = ( + 'name', + 'volume', + 'emoji_id', + 'emoji_name', + ) + + payload = {k: v for k, v in payload.items() if k in valid_keys} + + return self.request( + Route( + 'PATCH', + '/guilds/{guild_id}/soundboard-sounds/{sound_id}', + guild_id=guild_id, + sound_id=sound_id, + ), + json=payload, + reason=reason, + ) + + def delete_soundboard_sound(self, guild_id: Snowflake, sound_id: Snowflake, *, reason: Optional[str]) -> Response[None]: + return self.request( + Route( + 'DELETE', + '/guilds/{guild_id}/soundboard-sounds/{sound_id}', + guild_id=guild_id, + sound_id=sound_id, + ), + reason=reason, + ) + + def send_soundboard_sound(self, channel_id: Snowflake, **payload: Any) -> Response[None]: + valid_keys = ('sound_id', 'source_guild_id') + payload = {k: v for k, v in payload.items() if k in valid_keys} + print(payload) + return self.request( + (Route('POST', '/channels/{channel_id}/send-soundboard-sound', channel_id=channel_id)), json=payload + ) + # Application def application_info(self) -> Response[appinfo.AppInfo]: diff --git a/discord/soundboard.py b/discord/soundboard.py new file mode 100644 index 000000000000..3351aacb78ff --- /dev/null +++ b/discord/soundboard.py @@ -0,0 +1,325 @@ +""" +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 . import utils +from .mixins import Hashable +from .partial_emoji import PartialEmoji, _EmojiTag +from .user import User +from .utils import MISSING +from .asset import Asset, AssetMixin + +if TYPE_CHECKING: + import datetime + from typing import Dict, Any + + from .types.soundboard import ( + BaseSoundboardSound as BaseSoundboardSoundPayload, + SoundboardDefaultSound as SoundboardDefaultSoundPayload, + SoundboardSound as SoundboardSoundPayload, + ) + from .state import ConnectionState + from .guild import Guild + from .message import EmojiInputType + +__all__ = ('BaseSoundboardSound', 'SoundboardDefaultSound', 'SoundboardSound') + + +class BaseSoundboardSound(Hashable, AssetMixin): + """Represents a generic Discord soundboard sound. + + .. versionadded:: 2.5 + + .. 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%). + """ + + __slots__ = ('_state', 'id', 'volume') + + def __init__(self, *, state: ConnectionState, data: BaseSoundboardSoundPayload): + self._state: ConnectionState = state + self.id: int = int(data['sound_id']) + self._update(data) + + def __eq__(self, other: object) -> bool: + if isinstance(other, self.__class__): + return self.id == other.id + return NotImplemented + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + def _update(self, data: BaseSoundboardSoundPayload): + self.volume: float = data['volume'] + + @property + def url(self) -> str: + """:class:`str`: Returns the URL of the sound.""" + return f'{Asset.BASE}/soundboard-sounds/{self.id}' + + +class SoundboardDefaultSound(BaseSoundboardSound): + """Represents a Discord soundboard default sound. + + .. versionadded:: 2.5 + + .. 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%). + name: :class:`str` + The name of the sound. + emoji: :class:`PartialEmoji` + The emoji of the sound. + """ + + __slots__ = ('name', 'emoji') + + def __init__(self, *, state: ConnectionState, data: SoundboardDefaultSoundPayload): + self.name: str = data['name'] + self.emoji: PartialEmoji = PartialEmoji(name=data['emoji_name']) + super().__init__(state=state, 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}>" + + +class SoundboardSound(BaseSoundboardSound): + """Represents a Discord soundboard sound. + + .. versionadded:: 2.5 + + .. 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%). + name: :class:`str` + The name of the sound. + emoji: Optional[:class:`PartialEmoji`] + The emoji of the sound. ``None`` if no emoji is set. + guild: :class:`Guild` + The guild in which the sound is uploaded. + available: :class:`bool` + Whether this sound is available for use. + """ + + __slots__ = ('_state', 'name', 'emoji', '_user', 'available', '_user_id', 'guild') + + def __init__(self, *, guild: Guild, state: ConnectionState, data: SoundboardSoundPayload): + super().__init__(state=state, data=data) + self.guild = guild + self._user_id = utils._get_as_snowflake(data, 'user_id') + self._user = data.get('user') + + self._update(data) + + def __repr__(self) -> str: + attrs = [ + ('id', self.id), + ('name', self.name), + ('volume', self.volume), + ('emoji', self.emoji), + ('user', self.user), + ] + inner = ' '.join('%s=%r' % t for t in attrs) + return f"<{self.__class__.__name__} {inner}>" + + def _update(self, data: SoundboardSoundPayload): + super()._update(data) + + self.name: str = data['name'] + self.emoji: Optional[PartialEmoji] = None + + emoji_id = utils._get_as_snowflake(data, 'emoji_id') + emoji_name = data['emoji_name'] + if emoji_id is not None or emoji_name is not None: + self.emoji = PartialEmoji(id=emoji_id, name=emoji_name) # type: ignore # emoji_name cannot be None here + + self.available: bool = data['available'] + + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the snowflake's creation time in UTC.""" + return utils.snowflake_time(self.id) + + @property + def user(self) -> Optional[User]: + """Optional[:class:`User`]: The user who uploaded the sound.""" + if self._user is None: + if self._user_id is None: + return None + return self._state.get_user(self._user_id) + return User(state=self._state, data=self._user) + + async def edit( + self, + *, + name: str = MISSING, + volume: Optional[float] = MISSING, + emoji: Optional[EmojiInputType] = MISSING, + reason: Optional[str] = None, + ): + """|coro| + + Edits the soundboard sound. + + You must have :attr:`~Permissions.manage_expressions` to edit the sound. + If the sound was created by the client, you must have either :attr:`~Permissions.manage_expressions` + or :attr:`~Permissions.create_expressions`. + + Parameters + ---------- + name: :class:`str` + The new name of the sound. Must be between 2 and 32 characters. + volume: Optional[:class:`float`] + The new volume of the sound. Must be between 0 and 1. + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]] + The new emoji of the sound. + reason: Optional[:class:`str`] + The reason for editing this sound. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to edit the soundboard sound. + HTTPException + Editing the soundboard sound failed. + + Returns + ------- + :class:`SoundboardSound` + The newly updated soundboard sound. + """ + + payload: Dict[str, Any] = {} + + if name is not MISSING: + payload['name'] = name + + if volume is not MISSING: + payload['volume'] = volume + + if emoji is not MISSING: + if emoji is None: + payload['emoji_id'] = None + payload['emoji_name'] = None + else: + if isinstance(emoji, _EmojiTag): + partial_emoji = emoji._to_partial() + elif isinstance(emoji, str): + partial_emoji = PartialEmoji.from_str(emoji) + else: + partial_emoji = None + + if partial_emoji is not None: + if partial_emoji.id is None: + payload['emoji_name'] = partial_emoji.name + else: + payload['emoji_id'] = partial_emoji.id + + data = await self._state.http.edit_soundboard_sound(self.guild.id, self.id, reason=reason, **payload) + return SoundboardSound(guild=self.guild, state=self._state, data=data) + + async def delete(self, *, reason: Optional[str] = None) -> None: + """|coro| + + Deletes the soundboard sound. + + You must have :attr:`~Permissions.manage_expressions` to delete the sound. + If the sound was created by the client, you must have either :attr:`~Permissions.manage_expressions` + or :attr:`~Permissions.create_expressions`. + + Parameters + ----------- + reason: Optional[:class:`str`] + The reason for deleting this sound. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to delete the soundboard sound. + HTTPException + Deleting the soundboard sound failed. + """ + await self._state.http.delete_soundboard_sound(self.guild.id, self.id, reason=reason) diff --git a/discord/state.py b/discord/state.py index 6279f14bf952..91fd915bf6bc 100644 --- a/discord/state.py +++ b/discord/state.py @@ -78,6 +78,7 @@ from .automod import AutoModRule, AutoModAction from .audit_logs import AuditLogEntry from ._types import ClientT +from .soundboard import SoundboardSound if TYPE_CHECKING: from .abc import PrivateChannel @@ -455,6 +456,14 @@ def emojis(self) -> Sequence[Emoji]: def stickers(self) -> Sequence[GuildSticker]: return utils.SequenceProxy(self._stickers.values()) + @property + def soundboard_sounds(self) -> List[SoundboardSound]: + all_sounds = [] + for guild in self.guilds: + all_sounds.extend(guild.soundboard_sounds) + + return all_sounds + def get_emoji(self, emoji_id: Optional[int]) -> Optional[Emoji]: # the keys of self._emojis are ints return self._emojis.get(emoji_id) # type: ignore @@ -1555,6 +1564,62 @@ def parse_guild_scheduled_event_user_remove(self, data: gw.GuildScheduledEventUs else: _log.debug('SCHEDULED_EVENT_USER_REMOVE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_guild_soundboard_sound_create(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: + guild_id = int(data['guild_id']) # type: ignore # can't be None here + guild = self._get_guild(guild_id) + if guild is not None: + sound = SoundboardSound(guild=guild, state=self, data=data) + guild._add_soundboard_sound(sound) + self.dispatch('soundboard_sound_create', sound) + else: + _log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing unknown guild ID: %s. Discarding.', guild_id) + + def _update_and_dispatch_sound_update(self, sound: SoundboardSound, data: gw.GuildSoundBoardSoundUpdateEvent): + old_sound = copy.copy(sound) + sound._update(data) + self.dispatch('soundboard_sound_update', old_sound, sound) + + def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundUpdateEvent) -> None: + guild_id = int(data['guild_id']) # type: ignore # can't be None here + guild = self._get_guild(guild_id) + if guild is not None: + sound_id = int(data['sound_id']) + sound = guild.get_soundboard_sound(sound_id) + if sound is not None: + self._update_and_dispatch_sound_update(sound, data) + else: + _log.warning('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown sound ID: %s. Discarding.', sound_id) + else: + _log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown guild ID: %s. Discarding.', guild_id) + + def parse_guild_soundboard_sound_delete(self, data: gw.GuildSoundBoardSoundDeleteEvent) -> None: + guild_id = int(data['guild_id']) + guild = self._get_guild(guild_id) + if guild is not None: + sound_id = int(data['sound_id']) + sound = guild.get_soundboard_sound(sound_id) + if sound is not None: + guild._remove_soundboard_sound(sound) + self.dispatch('soundboard_sound_delete', sound) + else: + _log.warning('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown sound ID: %s. Discarding.', sound_id) + else: + _log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown guild ID: %s. Discarding.', guild_id) + + def parse_guild_soundboard_sounds_update(self, data: gw.GuildSoundBoardSoundsUpdateEvent) -> None: + for raw_sound in data: + guild_id = int(raw_sound['guild_id']) # type: ignore # can't be None here + guild = self._get_guild(guild_id) + if guild is not None: + sound_id = int(raw_sound['sound_id']) + sound = guild.get_soundboard_sound(sound_id) + if sound is not None: + self._update_and_dispatch_sound_update(sound, raw_sound) + else: + _log.warning('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing unknown sound ID: %s. Discarding.', sound_id) + else: + _log.debug('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing unknown guild ID: %s. Discarding.', guild_id) + def parse_application_command_permissions_update(self, data: GuildApplicationCommandPermissionsPayload): raw = RawAppCommandPermissionsUpdateEvent(data=data, state=self) self.dispatch('raw_app_command_permissions_update', raw) @@ -1585,6 +1650,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']) @@ -1707,6 +1780,15 @@ def get_channel(self, id: Optional[int]) -> Optional[Union[Channel, Thread]]: def create_message(self, *, channel: MessageableChannel, data: MessagePayload) -> Message: return Message(state=self, channel=channel, data=data) + def get_soundboard_sound(self, id: Optional[int]) -> Optional[SoundboardSound]: + if id is None: + return + + for guild in self.guilds: + sound = guild._resolve_soundboard_sound(id) + if sound is not None: + return sound + class AutoShardedConnectionState(ConnectionState[ClientT]): def __init__(self, *args: Any, **kwargs: Any) -> None: diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index cd949709a479..2c37542fddc7 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -88,6 +88,9 @@ 111, 112, 121, + 130, + 131, + 132, 140, 141, 142, @@ -112,6 +115,7 @@ class _AuditLogChange_Str(TypedDict): 'permissions', 'tags', 'unicode_emoji', + 'emoji_name', ] new_value: str old_value: str @@ -136,6 +140,8 @@ class _AuditLogChange_Snowflake(TypedDict): 'channel_id', 'inviter_id', 'guild_id', + 'user_id', + 'sound_id', ] new_value: Snowflake old_value: Snowflake @@ -183,6 +189,12 @@ class _AuditLogChange_Int(TypedDict): old_value: int +class _AuditLogChange_Float(TypedDict): + key: Literal['volume'] + new_value: float + old_value: float + + class _AuditLogChange_ListRole(TypedDict): key: Literal['$add', '$remove'] new_value: List[Role] @@ -290,6 +302,7 @@ class _AuditLogChange_TriggerMetadata(TypedDict): _AuditLogChange_AssetHash, _AuditLogChange_Snowflake, _AuditLogChange_Int, + _AuditLogChange_Float, _AuditLogChange_Bool, _AuditLogChange_ListRole, _AuditLogChange_MFALevel, diff --git a/discord/types/channel.py b/discord/types/channel.py index d5d82b5c6461..4b593e55426a 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,20 @@ class VoiceChannel(_BaseTextChannel): video_quality_mode: NotRequired[VideoQualityMode] +VoiceChannelEffectAnimationType = Literal[0, 1] + + +class VoiceChannelEffect(TypedDict): + guild_id: Snowflake + channel_id: Snowflake + user_id: Snowflake + emoji: NotRequired[Optional[PartialEmoji]] + animation_type: NotRequired[VoiceChannelEffectAnimationType] + animation_id: NotRequired[int] + sound_id: NotRequired[Union[int, str]] + sound_volume: NotRequired[float] + + 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 ff43a5f25e70..974ceb20460b 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -31,7 +31,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 @@ -45,6 +45,7 @@ from .threads import Thread, ThreadMember from .scheduled_event import GuildScheduledEvent from .audit_log import AuditLogEntry +from .soundboard import SoundboardSound class SessionStartLimit(TypedDict): @@ -319,6 +320,15 @@ class _GuildScheduledEventUsersEvent(TypedDict): GuildScheduledEventUserAdd = GuildScheduledEventUserRemove = _GuildScheduledEventUsersEvent VoiceStateUpdateEvent = GuildVoiceState +VoiceChannelEffectSendEvent = VoiceChannelEffect + +GuildSoundBoardSoundCreateEvent = GuildSoundBoardSoundUpdateEvent = SoundboardSound +GuildSoundBoardSoundsUpdateEvent = List[SoundboardSound] + + +class GuildSoundBoardSoundDeleteEvent(TypedDict): + sound_id: Snowflake + guild_id: Snowflake class VoiceServerUpdateEvent(TypedDict): diff --git a/discord/types/guild.py b/discord/types/guild.py index ba43fbf96c14..e0a1f3e54438 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -37,6 +37,7 @@ from .emoji import Emoji from .user import User from .threads import Thread +from .soundboard import SoundboardSound class Ban(TypedDict): @@ -90,6 +91,8 @@ class IncidentData(TypedDict): 'VIP_REGIONS', 'WELCOME_SCREEN_ENABLED', 'RAID_ALERTS_DISABLED', + 'SOUNDBOARD', + 'MORE_SOUNDBOARD', ] @@ -154,6 +157,7 @@ class Guild(_BaseGuildPreview): max_members: NotRequired[int] premium_subscription_count: NotRequired[int] max_video_channel_users: NotRequired[int] + soundboard_sounds: NotRequired[List[SoundboardSound]] class InviteGuild(Guild, total=False): diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py new file mode 100644 index 000000000000..4910df8082f5 --- /dev/null +++ b/discord/types/soundboard.py @@ -0,0 +1,49 @@ +""" +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 typing_extensions import NotRequired + +from .snowflake import Snowflake +from .user import User + + +class BaseSoundboardSound(TypedDict): + sound_id: Union[Snowflake, str] # basic string number when it's a default sound + volume: float + + +class SoundboardSound(BaseSoundboardSound): + name: str + emoji_name: Optional[str] + emoji_id: Optional[Snowflake] + user_id: NotRequired[Snowflake] + available: bool + guild_id: NotRequired[Snowflake] + user: NotRequired[User] + + +class SoundboardDefaultSound(BaseSoundboardSound): + name: str + emoji_name: str diff --git a/discord/utils.py b/discord/utils.py index 89cc8bdebfce..ee4097c467a6 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -623,9 +623,19 @@ def _get_mime_type_for_image(data: bytes): raise ValueError('Unsupported image type given') -def _bytes_to_base64_data(data: bytes) -> str: +def _get_mime_type_for_audio(data: bytes): + if data.startswith(b'\x49\x44\x33') or data.startswith(b'\xff\xfb'): + return 'audio/mpeg' + else: + raise ValueError('Unsupported audio type given') + + +def _bytes_to_base64_data(data: bytes, *, audio: bool = False) -> str: fmt = 'data:{mime};base64,{data}' - mime = _get_mime_type_for_image(data) + if audio: + mime = _get_mime_type_for_audio(data) + else: + mime = _get_mime_type_for_image(data) b64 = b64encode(data).decode('ascii') return fmt.format(mime=mime, data=b64) diff --git a/docs/api.rst b/docs/api.rst index e415ea8ceb07..c4feaa246501 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1298,6 +1298,35 @@ Scheduled Events :type user: :class:`User` +Soundboard +~~~~~~~~~~~ + +.. function:: on_soundboard_sound_create(sound) + on_soundboard_sound_delete(sound) + + Called when a :class:`SoundboardSound` is created or deleted. + + .. versionadded:: 2.5 + + :param sound: The soundboard sound that was created or deleted. + :type sound: :class:`SoundboardSound` + +.. function:: on_soundboard_sound_update(before, after) + + Called when a :class:`SoundboardSound` is updated. + + The following examples illustrate when this event is called: + + - The name is changed. + - The emoji is changed. + - The volume is changed. + + .. versionadded:: 2.5 + + :param sound: The soundboard sound that was updated. + :type sound: :class:`SoundboardSound` + + Stages ~~~~~~~ @@ -1483,6 +1512,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.5 + + :param effect: The effect that is sent. + :type effect: :class:`VoiceChannelEffect` + .. _discord-api-utils: Utility Functions @@ -2945,6 +2985,42 @@ of :class:`enum.Enum`. .. versionadded:: 2.4 + .. attribute:: soundboard_sound_create + + A soundboard sound was created. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.emoji` + - :attr:`~AuditLogDiff.volume` + + .. versionadded:: 2.5 + + .. attribute:: soundboard_sound_update + + A soundboard sound was updated. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.emoji` + - :attr:`~AuditLogDiff.volume` + + .. versionadded:: 2.5 + + .. attribute:: soundboard_sound_delete + + A soundboard sound was deleted. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.emoji` + - :attr:`~AuditLogDiff.volume` + + .. versionadded:: 2.5 + .. class:: AuditLogActionCategory Represents the category that the :class:`AuditLogAction` belongs to. @@ -3663,6 +3739,21 @@ of :class:`enum.Enum`. A burst reaction, also known as a "super reaction". +.. class:: VoiceChannelEffectAnimationType + + Represents the animation type of a voice channel effect. + + .. versionadded:: 2.5 + + .. attribute:: premium + + A fun animation, sent by a Nitro subscriber. + + .. attribute:: basic + + The standard animation. + + .. _discord-api-audit-logs: Audit Log Data @@ -4128,11 +4219,12 @@ AuditLogDiff .. attribute:: emoji - The name of the emoji that represents a sticker being changed. + The emoji which represents one of the following: - See also :attr:`GuildSticker.emoji`. + * :attr:`GuildSticker.emoji` + * :attr:`SoundboardSound.emoji` - :type: :class:`str` + :type: Union[:class:`str`, :class:`PartialEmoji`] .. attribute:: unicode_emoji @@ -4153,9 +4245,10 @@ AuditLogDiff .. attribute:: available - The availability of a sticker being changed. + The availability of one of the following being changed: - See also :attr:`GuildSticker.available` + * :attr:`GuildSticker.available` + * :attr:`SoundboardSound.available` :type: :class:`bool` @@ -4378,6 +4471,22 @@ AuditLogDiff :type: Optional[:class:`PartialEmoji`] + .. attribute:: user + + The user that represents the uploader of a soundboard sound. + + See also :attr:`SoundboardSound.user` + + :type: Union[:class:`Member`, :class:`User`] + + .. attribute:: volume + + The volume of a soundboard sound. + + See also :attr:`SoundboardSound.volume` + + :type: :class:`float` + .. this is currently missing the following keys: reason and application_id I'm not sure how to port these @@ -4799,6 +4908,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.5 + + .. 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 ~~~~~~~~~~~~~ @@ -4965,6 +5103,30 @@ GuildSticker .. autoclass:: GuildSticker() :members: +BaseSoundboardSound +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: BaseSoundboardSound + +.. autoclass:: BaseSoundboardSound() + :members: + +SoundboardDefaultSound +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: SoundboardDefaultSound + +.. autoclass:: SoundboardDefaultSound() + :members: + +SoundboardSound +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: SoundboardSound + +.. autoclass:: SoundboardSound() + :members: + ShardInfo ~~~~~~~~~~~