diff --git a/CHANGELOG.md b/CHANGELOG.md index 38725e4141..1bb80e0ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,15 @@ These changes are available on the `master` branch, but have not yet been releas ([#2659](https://github.com/Pycord-Development/pycord/pull/2659)) - Added `VoiceMessage` subclass of `File` to allow voice messages to be sent. ([#2579](https://github.com/Pycord-Development/pycord/pull/2579)) +- Added the following soundboard-related features: + - Manage guild soundboard sounds with `Guild.fetch_sounds()`, `Guild.create_sound()`, + `SoundboardSound.edit()`, and `SoundboardSound.delete()`. + - Access Discord default sounds with `Client.fetch_default_sounds()`. + - Play sounds in voice channels with `VoiceChannel.send_soundboard_sound()`. + - New `on_voice_channel_effect_send` event for sound and emoji effects. + - Soundboard limits based on guild premium tier (8-48 slots) in + `Guild.soundboard_limit`. + ([#2623](https://github.com/Pycord-Development/pycord/pull/2623)) - Added new `Subscription` object and related methods/events. ([#2564](https://github.com/Pycord-Development/pycord/pull/2564)) - Added the ability to change the API's base URL with `Route.API_BASE_URL`. diff --git a/discord/__init__.py b/discord/__init__.py index d6031ce3ac..3baa66e48f 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -64,6 +64,7 @@ from .role import * from .scheduled_events import * from .shard import * +from .soundboard import * from .stage_instance import * from .sticker import * from .team import * diff --git a/discord/asset.py b/discord/asset.py index 07c7ca8e7b..4ff5663426 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -300,6 +300,14 @@ def _from_scheduled_event_image( animated=False, ) + @classmethod + def _from_soundboard_sound(cls, state, sound_id: int) -> Asset: + return cls( + state, + url=f"{cls.BASE}/soundboard-sounds/{sound_id}", + key=str(sound_id), + ) + def __str__(self) -> str: return self._url diff --git a/discord/channel.py b/discord/channel.py index 687baa5e5a..b035c3ff45 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -26,7 +26,16 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, TypeVar, overload +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Iterable, + Mapping, + NamedTuple, + TypeVar, + overload, +) import discord.abc @@ -40,6 +49,7 @@ SortOrder, StagePrivacyLevel, VideoQualityMode, + VoiceChannelEffectAnimationType, VoiceRegion, try_enum, ) @@ -52,6 +62,7 @@ from .object import Object from .partial_emoji import PartialEmoji, _EmojiTag from .permissions import PermissionOverwrite, Permissions +from .soundboard import PartialSoundboardSound, SoundboardSound from .stage_instance import StageInstance from .threads import Thread from .utils import MISSING @@ -67,6 +78,7 @@ "ForumChannel", "MediaChannel", "ForumTag", + "VoiceChannelEffectSendEvent", ) if TYPE_CHECKING: @@ -85,6 +97,7 @@ from .types.channel import StageChannel as StageChannelPayload from .types.channel import TextChannel as TextChannelPayload from .types.channel import VoiceChannel as VoiceChannelPayload + from .types.channel import VoiceChannelEffectSendEvent as VoiceChannelEffectSend from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration from .user import BaseUser, ClientUser, User @@ -2193,6 +2206,25 @@ async def set_status( """ await self._state.http.set_voice_channel_status(self.id, status, reason=reason) + async def send_soundboard_sound(self, sound: PartialSoundboardSound) -> None: + """|coro| + + Sends a soundboard sound to the voice channel. + + Parameters + ---------- + sound: :class:`PartialSoundboardSound` + The soundboard sound to send. + + Raises + ------ + Forbidden + You do not have proper permissions to send the soundboard sound. + HTTPException + Sending the soundboard sound failed. + """ + await self._state.http.send_soundboard_sound(self.id, sound) + class StageChannel(discord.abc.Messageable, VocalGuildChannel): """Represents a Discord guild stage channel. @@ -3413,6 +3445,84 @@ def get_partial_message(self, message_id: int, /) -> PartialMessage: return PartialMessage(channel=self, id=message_id) +class VoiceChannelEffectAnimation(NamedTuple): + """Represents an animation that can be sent to a voice channel. + + .. versionadded:: 2.7 + """ + + id: int + type: VoiceChannelEffectAnimationType + + +class VoiceChannelSoundEffect(PartialSoundboardSound): ... + + +class VoiceChannelEffectSendEvent: + """Represents the payload for an :func:`on_voice_channel_effect_send`. + + .. versionadded:: 2.7 + + Attributes + ---------- + animation_type: :class:`int` + The type of animation that is being sent. + animation_id: :class:`int` + The ID of the animation that is being sent. + sound: Optional[:class:`SoundboardSound`] + The sound that is being sent, could be ``None`` if the effect is not a sound effect. + guild: :class:`Guild` + The guild in which the sound is being sent. + user: :class:`Member` + The member that sent the sound. + channel: :class:`VoiceChannel` + The voice channel in which the sound is being sent. + data: :class:`dict` + The raw data sent by the gateway. + """ + + __slots__ = ( + "_state", + "animation_type", + "animation_id", + "sound", + "guild", + "user", + "channel", + "data", + "emoji", + ) + + def __init__( + self, + data: VoiceChannelEffectSend, + state: ConnectionState, + sound: SoundboardSound | PartialSoundboardSound | None = None, + ) -> None: + self._state = state + channel_id = int(data["channel_id"]) + user_id = int(data["user_id"]) + guild_id = int(data["guild_id"]) + self.animation_type: VoiceChannelEffectAnimationType = try_enum( + VoiceChannelEffectAnimationType, data["animation_type"] + ) + self.animation_id = int(data["animation_id"]) + self.sound = sound + self.guild = state._get_guild(guild_id) + self.user = self.guild.get_member(user_id) + self.channel = self.guild.get_channel(channel_id) + self.emoji = ( + PartialEmoji( + name=data["emoji"]["name"], + animated=data["emoji"]["animated"], + id=data["emoji"]["id"], + ) + if data.get("emoji", None) + else None + ) + self.data = data + + def _guild_channel_factory(channel_type: int): value = try_enum(ChannelType, channel_type) if value is ChannelType.text: diff --git a/discord/client.py b/discord/client.py index 6768d4a660..2411486ae2 100644 --- a/discord/client.py +++ b/discord/client.py @@ -53,6 +53,7 @@ from .mentions import AllowedMentions from .monetization import SKU, Entitlement from .object import Object +from .soundboard import SoundboardSound from .stage_instance import StageInstance from .state import ConnectionState from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory @@ -71,6 +72,7 @@ from .member import Member from .message import Message from .poll import Poll + from .soundboard import SoundboardSound from .voice_client import VoiceProtocol __all__ = ("Client",) @@ -2278,3 +2280,46 @@ async def delete_emoji(self, emoji: Snowflake) -> None: ) if self._connection.cache_app_emojis and self._connection.get_emoji(emoji.id): self._connection.remove_emoji(emoji) + + def get_sound(self, sound_id: int) -> SoundboardSound | None: + """Gets a :class:`.Sound` from the bot's sound cache. + + .. versionadded:: 2.7 + + Parameters + ---------- + sound_id: :class:`int` + The ID of the sound to get. + + Returns + ------- + Optional[:class:`.SoundboardSound`] + The sound with the given ID. + """ + return self._connection._get_sound(sound_id) + + @property + def sounds(self) -> list[SoundboardSound]: + """A list of all the sounds the bot can see. + + .. versionadded:: 2.7 + """ + return self._connection.sounds + + async def fetch_default_sounds(self) -> list[SoundboardSound]: + """|coro| + + Fetches the bot's default sounds. + + .. versionadded:: 2.7 + + Returns + ------- + List[:class:`.SoundboardSound`] + The bot's default sounds. + """ + data = await self._connection.http.get_default_sounds() + return [ + SoundboardSound(http=self.http, state=self._connection, data=s) + for s in data + ] diff --git a/discord/enums.py b/discord/enums.py index e1086651e9..5f533a9f9a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -71,6 +71,7 @@ "PromptType", "OnboardingMode", "ReactionType", + "VoiceChannelEffectAnimationType", "SKUType", "EntitlementType", "EntitlementOwnerType", @@ -1055,6 +1056,16 @@ class PollLayoutType(Enum): default = 1 +class VoiceChannelEffectAnimationType(Enum): + """Voice channel effect animation type. + + .. versionadded:: 2.7 + """ + + premium = 0 + basic = 1 + + class SubscriptionStatus(Enum): """The status of a subscription.""" diff --git a/discord/gateway.py b/discord/gateway.py index 4af59f3864..b0eb364601 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -284,6 +284,7 @@ class DiscordWebSocket: HELLO = 10 HEARTBEAT_ACK = 11 GUILD_SYNC = 12 + REQUEST_SOUNDBOARD_SOUNDS = 31 def __init__(self, socket, *, loop): self.socket = socket @@ -724,6 +725,15 @@ async def voice_state(self, guild_id, channel_id, self_mute=False, self_deaf=Fal _log.debug("Updating our voice state to %s.", payload) await self.send_as_json(payload) + async def request_soundboard_sounds(self, guild_ids): + payload = { + "op": self.REQUEST_SOUNDBOARD_SOUNDS, + "d": {"guild_ids": guild_ids}, + } + + _log.debug("Requesting soundboard sounds for guilds %s.", guild_ids) + await self.send_as_json(payload) + async def close(self, code=4000): if self._keep_alive: self._keep_alive.stop() diff --git a/discord/guild.py b/discord/guild.py index 337abd31c0..4ec97713f6 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -81,6 +81,7 @@ from .permissions import PermissionOverwrite from .role import Role from .scheduled_events import ScheduledEvent, ScheduledEventLocation +from .soundboard import SoundboardSound from .stage_instance import StageInstance from .sticker import GuildSticker from .threads import Thread, ThreadMember @@ -130,6 +131,7 @@ class BanEntry(NamedTuple): class _GuildLimit(NamedTuple): emoji: int stickers: int + soundboard: int bitrate: float filesize: int @@ -286,14 +288,25 @@ class Guild(Hashable): "_threads", "approximate_member_count", "approximate_presence_count", + "_sounds", ) _PREMIUM_GUILD_LIMITS: ClassVar[dict[int | None, _GuildLimit]] = { - None: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=10_485_760), - 0: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=10_485_760), - 1: _GuildLimit(emoji=100, stickers=15, bitrate=128e3, filesize=10_485_760), - 2: _GuildLimit(emoji=150, stickers=30, bitrate=256e3, filesize=52_428_800), - 3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104_857_600), + None: _GuildLimit( + emoji=50, stickers=5, soundboard=8, bitrate=96e3, filesize=10_485_760 + ), + 0: _GuildLimit( + emoji=50, stickers=5, soundboard=8, bitrate=96e3, filesize=10_485_760 + ), + 1: _GuildLimit( + emoji=100, stickers=15, soundboard=24, bitrate=128e3, filesize=10_485_760 + ), + 2: _GuildLimit( + emoji=150, stickers=30, soundboard=36, bitrate=256e3, filesize=52_428_800 + ), + 3: _GuildLimit( + emoji=250, stickers=60, soundboard=48, bitrate=384e3, filesize=104_857_600 + ), } def __init__(self, *, data: GuildPayload, state: ConnectionState): @@ -308,6 +321,7 @@ def __init__(self, *, data: GuildPayload, state: ConnectionState): self._voice_states: dict[int, VoiceState] = {} self._threads: dict[int, Thread] = {} self._state: ConnectionState = state + self._sounds: dict[int, SoundboardSound] = {} self._from_data(data) def _add_channel(self, channel: GuildChannel, /) -> None: @@ -550,6 +564,133 @@ def _from_data(self, guild: GuildPayload) -> None: for obj in guild.get("voice_states", []): self._update_voice_state(obj, int(obj["channel_id"])) + for sound in guild.get("soundboard_sounds", []): + sound = SoundboardSound(state=state, http=state.http, data=sound) + self._add_sound(sound) + + def _add_sound(self, sound: SoundboardSound) -> None: + self._sounds[sound.id] = sound + self._state._add_sound(sound) + + def _remove_sound(self, sound_id: int) -> None: + self._sounds.pop(sound_id, None) + + async def fetch_sounds(self) -> list[SoundboardSound]: + """|coro| + Fetches all the soundboard sounds in the guild. + + .. versionadded:: 2.7 + + Returns + ------- + List[:class:`SoundboardSound`] + The sounds in the guild. + """ + data = await self._state.http.get_all_guild_sounds(self.id) + return [ + SoundboardSound( + state=self._state, + http=self._state.http, + data=sound, + ) + for sound in data["items"] + ] + + async def fetch_sound(self, sound_id: int) -> SoundboardSound: + """|coro| + Fetches a soundboard sound in the guild. + + .. versionadded:: 2.7 + + Parameters + ---------- + sound_id: :class:`int` + The ID of the sound. + + Returns + ------- + :class:`SoundboardSound` + The sound. + """ + data = await self._state.http.get_guild_sound(self.id, sound_id) + return SoundboardSound( + state=self._state, + http=self._state.http, + data=data, + ) + + async def create_sound( + self, + name: str, + sound: bytes, + volume: float = 1.0, + emoji: PartialEmoji | GuildEmoji | str | None = None, + reason: str | None = None, + ) -> SoundboardSound: + """|coro| + Creates a :class:`SoundboardSound` in the guild. + You must have :attr:`Permissions.manage_expressions` permission to use this. + + .. versionadded:: 2.7 + + Parameters + ---------- + name: :class:`str` + The name of the sound. + sound: :class:`bytes` + The :term:`py:bytes-like object` representing the sound data. + Only MP3 sound files that are less than 5.2 seconds long are supported. + volume: :class:`float` + The volume of the sound. Defaults to 1.0. + emoji: Optional[Union[:class:`PartialEmoji`, :class:`GuildEmoji`, :class:`str`]] + The emoji of the sound. + reason: Optional[:class:`str`] + The reason for creating this sound. Shows up on the audit log. + + Returns + ------- + :class:`SoundboardSound` + The created sound. + + Raises + ------ + :exc:`HTTPException` + Creating the sound failed. + :exc:`Forbidden` + You do not have permissions to create sounds. + """ + + payload: dict[str, Any] = { + "name": name, + "sound": utils._bytes_to_base64_data(sound), + "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_guild_sound( + self.id, reason=reason, **payload + ) + return SoundboardSound( + state=self._state, + http=self._state.http, + data=data, + ) + # TODO: refactor/remove? def _sync(self, data: GuildPayload) -> None: try: @@ -676,6 +817,17 @@ def categories(self) -> list[CategoryChannel]: r.sort(key=lambda c: (c.position or -1, c.id)) return r + @property + def sounds(self) -> list[SoundboardSound]: + """A list of soundboard sounds that belong to this guild. + + .. versionadded:: 2.7 + + This is sorted by the position and are in UI order from top to bottom. + """ + r = list(self._sounds.values()) + return r + def by_category(self) -> list[ByCategoryItem]: """Returns every :class:`CategoryChannel` and their associated channels. @@ -826,6 +978,17 @@ def sticker_limit(self) -> int: more_stickers, self._PREMIUM_GUILD_LIMITS[self.premium_tier].stickers ) + @property + def soundboard_limit(self) -> int: + """The maximum number of soundboard slots this guild has. + + .. versionadded:: 2.7 + """ + more_soundboard = 48 if "MORE_SOUNDBOARD" in self.features else 0 + return max( + more_soundboard, self._PREMIUM_GUILD_LIMITS[self.premium_tier].soundboard + ) + @property def bitrate_limit(self) -> int: """The maximum bitrate for voice channels this guild can have.""" @@ -4149,3 +4312,20 @@ def entitlements( guild_id=self.id, exclude_ended=exclude_ended, ) + + def get_sound(self, sound_id: int) -> Soundboard | None: + """Returns a sound with the given ID. + + .. versionadded :: 2.7 + + Parameters + ---------- + sound_id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[:class:`SoundboardSound`] + The sound or ``None`` if not found. + """ + return self._sounds.get(sound_id) diff --git a/discord/http.py b/discord/http.py index 2db704b268..6a10093dd7 100644 --- a/discord/http.py +++ b/discord/http.py @@ -46,6 +46,7 @@ ) from .file import VoiceMessage from .gateway import DiscordClientWebSocketResponse +from .soundboard import PartialSoundboardSound, SoundboardSound from .utils import MISSING, warn_deprecated _log = logging.getLogger(__name__) @@ -84,6 +85,7 @@ widget, ) from .types.snowflake import Snowflake, SnowflakeList + from .types.soundboard import SoundboardSound as SoundboardSoundPayload T = TypeVar("T") BE = TypeVar("BE", bound=BaseException) @@ -1762,7 +1764,7 @@ def create_guild_sticker( initial_bytes = file.fp.read(16) try: - mime_type = utils._get_mime_type_for_image(initial_bytes) + mime_type = utils._get_mime_type_for_file(initial_bytes) except InvalidArgument: if initial_bytes.startswith(b"{"): mime_type = "application/json" @@ -3223,3 +3225,98 @@ async def get_bot_gateway( def get_user(self, user_id: Snowflake) -> Response[user.User]: return self.request(Route("GET", "/users/{user_id}", user_id=user_id)) + + def delete_sound( + self, sound: SoundboardSound, *, reason: str | None + ) -> Response[None]: + return self.request( + Route( + "DELETE", + "/guilds/{guild_id}/soundboard-sounds/{sound_id}", + guild_id=sound.guild.id, + sound_id=sound.id, + ), + reason=reason, + ) + + def get_default_sounds(self) -> Response[list[SoundboardSoundPayload]]: + return self.request(Route("GET", "/soundboard-default-sounds")) + + def create_guild_sound( + self, guild_id: Snowflake, reason: str | None, **payload + ) -> Response[SoundboardSoundPayload]: + keys = ( + "name", + "sound", + "volume", + "emoji_id", + "emoji_name", + ) + + payload = {k: v for k, v in payload.items() if k in 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 get_all_guild_sounds( + self, guild_id: Snowflake + ) -> Response[list[SoundboardSoundPayload]]: + return self.request( + Route("GET", "/guilds/{guild_id}/soundboard-sounds", guild_id=guild_id) + ) + + def get_guild_sound( + self, guild_id: Snowflake, sound_id: Snowflake + ) -> Response[SoundboardSoundPayload]: + return self.request( + Route( + "GET", + "/guilds/{guild_id}/soundboard-sounds/{sound_id}", + guild_id=guild_id, + sound_id=sound_id, + ) + ) + + def edit_guild_sound( + self, guild_id: Snowflake, sound_id: Snowflake, *, reason: str | None, **payload + ) -> Response[SoundboardSoundPayload]: + keys = ( + "name", + "volume", + "emoji_id", + "emoji_name", + ) + + payload = {k: v for k, v in payload.items() if k in keys and v is not None} + + 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 send_soundboard_sound( + self, channel_id: int, sound: PartialSoundboardSound + ) -> Response[None]: + payload = { + "sound_id": sound.id, + } + if isinstance(sound, SoundboardSound) and not sound.is_default_sound: + payload["source_guild_id"] = sound.guild_id + + return self.request( + Route( + "POST", + "/channels/{channel_id}/send-soundboard-sound", + channel_id=channel_id, + ), + json=payload, + ) diff --git a/discord/raw_models.py b/discord/raw_models.py index 73da688b7f..d58b8e4278 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -29,7 +29,13 @@ from typing import TYPE_CHECKING from .automod import AutoModAction, AutoModTriggerType -from .enums import AuditLogAction, ChannelType, ReactionType, try_enum +from .enums import ( + AuditLogAction, + ChannelType, + ReactionType, + VoiceChannelEffectAnimationType, + try_enum, +) if TYPE_CHECKING: from .abc import MessageableChannel @@ -37,6 +43,7 @@ from .member import Member from .message import Message from .partial_emoji import PartialEmoji + from .soundboard import PartialSoundboardSound, SoundboardSound from .state import ConnectionState from .threads import Thread from .types.raw_models import ( @@ -58,6 +65,9 @@ ThreadMembersUpdateEvent, ThreadUpdateEvent, TypingEvent, + ) + from .types.raw_models import VoiceChannelEffectSendEvent as VoiceChannelEffectSend + from .types.raw_models import ( VoiceChannelStatusUpdateEvent, ) from .user import User @@ -81,6 +91,7 @@ "RawAuditLogEntryEvent", "RawVoiceChannelStatusUpdateEvent", "RawMessagePollVoteEvent", + "RawSoundboardSoundDeleteEvent", ) @@ -841,3 +852,17 @@ def __init__(self, data: MessagePollVoteEvent, added: bool) -> None: self.guild_id: int | None = int(data["guild_id"]) except KeyError: self.guild_id: int | None = None + + +class RawSoundboardSoundDeleteEvent(_RawReprMixin): + """Represents the payload for an :func:`on_raw_soundboard_sound_delete`. + + .. versionadded 2.7 + """ + + __slots__ = ("sound_id", "guild_id", "data") + + def __init__(self, data: PartialSoundboardSound) -> None: + self.sound_id: int = int(data["sound_id"]) + self.guild_id: int = int(data["guild_id"]) + self.data: PartialSoundboardSound = data diff --git a/discord/soundboard.py b/discord/soundboard.py new file mode 100644 index 0000000000..6fae037090 --- /dev/null +++ b/discord/soundboard.py @@ -0,0 +1,270 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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, Any, Coroutine + +from typing_extensions import override + +from .asset import Asset +from .emoji import PartialEmoji, _EmojiTag +from .mixins import Hashable +from .types.channel import ( + VoiceChannelEffectSendEvent as VoiceChannelEffectSendEventPayload, +) +from .types.soundboard import SoundboardSound as SoundboardSoundPayload +from .utils import cached_slot_property + +if TYPE_CHECKING: + from .guild import Guild + from .http import HTTPClient + from .state import ConnectionState + + +__all__ = ( + "PartialSoundboardSound", + "SoundboardSound", +) + + +class PartialSoundboardSound(Hashable): + """A partial soundboard sound. + + .. versionadded:: 2.7 + + Attributes + ---------- + id: :class:`int` + The sound's ID. + volume: :class:`float` + The sound's volume. + emoji: :class:`PartialEmoji` | :class:`None` + The sound's emoji. Could be ``None`` if the sound has no emoji. + """ + + __slots__ = ("id", "volume", "emoji", "_http", "_state") + + def __init__( + self, + data: SoundboardSoundPayload | VoiceChannelEffectSendEventPayload, + state: ConnectionState, + http: HTTPClient, + ): + self._http = http + self._state = state + self._from_data(data) + + def _from_data( + self, data: SoundboardSoundPayload | VoiceChannelEffectSendEventPayload + ) -> None: + self.id = int(data.get("sound_id", 0)) + self.volume = ( + float(data.get("volume", 0) or data.get("sound_volume", 0)) or None + ) + self.emoji = None + if raw_emoji := data.get( + "emoji" + ): # From gateway event (VoiceChannelEffectSendEventPayload) + self.emoji = PartialEmoji.from_dict(raw_emoji) + elif data.get("emoji_name") or data.get( + "emoji_id" + ): # From HTTP response (SoundboardSoundPayload) + self.emoji = PartialEmoji( + name=data.get("emoji_name"), + id=int(data.get("emoji_id", 0) or 0) or None, + ) + + @override + def __eq__( + self, other: PartialSoundboardSound + ) -> bool: # pyright: ignore[reportIncompatibleMethodOverride] + if isinstance(other, self, __class__): + return self.id == other.id + return NotImplemented + + @override + def __ne__( + self, other: PartialSoundboardSound + ) -> bool: # pyright: ignore[reportIncompatibleMethodOverride] + return not self.__eq__(other) + + @property + def file(self) -> Asset: + """:class:`Asset`: Returns the sound's file.""" + return Asset._from_soundboard_sound(self._state, sound_id=self.id) + + def __repr__(self) -> str: + return f"" + + +class SoundboardSound(PartialSoundboardSound): + """Represents a soundboard sound. + + .. versionadded:: 2.7 + + Attributes + ---------- + id: :class:`int` + The sound's ID. + volume: :class:`float` + The sound's volume. + emoji: :class:`PartialEmoji` | :class:`None` + The sound's emoji. Could be ``None`` if the sound has no emoji. + name: :class:`str` + The sound's name. + available: :class:`bool` + Whether the sound is available. Could be ``False`` if the sound is not available. + This is the case, for example, when the guild loses the boost level required to use the sound. + guild_id: :class:`int` | :class:`None` + The ID of the guild to which the sound belongs. Could be :class:`None` if the sound is a default sound. + user: :class:`User` | :class:`None` + The sound's owner. Could be ``None`` if the sound is a default sound. + """ + + __slots__ = ( + "name", + "available", + "guild_id", + "user", + "_cs_guild", + "_state", + ) + + def __init__( + self, + *, + state: ConnectionState, + http: HTTPClient, + data: SoundboardSoundPayload, + ) -> None: + super().__init__(data, state, http) + + @override + def _from_data( + self, data: SoundboardSoundPayload + ) -> None: # pyright: ignore[reportIncompatibleMethodOverride] + super()._from_data(data) + self.name = data["name"] + self.available: bool = data["available"] + self.guild_id = int(data.get("guild_id", 0) or 0) or None + user = data.get("user") + self.user = self._state.store_user(user) if user else None + + @cached_slot_property("_cs_guild") + def guild(self) -> Guild | None: + """:class:`Guild` | :class:`None` The guild the sound belongs to. Could be :class:`None` if the sound is a default sound.""" + return self._state._get_guild(self.guild_id) if self.guild_id else None + + @override + def __eq__( + self, other: SoundboardSound + ) -> bool: # pyright: ignore[reportIncompatibleMethodOverride] + return isinstance(other, SoundboardSound) and self.__dict__ == other.__dict__ + + @property + def is_default_sound(self) -> bool: + """:class:`bool`: Whether the sound is a default sound.""" + return self.guild_id is None + + def edit( + self, + *, + name: str | None = None, + volume: float | None = None, + emoji: PartialEmoji | str | None = None, + reason: str | None = None, + ) -> Coroutine[Any, Any, SoundboardSound]: + """Edits the sound. + + .. versionadded:: 2.7 + + Parameters + ---------- + name: Optional[:class:`str`] + The new name of the sound. + volume: Optional[:class:`float`] + The new volume of the sound. + emoji: Optional[Union[:class:`PartialEmoji`, :class:`str`]] + The new emoji of the sound. + reason: Optional[:class:`str`] + The reason for editing the sound. Shows up in the audit log. + + Returns + ------- + :class:`SoundboardSound` + The edited sound. + + Raises + ------ + :exc:`ValueError` + Editing a default sound is not allowed. + """ + if self.is_default_sound: + raise ValueError("Cannot edit a default sound.") + payload: dict[str, Any] = { + "name": name, + "volume": volume, + "emoji_id": None, + "emoji_name": None, + } + partial_emoji = 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) + + 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 + + return self._http.edit_guild_sound( + self.guild_id, self.id, reason=reason, **payload + ) + + def delete(self, *, reason: str | None = None) -> Coroutine[Any, Any, None]: + """Deletes the sound. + + .. versionadded:: 2.7 + + Parameters + ---------- + reason: Optional[:class:`str`] + The reason for deleting the sound. Shows up in the audit log. + + Raises + ------ + :exc:`ValueError` + Deleting a default sound is not allowed. + """ + if self.is_default_sound: + raise ValueError("Cannot delete a default sound.") + return self._http.delete_sound(self, reason=reason) + + @override + def __repr__(self) -> str: + return f"" diff --git a/discord/state.py b/discord/state.py index 52a9cc0989..5543e62f15 100644 --- a/discord/state.py +++ b/discord/state.py @@ -66,6 +66,7 @@ from .raw_models import * from .role import Role from .scheduled_events import ScheduledEvent +from .soundboard import PartialSoundboardSound, SoundboardSound from .stage_instance import StageInstance from .sticker import GuildSticker from .threads import Thread, ThreadMember @@ -283,6 +284,7 @@ def clear(self, *, views: bool = True) -> None: self._view_store: ViewStore = ViewStore(self) self._modal_store: ModalStore = ModalStore(self) self._voice_clients: dict[int, VoiceClient] = {} + self._sounds: dict[int, SoundboardSound] = {} # LRU of max size 128 self._private_channels: OrderedDict[int, PrivateChannel] = OrderedDict() @@ -651,6 +653,7 @@ async def _delay_ready(self) -> None: except asyncio.CancelledError: pass else: + await self._add_default_sounds() # dispatch the event self.call_handlers("ready") self.dispatch("ready") @@ -2012,6 +2015,83 @@ def create_message( ) -> Message: return Message(state=self, channel=channel, data=data) + def parse_voice_channel_effect_send(self, data) -> None: + if sound_id := int(data.get("sound_id", 0)): + sound = self._get_sound(sound_id) + if sound is None: + sound = PartialSoundboardSound(data, self, self.http) + raw = VoiceChannelEffectSendEvent(data, self, sound) + else: + raw = VoiceChannelEffectSendEvent(data, self, None) + + self.dispatch("voice_channel_effect_send", raw) + + def _get_sound(self, sound_id: int) -> SoundboardSound | None: + return self._sounds.get(sound_id) + + def _update_sound(self, sound: SoundboardSound) -> SoundboardSound | None: + before = self._sounds.get(sound.id) + self._sounds[sound.id] = sound + return before + + def parse_soundboard_sounds(self, data) -> None: + guild_id = int(data["guild_id"]) + for sound_data in data["soundboard_sounds"]: + self._add_sound( + SoundboardSound( + state=self, http=self.http, data=sound_data, guild_id=guild_id + ) + ) + + def parse_guild_soundboard_sounds_update(self, data): + before_sounds = [] + after_sounds = [] + for sound_data in data["soundboard_sounds"]: + after = SoundboardSound(state=self, http=self.http, data=sound_data) + if before := self._update_sound(after): + before_sounds.append(before) + after_sounds.append(after) + if len(before_sounds) == len(after_sounds): + self.dispatch("soundboard_sounds_update", before_sounds, after_sounds) + self.dispatch("raw_soundboard_sounds_update", after_sounds) + + def parse_guild_soundboard_sound_update(self, data): + after = SoundboardSound(state=self, http=self.http, data=data) + if before := self._update_sound(after): + self.dispatch("soundboard_sound_update", before, after) + self.dispatch("raw_soundboard_sound_update", after) + + def parse_guild_soundboard_sound_create(self, data): + sound = SoundboardSound(state=self, http=self.http, data=data) + self._add_sound(sound) + self.dispatch("soundboard_sound_create", sound) + + def parse_guild_soundboard_sound_delete(self, data): + sound_id = int(data["sound_id"]) + sound = self._get_sound(sound_id) + if sound is not None: + self._remove_sound(sound) + self.dispatch("soundboard_sound_delete", sound) + self.dispatch( + "raw_soundboard_sound_delete", RawSoundboardSoundDeleteEvent(data) + ) + + async def _add_default_sounds(self) -> None: + default_sounds = await self.http.get_default_sounds() + for default_sound in default_sounds: + sound = SoundboardSound(state=self, http=self.http, data=default_sound) + self._add_sound(sound) + + def _add_sound(self, sound: SoundboardSound) -> None: + self._sounds[sound.id] = sound + + def _remove_sound(self, sound: SoundboardSound) -> None: + self._sounds.pop(sound.id, None) + + @property + def sounds(self) -> list[SoundboardSound]: + return list(self._sounds.values()) + class AutoShardedConnectionState(ConnectionState): def __init__(self, *args: Any, **kwargs: Any) -> None: diff --git a/discord/types/channel.py b/discord/types/channel.py index fe5e097eca..d4661cf8c4 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -31,6 +31,7 @@ from ..enums import SortOrder from ..flags import ChannelFlags +from .emoji import PartialEmoji from .snowflake import Snowflake from .threads import ThreadArchiveDuration, ThreadMember, ThreadMetadata from .user import User @@ -182,3 +183,14 @@ class StageInstance(TypedDict): privacy_level: PrivacyLevel discoverable_disabled: bool guild_scheduled_event_id: Snowflake + + +class VoiceChannelEffectSendEvent(TypedDict): + channel_id: Snowflake + guild_id: Snowflake + user_id: Snowflake + emoji: NotRequired[PartialEmoji | None] + animation_type: NotRequired[int | None] + animation_id: NotRequired[int] + sound_id: NotRequired[Snowflake | int] + sound_volume: NotRequired[float] diff --git a/discord/types/guild.py b/discord/types/guild.py index 9ada5e194e..9618741a3e 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -80,6 +80,8 @@ class UnavailableGuild(TypedDict): "MEMBER_VERIFICATION_GATE_ENABLED", "MONETIZATION_ENABLED", "MORE_EMOJI", + "MORE_SOUNDBOARD", + "SOUNDBOARD", "MORE_STICKERS", "NEWS", "NEW_THREAD_PERMISSIONS", diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py new file mode 100644 index 0000000000..9a4c19b0ed --- /dev/null +++ b/discord/types/soundboard.py @@ -0,0 +1,42 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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_extensions import NotRequired, TypedDict + +from discord.types.user import User + +from .snowflake import Snowflake + + +class SoundboardSound(TypedDict): + name: str + sound_id: Snowflake | int + volume: float + emoji_name: str | None + emoji_id: Snowflake | None + guild_id: NotRequired[Snowflake] + user: NotRequired[User] + available: bool diff --git a/discord/utils.py b/discord/utils.py index 363d339391..19abbe409f 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -646,7 +646,7 @@ def _get_as_snowflake(data: Any, key: str) -> int | None: return value and int(value) -def _get_mime_type_for_image(data: bytes): +def _get_mime_type_for_file(data: bytes): if data.startswith(b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"): return "image/png" elif data[0:3] == b"\xff\xd8\xff" or data[6:10] in (b"JFIF", b"Exif"): @@ -655,13 +655,15 @@ def _get_mime_type_for_image(data: bytes): return "image/gif" elif data.startswith(b"RIFF") and data[8:12] == b"WEBP": return "image/webp" + elif data.startswith(b"\x49\x44\x33") or data.startswith(b"\xff\xfb"): + return "audio/mpeg" else: - raise InvalidArgument("Unsupported image type given") + raise InvalidArgument("Unsupported file type given") def _bytes_to_base64_data(data: bytes) -> str: fmt = "data:{mime};base64,{data}" - mime = _get_mime_type_for_image(data) + mime = _get_mime_type_for_file(data) b64 = b64encode(data).decode("ascii") return fmt.format(mime=mime, data=b64) diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 4d278e3758..84077753d0 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2501,6 +2501,20 @@ of :class:`enum.Enum`. The interaction is in a private DM or group DM channel. +.. class:: VoiceChannelEffectAnimationType + + Represents the type of animation for a voice channel effect. + + .. versionadded:: 2.7 + + .. attribute:: premium + + The animation is a premium effect. + + .. attribute:: basic + + The animation is a basic effect. + .. class:: SubscriptionStatus diff --git a/docs/api/events.rst b/docs/api/events.rst index e9bd0a4c0d..b1f08b89e7 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1413,3 +1413,85 @@ Voice Channel Status Update :param payload: The raw voice channel status update payload. :type payload: :class:`RawVoiceChannelStatusUpdateEvent` + +Voice Channel Effects +--------------------- +.. function:: on_voice_channel_effect_send(event) + + Called when a voice channel effect is sent. + + .. versionadded:: 2.7 + + :param event: The voice channel effect event. + :type event: :class:`VoiceChannelEffectSendEvent` + +Soundboard Sound +---------------- +.. function:: on_soundboard_sounds_update(before, after) + + Called when multiple guild soundboard sounds are updated at once and they were all already in the cache. + This is called, for example, when a guild loses a boost level and some sounds become unavailable. + + .. versionadded:: 2.7 + + :param before: The soundboard sounds prior to being updated. + :type before: List[:class:`SoundboardSound`] + :param after: The soundboard sounds after being updated. + :type after: List[:class:`SoundboardSound`] + +.. function:: on_raw_soundboard_sounds_update(after) + + Called when multiple guild soundboard sounds are updated at once. + This is called, for example, when a guild loses a boost level and some sounds become unavailable. + + .. versionadded:: 2.7 + + :param after: The soundboard sounds after being updated. + :type after: List[:class:`SoundboardSound`] + +.. function:: on_soundboard_sound_update(before, after) + + Called when a soundboard sound is updated and it was already in the cache. + + .. versionadded:: 2.7 + + :param before: The soundboard sound prior to being updated. + :type before: :class:`Soundboard + :param after: The soundboard sound after being updated. + :type after: :class:`Soundboard + +.. function:: on_raw_soundboard_sound_update(after) + + Called when a soundboard sound is updated. + + .. versionadded:: 2.7 + + :param after: The soundboard sound after being updated. + :type after: :class:`SoundboardSound` + +.. function:: on_soundboard_sound_delete(sound) + + Called when a soundboard sound is deleted. + + .. versionadded:: 2.7 + + :param sound: The soundboard sound that was deleted. + :type sound: :class:`SoundboardSound` + +.. function:: on_raw_soundboard_sound_delete(payload) + + Called when a soundboard sound is deleted. + + .. versionadded:: 2.7 + + :param payload: The raw event payload data. + :type payload: :class:`RawSoundboardSoundDeleteEvent` + +.. function:: on_soundboard_sound_create(sound) + + Called when a soundboard sound is created. + + .. versionadded:: 2.7 + + :param sound: The soundboard sound that was created. + :type sound: :class:`SoundboardSound` diff --git a/docs/api/models.rst b/docs/api/models.rst index cb702b2c38..f83e154ecb 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -505,6 +505,20 @@ Stickers .. autoclass:: GuildSticker() :members: +Soundboard +---------- + +.. attributetable:: PartialSoundboardSound + +.. autoclass:: PartialSoundboardSound() + :members: + +.. attributetable:: SoundboardSound + +.. autoclass:: SoundboardSound() + :members: + :inherited-members: + Events ------ @@ -588,6 +602,11 @@ Events .. autoclass:: RawVoiceChannelStatusUpdateEvent() :members: +.. attributetable:: VoiceChannelEffectSendEvent + +.. autoclass:: VoiceChannelEffectSendEvent() + :members: + Webhooks diff --git a/examples/soundboard.py b/examples/soundboard.py new file mode 100644 index 0000000000..d19faa666d --- /dev/null +++ b/examples/soundboard.py @@ -0,0 +1,170 @@ +import asyncio +import logging +import os + +from dotenv import load_dotenv + +import discord + +logging.basicConfig(level=logging.INFO) + +load_dotenv() +TOKEN = os.getenv("TOKEN") + +bot = discord.Bot() + + +class SoundboardCog(discord.Cog): + """A cog demonstrating Discord's soundboard features.""" + + def __init__(self, bot: discord.Bot): + self.bot = bot + + @discord.Cog.listener() + async def on_voice_channel_effect_send( + self, event: discord.VoiceChannelEffectSendEvent + ): + """Called when someone uses a soundboard effect in a voice channel.""" + if event.sound: + print(f"{event.user} played sound '{event.sound.name}' in {event.channel}") + elif event.emoji: + print(f"{event.user} sent emoji effect {event.emoji} in {event.channel}") + + @discord.slash_command() + async def list_sounds(self, ctx: discord.ApplicationContext): + """Lists all the available sounds in the guild.""" + await ctx.defer() + + # Fetch both default and guild-specific sounds + default_sounds = await self.bot.fetch_default_sounds() + guild_sounds = await ctx.guild.fetch_sounds() + + embed = discord.Embed(title="Available Sounds") + + # List default sounds + if default_sounds: + default_list = "\n".join( + f"{s.emoji} {s.name} (Volume: {s.volume})" for s in default_sounds + ) + embed.add_field( + name="Default Sounds", value=default_list or "None", inline=False + ) + + # List guild sounds + if guild_sounds: + guild_list = "\n".join( + f"{s.emoji} {s.name} (Volume: {s.volume})" for s in guild_sounds + ) + embed.add_field( + name="Guild Sounds", value=guild_list or "None", inline=False + ) + + await ctx.respond(embed=embed) + + @discord.slash_command() + @discord.default_permissions(manage_guild=True) + async def add_sound( + self, + ctx: discord.ApplicationContext, + name: str, + emoji: str, + attachment: discord.Attachment, + ): + """Adds a new sound to the guild's soundboard. Currently only supports mp3 files.""" + await ctx.defer() + + if not attachment.content_type.startswith("audio/"): + return await ctx.respond("Please upload an audio file!") + + try: + sound_bytes = await attachment.read() + emoji = discord.PartialEmoji.from_str(emoji) + + new_sound = await ctx.guild.create_sound( + name=name, sound=sound_bytes, volume=1.0, emoji=emoji + ) + + await ctx.respons(f"Added new sound: {new_sound.emoji} {new_sound.name}") + except Exception as e: + await ctx.respond(f"Failed to add sound: {str(e)}") + + @discord.slash_command() + @discord.default_permissions(manage_guild=True) + async def edit_sound( + self, + ctx: discord.ApplicationContext, + sound_name: str, + new_name: str | None = None, + new_emoji: str | None = None, + new_volume: float | None = None, + ): + """Edits an existing sound in the guild's soundboard.""" + await ctx.defer() + + # Find the sound by name + sounds = await ctx.guild.fetch_sounds() + sound = discord.utils.get(sounds, name=sound_name) + + if not sound: + return await ctx.respond(f"Sound '{sound_name}' not found!") + + try: + await sound.edit( + name=new_name or sound.name, + emoji=( + discord.PartialEmoji.from_str(new_emoji) + if new_emoji + else sound.emoji + ), + volume=new_volume or sound.volume, + ) + await ctx.respond(f"Updated sound: {sound.emoji} {sound.name}") + except Exception as e: + await ctx.respond(f"Failed to edit sound: {str(e)}") + + @discord.slash_command() + async def play_sound( + self, + ctx: discord.ApplicationContext, + sound_name: str, + channel: discord.VoiceChannel | None = None, + ): + """Plays a sound in a voice channel.""" + await ctx.defer() + + # Use author's voice channel if none specified + if not channel and ctx.author.voice: + channel = ctx.author.voice.channel + if not channel: + return await ctx.respond("Please specify a voice channel or join one!") + + try: + # Find the sound + sounds = await ctx.guild.fetch_sounds() + sound = discord.utils.get(sounds, name=sound_name) + if not sound: + # Check default sounds if not found in guild sounds + defaults = await self.bot.fetch_default_sounds() + sound = discord.utils.get(defaults, name=sound_name) + + if not sound: + return await ctx.respond(f"Sound '{sound_name}' not found!") + + # Connect to voice channel if not already connected + voice_client = await channel.connect() + + # Play the sound + await channel.send_soundboard_sound(sound) + await ctx.respond(f"Playing sound: {sound.emoji} {sound.name}") + + await asyncio.sleep(6) + if voice_client.is_connected(): + await voice_client.disconnect() + + except Exception as e: + await ctx.respond(f"Failed to play sound: {str(e)}") + + +bot.add_cog(SoundboardCog(bot)) + +bot.run(TOKEN) diff --git a/requirements/_.txt b/requirements/_.txt index e8dfe84d1e..e8be976f05 100644 --- a/requirements/_.txt +++ b/requirements/_.txt @@ -1,2 +1,2 @@ aiohttp>=3.6.0,<4.0 -typing_extensions>=4,<5 +typing_extensions>=4.5.0,<5