From fefff2b4fdfe97c8ceb217693c185460ce0e5c33 Mon Sep 17 00:00:00 2001 From: Dasupergrasskakjd Date: Tue, 16 Jan 2024 20:03:25 +0000 Subject: [PATCH 01/30] Original work at #2321 Co-authored-by: Dasupergrasskakjd Co-authored-by: Dorukyum <53639936+Dorukyum@users.noreply.github.com> --- discord/__init__.py | 1 + discord/asset.py | 8 ++ discord/channel.py | 89 ++++++++++++++++- discord/client.py | 42 ++++++++ discord/enums.py | 8 ++ discord/gateway.py | 10 ++ discord/guild.py | 114 ++++++++++++++++++++++ discord/http.py | 60 +++++++++++- discord/raw_models.py | 12 ++- discord/soundboard.py | 185 ++++++++++++++++++++++++++++++++++++ discord/state.py | 43 +++++++++ discord/types/channel.py | 12 +++ discord/types/soundboard.py | 46 +++++++++ discord/utils.py | 8 +- 14 files changed, 631 insertions(+), 7 deletions(-) create mode 100644 discord/soundboard.py create mode 100644 discord/types/soundboard.py 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 d70083d9f7..ba93c0a9ca 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 @@ -66,6 +77,8 @@ "PartialMessageable", "ForumChannel", "ForumTag", + # "VoiceChannelEffect", + "VoiceChannelEffectSendEvent", ) if TYPE_CHECKING: @@ -84,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 @@ -3220,6 +3234,79 @@ def get_partial_message(self, message_id: int, /) -> PartialMessage: return PartialMessage(channel=self, id=message_id) +class VoiceChannelEffectAnimation(NamedTuple): + id: int + type: VoiceChannelEffectAnimationType + + +class VoiceChannelSoundEffect(PartialSoundboardSound): ... + + +class VoiceChannelEffectSendEvent: + """Represents the payload for an :func:`on_voice_channel_effect_send` + + .. versionadded:: 2.4 + + 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, might be None if the effect is not a sound effect. + guild: :class:`Guild` + The guild that the sound is being sent in. + user: :class:`Member` + The member that is sending the sound. + channel: :class:`VoiceChannel` + The voice channel that the sound is being sent in. + data: :class:`dict` + The raw data sent by the gateway([#6025](https://github.com/discord/discord-api-docs/pull/6025)). + """ + + __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 1520952f04..9cd6f61671 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 DefaultSoundboardSound 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",) @@ -2269,3 +2271,43 @@ 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.4 + + Parameters + ---------- + sound_id: :class:`int` + The ID of the sound to get. + + Returns + ------- + :class:`.Sound` + The sound from the 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.4 + """ + return self._connection.sounds + + async def fetch_default_sounds(self) -> list[SoundboardSound]: + """|coro| + + Fetches the bot's default sounds. + + .. versionadded:: 2.4 + + Returns + ------- + List[:class:`.Sound`] + The bot's default sounds. + """ + data = await self._connection.http.get_default_sounds() + return [DefaultSoundboardSound(self.http, s) for s in data] diff --git a/discord/enums.py b/discord/enums.py index 14aa54d460..fd671cb2ce 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -71,6 +71,7 @@ "PromptType", "OnboardingMode", "ReactionType", + "VoiceChannelEffectAnimationType", "SKUType", "EntitlementType", "EntitlementOwnerType", @@ -1053,6 +1054,13 @@ class PollLayoutType(Enum): default = 1 +class VoiceChannelEffectAnimationType(Enum): + """Voice channel effect animation type""" + + premium = 0 + basic = 1 + + T = TypeVar("T") diff --git a/discord/gateway.py b/discord/gateway.py index 47d4853a65..9d59edc3d1 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 @@ -722,6 +723,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 b1e937d07b..084c45158c 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 @@ -286,6 +287,7 @@ class Guild(Hashable): "_threads", "approximate_member_count", "approximate_presence_count", + "_sounds", ) _PREMIUM_GUILD_LIMITS: ClassVar[dict[int | None, _GuildLimit]] = { @@ -308,6 +310,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 +553,91 @@ 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, guild=self + ) + 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 create_sound( + self, + name: str, + sound: bytes, + volume: float = 1.0, + emoji: PartialEmoji | Emoji | str | None = None, + reason: str | None = None, + ): + """|coro| + Creates a :class:`SoundboardSound` in the guild. + You must have :attr:`Permissions.manage_expressions` permission to use this. + + .. versionadded:: 2.4 + + 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 don't exceed the duration of 5.2s are supported. + volume: :class:`float` + The volume of the sound. Defaults to 1.0. + emoji: Union[:class:`PartialEmoji`, :class:`Emoji`, :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_sound(self.id, reason=reason, **payload) + return SoundboardSound( + state=self._state, + http=self._state.http, + data=data, + guild=self, + owner_id=self._state.self_id, + ) + # TODO: refactor/remove? def _sync(self, data: GuildPayload) -> None: try: @@ -676,6 +764,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.5 + + 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. @@ -4149,3 +4248,18 @@ def entitlements( guild_id=self.id, exclude_ended=exclude_ended, ) + + def get_sound(self, sound_id: int): + """Returns a sound with the given ID. + + Parameters + ---------- + sound_id: :class:`int` + The ID to search for. + + Returns + ------- + Optional[:class:`Sound`] + The sound or ``None`` if not found. + """ + return self._sounds.get(sound_id) diff --git a/discord/http.py b/discord/http.py index 1ca3135f8d..a17c4e2ee5 100644 --- a/discord/http.py +++ b/discord/http.py @@ -54,6 +54,7 @@ from .enums import AuditLogAction, InteractionResponseType from .file import File + from .soundboard import SoundboardSound from .types import ( appinfo, application_role_connection, @@ -83,6 +84,7 @@ widget, ) from .types.snowflake import Snowflake, SnowflakeList + from .types.soundboard import SoundboardSound as SoundboardSoundPayload T = TypeVar("T") BE = TypeVar("BE", bound=BaseException) @@ -1748,7 +1750,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" @@ -3172,3 +3174,59 @@ 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): + return self.request(Route("GET", "/soundboard-default-sounds")) + + def create_sound(self, guild_id: Snowflake, reason: str | None, **payload): + keys = ( + "name", + "suond", + "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 edit_sound( + self, guild_id: Snowflake, sound_Id: Snowflake, *, reason: str | None, **payload + ): + 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, + ) diff --git a/discord/raw_models.py b/discord/raw_models.py index a2881839a6..dfa3895725 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 AuditLogEntryEvent @@ -56,8 +63,9 @@ ThreadMembersUpdateEvent, ThreadUpdateEvent, TypingEvent, - VoiceChannelStatusUpdateEvent, ) + from .types.raw_models import VoiceChannelEffectSendEvent as VoiceChannelEffectSend + from .types.raw_models import VoiceChannelStatusUpdateEvent from .user import User diff --git a/discord/soundboard.py b/discord/soundboard.py new file mode 100644 index 0000000000..1e1525dc08 --- /dev/null +++ b/discord/soundboard.py @@ -0,0 +1,185 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +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 + +from .asset import Asset +from .emoji import PartialEmoji +from .mixins import Hashable +from .types.soundboard import PartialSoundboardSound as PartialSoundboardSoundPayload +from .types.soundboard import SoundboardSound as SoundboardSoundPayload + +if TYPE_CHECKING: + from .guild import Guild + from .http import HTTPClient + from .member import Member + from .state import ConnectionState + + +__all__ = ( + "PartialSoundboardSound", + "SoundboardSound", + "DefaultSoundboardSound", +) + + +class PartialSoundboardSound(Hashable): + """A partial soundboard sound. + + Attributes + ---------- + id: :class:`int` + The sound's ID. + volume: :class:`float` + The sound's volume. + emoji: :class:`PartialEmoji` + The sound's emoji. + """ + + __slots__ = ("id", "volume", "emoji", "_http", "emoji") + + def __init__(self, data: PartialSoundboardSoundPayload, http: HTTPClient): + self._http = http + self.id = int(data["sound_id"]) + self.volume = ( + float(data["volume"]) if data.get("volume") else data["sound_volume"] + ) + self.emoji = PartialEmoji( + name=data.get("emoji_name"), + id=int(data["emoji_id"]) if data.get("emoji_id") else None, + ) + + def __eq__(self, other: PartialSoundboardSound) -> bool: + if isinstance(other, self, __class__): + return self.id == other.id + return NotImplemented + + def __ne__(self, other: PartialSoundboardSound) -> bool: + return not self.__eq__(other) + + @property + def file(self) -> Asset: + """:class:`Asset`: Returns the sound's file.""" + return Asset._from_soundboard_sound(self) + + def _update(self, data: PartialSoundboardSoundPayload) -> None: + self.volume = float(data["volume"]) + self.emoji = PartialEmoji( + name=data.get("emoji_name"), + id=int(data["emoji_id"]) if data.get("emoji_id") else None, + ) + + +class SoundboardSound(PartialSoundboardSound): + """Represents a soundboard sound. + + Attributes + ---------- + id: :class:`int` + The sound's ID. + volume: :class:`float` + The sound's volume. + name: :class:`str` + The sound's name. + available: :class:`bool` + Whether the sound is available. + emoji: :class:`PartialEmoji` + The sound's emoji. + guild: :class:`Guild` + The guild the sound belongs to. + owner: :class:`Member` + The sound's owner. + """ + + __slots__ = ( + "id", + "volume", + "name", + "available", + "emoji", + "guild", + "owner", + "_http", + "_state", + "emoji", + ) + + def __init__( + self, + *, + state: ConnectionState, + http: HTTPClient, + data: SoundboardSoundPayload, + guild_id: int = None, + owner_id: Member = None, + guild: Guild = None, + ) -> None: + self._state = state + super().__init__(data, http) + self.name = data["name"] + self.available = bool(data.get("available", True)) + self.guild: Guild = guild or state._get_guild(guild_id) + self.owner: Member = self.guild.get_member(owner_id) + + def __eq__(self, other: SoundboardSound) -> bool: + return isinstance(other, SoundboardSound) and self.__dict__ == other.__dict__ + + def delete(self): + return self._http.delete_sound(self) + + def _update(self, data: PartialSoundboardSound) -> None: + super()._update(data) + self.name = data["name"] + self.available = bool(data.get("available", True)) + + +class DefaultSoundboardSound(PartialSoundboardSound): + """Represents a default soundboard sound. + + Attributes + ---------- + id: :class:`int` + The sound's ID. + volume: :class:`float` + The sound's volume. + name: :class:`str` + The sound's name. + emoji: :class:`PartialEmoji` + The sound's emoji. + """ + + __slots__ = ("id", "volume", "name", "emoji", "_http") + + def __init__(self, *, http: HTTPClient, data: SoundboardSoundPayload) -> None: + super().__init__(data, http) + self.name = data["name"] + + def __eq__(self, other: DefaultSoundboardSound) -> bool: + return ( + isinstance(other, DefaultSoundboardSound) + and self.__dict__ == other.__dict__ + ) diff --git a/discord/state.py b/discord/state.py index cf74d99285..4993f4f9f6 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 DefaultSoundboardSound, 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") @@ -2000,6 +2003,46 @@ def create_message( ) -> Message: return Message(state=self, channel=channel, data=data) + def parse_voice_channel_effect_send(self, data) -> None: + __import__("json") + if sound_id := int(data.get("sound_id", 0)): + sound = self._get_sound(sound_id) + if sound is None: + sound = PartialSoundboardSound(data, 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 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 + ) + ) + + async def _add_default_sounds(self): + default_sounds = await self.http.get_default_sounds() + for default_sound in default_sounds: + sound = DefaultSoundboardSound(http=self.http, data=default_sound) + self._add_sound(sound) + + def _add_sound(self, sound: SoundboardSound): + self._sounds[sound.id] = sound + + def _remove_sound(self, sound: SoundboardSound): + 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 1b7fb1fe5e..c64855385f 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -30,6 +30,7 @@ from .._typed_dict import NotRequired, TypedDict 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 @@ -181,3 +182,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 + sound_id: Snowflake | int + sound_volume: float + emoji: PartialEmoji | None + animation_type: int + animation_id: Snowflake diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py new file mode 100644 index 0000000000..a5d2293a55 --- /dev/null +++ b/discord/types/soundboard.py @@ -0,0 +1,46 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +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 typing import Optional, TypedDict, Union + +from .snowflake import Snowflake + + +class PartialSoundboardSound(TypedDict): + sound_id: Union[Snowflake, int] + emoji_name: Optional[str] + emoji_id: Optional[Snowflake] + volume: float + + +class SoundboardSound(PartialSoundboardSound): + user_id: Snowflake + name: str + guild_id: Snowflake + available: bool + + +class DefaultSoundboardSound(PartialSoundboardSound): + name: str diff --git a/discord/utils.py b/discord/utils.py index fbcf9c1f31..1a5f96cf46 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -647,7 +647,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"): @@ -656,13 +656,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) From 780062e40a6f0d2c6fdfc40f47f5e1311b6e0882 Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 24 Oct 2024 22:00:49 +0200 Subject: [PATCH 02/30] :sparkles: `MORE_SOUNDBOARD` and `SOUNDBOARD` feature flags --- discord/guild.py | 32 +++++++++++++++++++++++++++----- discord/types/guild.py | 2 ++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 084c45158c..e6ecd1ca3b 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -131,6 +131,7 @@ class BanEntry(NamedTuple): class _GuildLimit(NamedTuple): emoji: int stickers: int + soundboard: int bitrate: float filesize: int @@ -291,11 +292,21 @@ class Guild(Hashable): ) _PREMIUM_GUILD_LIMITS: ClassVar[dict[int | None, _GuildLimit]] = { - None: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), - 0: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), - 1: _GuildLimit(emoji=100, stickers=15, bitrate=128e3, filesize=26214400), - 2: _GuildLimit(emoji=150, stickers=30, bitrate=256e3, filesize=52428800), - 3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104857600), + None: _GuildLimit( + emoji=50, stickers=5, soundboard=8, bitrate=96e3, filesize=26214400 + ), + 0: _GuildLimit( + emoji=50, stickers=5, soundboard=8, bitrate=96e3, filesize=26214400 + ), + 1: _GuildLimit( + emoji=100, stickers=15, soundboard=24, bitrate=128e3, filesize=26214400 + ), + 2: _GuildLimit( + emoji=150, stickers=30, soundboard=36, bitrate=256e3, filesize=52428800 + ), + 3: _GuildLimit( + emoji=250, stickers=60, soundboard=48, bitrate=384e3, filesize=104857600 + ), } def __init__(self, *, data: GuildPayload, state: ConnectionState): @@ -925,6 +936,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.""" diff --git a/discord/types/guild.py b/discord/types/guild.py index cac645b272..8d5f7639e0 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -79,6 +79,8 @@ class UnavailableGuild(TypedDict): "MEMBER_VERIFICATION_GATE_ENABLED", "MONETIZATION_ENABLED", "MORE_EMOJI", + "MORE_SOUNDBOARD", + "SOUNDBOARD", "MORE_STICKERS", "NEWS", "NEW_THREAD_PERMISSIONS", From 61a087634277c0f9fa226e2d3edd97221be7e01f Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 00:14:00 +0200 Subject: [PATCH 03/30] :sparkles: `Guild.fetch_sounds` --- discord/client.py | 4 +-- discord/guild.py | 35 ++++++++++++++++----- discord/http.py | 13 ++++++-- discord/soundboard.py | 73 +++++++++++++++++++++++++++++-------------- 4 files changed, 89 insertions(+), 36 deletions(-) diff --git a/discord/client.py b/discord/client.py index 9cd6f61671..206aa9e446 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2293,7 +2293,7 @@ def get_sound(self, sound_id: int) -> SoundboardSound | None: def sounds(self) -> list[SoundboardSound]: """A list of all the sounds the bot can see. - .. versionadded:: 2.4 + .. versionadded:: 2.7 """ return self._connection.sounds @@ -2302,7 +2302,7 @@ async def fetch_default_sounds(self) -> list[SoundboardSound]: Fetches the bot's default sounds. - .. versionadded:: 2.4 + .. versionadded:: 2.7 Returns ------- diff --git a/discord/guild.py b/discord/guild.py index e6ecd1ca3b..abb34b306f 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -565,9 +565,7 @@ def _from_data(self, guild: GuildPayload) -> None: 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, guild=self - ) + sound = SoundboardSound(state=state, http=state.http, data=sound) self._add_sound(sound) def _add_sound(self, sound: SoundboardSound) -> None: @@ -577,12 +575,33 @@ def _add_sound(self, sound: SoundboardSound) -> None: 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 create_sound( self, name: str, sound: bytes, volume: float = 1.0, - emoji: PartialEmoji | Emoji | str | None = None, + emoji: PartialEmoji | GuildEmoji | str | None = None, reason: str | None = None, ): """|coro| @@ -600,7 +619,7 @@ async def create_sound( Only MP3 sound files that don't exceed the duration of 5.2s are supported. volume: :class:`float` The volume of the sound. Defaults to 1.0. - emoji: Union[:class:`PartialEmoji`, :class:`Emoji`, :class:`str`] + emoji: 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. @@ -640,13 +659,13 @@ async def create_sound( else: payload["emoji_id"] = partial_emoji.id - data = await self._state.http.create_sound(self.id, reason=reason, **payload) + data = await self._state.http.create_guild_sound( + self.id, reason=reason, **payload + ) return SoundboardSound( state=self._state, http=self._state.http, data=data, - guild=self, - owner_id=self._state.self_id, ) # TODO: refactor/remove? diff --git a/discord/http.py b/discord/http.py index a17c4e2ee5..43277f6b32 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3191,10 +3191,12 @@ def delete_sound( def get_default_sounds(self): return self.request(Route("GET", "/soundboard-default-sounds")) - def create_sound(self, guild_id: Snowflake, reason: str | None, **payload): + def create_guild_sound( + self, guild_id: Snowflake, reason: str | None, **payload + ) -> Response[SoundboardSoundPayload]: keys = ( "name", - "suond", + "sound", "volume", "emoji_id", "emoji_name", @@ -3208,6 +3210,13 @@ def create_sound(self, guild_id: Snowflake, reason: str | None, **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 edit_sound( self, guild_id: Snowflake, sound_Id: Snowflake, *, reason: str | None, **payload ): diff --git a/discord/soundboard.py b/discord/soundboard.py index 1e1525dc08..d7853160b0 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -27,11 +27,17 @@ from typing import TYPE_CHECKING +from typing_extensions import reveal_type + from .asset import Asset from .emoji import PartialEmoji from .mixins import Hashable +from .types.channel import ( + VoiceChannelEffectSendEvent as VoiceChannelEffectSendEventPayload, +) from .types.soundboard import PartialSoundboardSound as PartialSoundboardSoundPayload from .types.soundboard import SoundboardSound as SoundboardSoundPayload +from .utils import cached_slot_property if TYPE_CHECKING: from .guild import Guild @@ -62,16 +68,13 @@ class PartialSoundboardSound(Hashable): __slots__ = ("id", "volume", "emoji", "_http", "emoji") - def __init__(self, data: PartialSoundboardSoundPayload, http: HTTPClient): + def __init__( + self, + data: PartialSoundboardSoundPayload | VoiceChannelEffectSendEventPayload, + http: HTTPClient, + ): self._http = http - self.id = int(data["sound_id"]) - self.volume = ( - float(data["volume"]) if data.get("volume") else data["sound_volume"] - ) - self.emoji = PartialEmoji( - name=data.get("emoji_name"), - id=int(data["emoji_id"]) if data.get("emoji_id") else None, - ) + self._from_data(data) def __eq__(self, other: PartialSoundboardSound) -> bool: if isinstance(other, self, __class__): @@ -84,14 +87,24 @@ def __ne__(self, other: PartialSoundboardSound) -> bool: @property def file(self) -> Asset: """:class:`Asset`: Returns the sound's file.""" - return Asset._from_soundboard_sound(self) + return Asset._from_soundboard_sound(self, sound_id=self.id) - def _update(self, data: PartialSoundboardSoundPayload) -> None: - self.volume = float(data["volume"]) - self.emoji = PartialEmoji( - name=data.get("emoji_name"), - id=int(data["emoji_id"]) if data.get("emoji_id") else None, - ) + def _from_data( + self, data: PartialSoundboardSoundPayload | VoiceChannelEffectSendEventPayload + ) -> None: + self.id = int(data["sound_id"]) + self.volume = float(data.get("volume", 0)) or data.get("sound_volume") + if raw_emoji := data.get( + "emoji" + ): # From gateway event (VoiceChannelEffectSendEventPayload) + self.emoji = PartialEmoji.from_dict(raw_emoji) + elif emoji_id := data.get( + "emoji_id", 0 + ): # From HTTP response (PartialSoundboardSoundPayload) + self.emoji = PartialEmoji( + name=data.get("emoji_name"), + id=int(emoji_id) or None, + ) class SoundboardSound(PartialSoundboardSound): @@ -121,8 +134,9 @@ class SoundboardSound(PartialSoundboardSound): "name", "available", "emoji", - "guild", - "owner", + "guild_id", + "_cs_guild", + "user", "_http", "_state", "emoji", @@ -134,16 +148,27 @@ def __init__( state: ConnectionState, http: HTTPClient, data: SoundboardSoundPayload, - guild_id: int = None, - owner_id: Member = None, - guild: Guild = None, ) -> None: self._state = state super().__init__(data, http) + + def _from_data(self, data: SoundboardSoundPayload) -> None: + super()._from_data(data) self.name = data["name"] - self.available = bool(data.get("available", True)) - self.guild: Guild = guild or state._get_guild(guild_id) - self.owner: Member = self.guild.get_member(owner_id) + self.available: bool = data["available"] + self.guild_id = int(data["guild_id"]) + user = data.get("user") + + self.user = self._state.store_user(user) if user else None + + @cached_slot_property("_cs_guild") + def guild(self) -> Guild: + """:class:`Guild`: The guild the sound belongs to. + + The :class:`Guild` object representing the guild the sound belongs to. + .. versionadded:: 2.7 + """ + return self._state._get_guild(self.guild_id) def __eq__(self, other: SoundboardSound) -> bool: return isinstance(other, SoundboardSound) and self.__dict__ == other.__dict__ From 58a2b00c26d915193969e50e6dced5f32b688588 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 10:40:28 +0200 Subject: [PATCH 04/30] :sparkles: `Guild.fetch_sound` --- discord/guild.py | 23 +++++++++++++++++++++++ discord/http.py | 12 ++++++++++++ 2 files changed, 35 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index abb34b306f..44b373f04d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -596,6 +596,29 @@ async def fetch_sounds(self) -> list[SoundboardSound]: 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, diff --git a/discord/http.py b/discord/http.py index 43277f6b32..111bb21cb3 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3217,6 +3217,18 @@ def get_all_guild_sounds( 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_sound( self, guild_id: Snowflake, sound_Id: Snowflake, *, reason: str | None, **payload ): From e64e4f1d251833b9787c8fff06df92c7bdfe7d8b Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 13:00:51 +0200 Subject: [PATCH 05/30] :recycle: Cleanup --- discord/client.py | 7 +- discord/soundboard.py | 128 ++++++++++++++++-------------------- discord/state.py | 4 +- discord/types/channel.py | 10 +-- discord/types/soundboard.py | 25 ++++--- 5 files changed, 79 insertions(+), 95 deletions(-) diff --git a/discord/client.py b/discord/client.py index 206aa9e446..276d52d1cf 100644 --- a/discord/client.py +++ b/discord/client.py @@ -53,7 +53,7 @@ from .mentions import AllowedMentions from .monetization import SKU, Entitlement from .object import Object -from .soundboard import DefaultSoundboardSound +from .soundboard import SoundboardSound from .stage_instance import StageInstance from .state import ConnectionState from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory @@ -2310,4 +2310,7 @@ async def fetch_default_sounds(self) -> list[SoundboardSound]: The bot's default sounds. """ data = await self._connection.http.get_default_sounds() - return [DefaultSoundboardSound(self.http, s) for s in data] + return [ + SoundboardSound(http=self.http, state=self._connection, data=s) + for s in data + ] diff --git a/discord/soundboard.py b/discord/soundboard.py index d7853160b0..b17c05e773 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -25,9 +25,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Coroutine -from typing_extensions import reveal_type +from typing_extensions import override, reveal_type from .asset import Asset from .emoji import PartialEmoji @@ -35,21 +35,18 @@ from .types.channel import ( VoiceChannelEffectSendEvent as VoiceChannelEffectSendEventPayload, ) -from .types.soundboard import PartialSoundboardSound as PartialSoundboardSoundPayload 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 .member import Member from .state import ConnectionState __all__ = ( "PartialSoundboardSound", "SoundboardSound", - "DefaultSoundboardSound", ) @@ -66,22 +63,45 @@ class PartialSoundboardSound(Hashable): The sound's emoji. """ - __slots__ = ("id", "volume", "emoji", "_http", "emoji") + __slots__ = ("id", "volume", "emoji", "_http") def __init__( self, - data: PartialSoundboardSoundPayload | VoiceChannelEffectSendEventPayload, + data: SoundboardSoundPayload | VoiceChannelEffectSendEventPayload, http: HTTPClient, ): self._http = http self._from_data(data) - def __eq__(self, other: PartialSoundboardSound) -> bool: + 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 + ) + if raw_emoji := data.get( + "emoji" + ): # From gateway event (VoiceChannelEffectSendEventPayload) + self.emoji = PartialEmoji.from_dict(raw_emoji) + else: # 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 - def __ne__(self, other: PartialSoundboardSound) -> bool: + @override + def __ne__( + self, other: PartialSoundboardSound + ) -> bool: # pyright: ignore[reportIncompatibleMethodOverride] return not self.__eq__(other) @property @@ -89,22 +109,8 @@ def file(self) -> Asset: """:class:`Asset`: Returns the sound's file.""" return Asset._from_soundboard_sound(self, sound_id=self.id) - def _from_data( - self, data: PartialSoundboardSoundPayload | VoiceChannelEffectSendEventPayload - ) -> None: - self.id = int(data["sound_id"]) - self.volume = float(data.get("volume", 0)) or data.get("sound_volume") - if raw_emoji := data.get( - "emoji" - ): # From gateway event (VoiceChannelEffectSendEventPayload) - self.emoji = PartialEmoji.from_dict(raw_emoji) - elif emoji_id := data.get( - "emoji_id", 0 - ): # From HTTP response (PartialSoundboardSoundPayload) - self.emoji = PartialEmoji( - name=data.get("emoji_name"), - id=int(emoji_id) or None, - ) + def __repr__(self) -> str: + return f"" class SoundboardSound(PartialSoundboardSound): @@ -129,17 +135,12 @@ class SoundboardSound(PartialSoundboardSound): """ __slots__ = ( - "id", - "volume", "name", "available", - "emoji", "guild_id", - "_cs_guild", "user", - "_http", + "_cs_guild", "_state", - "emoji", ) def __init__( @@ -152,59 +153,42 @@ def __init__( self._state = state super().__init__(data, http) - def _from_data(self, data: SoundboardSoundPayload) -> None: + @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["guild_id"]) + 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: + def guild(self) -> Guild | None: """:class:`Guild`: The guild the sound belongs to. The :class:`Guild` object representing the guild the sound belongs to. .. versionadded:: 2.7 """ - return self._state._get_guild(self.guild_id) + return self._state._get_guild(self.guild_id) if self.guild_id else None - def __eq__(self, other: SoundboardSound) -> bool: + @override + def __eq__( + self, other: SoundboardSound + ) -> bool: # pyright: ignore[reportIncompatibleMethodOverride] return isinstance(other, SoundboardSound) and self.__dict__ == other.__dict__ - def delete(self): - return self._http.delete_sound(self) - - def _update(self, data: PartialSoundboardSound) -> None: - super()._update(data) - self.name = data["name"] - self.available = bool(data.get("available", True)) - - -class DefaultSoundboardSound(PartialSoundboardSound): - """Represents a default soundboard sound. - - Attributes - ---------- - id: :class:`int` - The sound's ID. - volume: :class:`float` - The sound's volume. - name: :class:`str` - The sound's name. - emoji: :class:`PartialEmoji` - The sound's emoji. - """ - - __slots__ = ("id", "volume", "name", "emoji", "_http") - - def __init__(self, *, http: HTTPClient, data: SoundboardSoundPayload) -> None: - super().__init__(data, http) - self.name = data["name"] - - def __eq__(self, other: DefaultSoundboardSound) -> bool: - return ( - isinstance(other, DefaultSoundboardSound) - 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 delete(self, *, reason: str | None = None) -> Coroutine[Any, Any, None]: + 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 4993f4f9f6..a44fe75d20 100644 --- a/discord/state.py +++ b/discord/state.py @@ -66,7 +66,7 @@ from .raw_models import * from .role import Role from .scheduled_events import ScheduledEvent -from .soundboard import DefaultSoundboardSound, PartialSoundboardSound, SoundboardSound +from .soundboard import PartialSoundboardSound, SoundboardSound from .stage_instance import StageInstance from .sticker import GuildSticker from .threads import Thread, ThreadMember @@ -2030,7 +2030,7 @@ def parse_soundboard_sounds(self, data) -> None: async def _add_default_sounds(self): default_sounds = await self.http.get_default_sounds() for default_sound in default_sounds: - sound = DefaultSoundboardSound(http=self.http, data=default_sound) + sound = SoundboardSound(http=self.http, data=default_sound) self._add_sound(sound) def _add_sound(self, sound: SoundboardSound): diff --git a/discord/types/channel.py b/discord/types/channel.py index c64855385f..60c0555837 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -188,8 +188,8 @@ class VoiceChannelEffectSendEvent(TypedDict): channel_id: Snowflake guild_id: Snowflake user_id: Snowflake - sound_id: Snowflake | int - sound_volume: float - emoji: PartialEmoji | None - animation_type: int - animation_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/soundboard.py b/discord/types/soundboard.py index a5d2293a55..431fa822ff 100644 --- a/discord/types/soundboard.py +++ b/discord/types/soundboard.py @@ -23,24 +23,21 @@ DEALINGS IN THE SOFTWARE. """ -from typing import Optional, TypedDict, Union +from __future__ import annotations -from .snowflake import Snowflake +from typing_extensions import NotRequired, TypedDict +from discord.types.user import User -class PartialSoundboardSound(TypedDict): - sound_id: Union[Snowflake, int] - emoji_name: Optional[str] - emoji_id: Optional[Snowflake] - volume: float +from .snowflake import Snowflake -class SoundboardSound(PartialSoundboardSound): - user_id: Snowflake +class SoundboardSound(TypedDict): name: str - guild_id: Snowflake + sound_id: Snowflake | int + volume: float + emoji_name: str | None + emoji_id: Snowflake | None + guild_id: NotRequired[Snowflake] + user: NotRequired[User] available: bool - - -class DefaultSoundboardSound(PartialSoundboardSound): - name: str From f03c2258afb8d0836c068e5b7b5d84603fdcd1b2 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 13:37:03 +0200 Subject: [PATCH 06/30] :bug: fix `PartialSoundboardSound.file` --- discord/soundboard.py | 9 +++++---- discord/state.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/discord/soundboard.py b/discord/soundboard.py index b17c05e773..455f2c58ac 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -63,14 +63,16 @@ class PartialSoundboardSound(Hashable): The sound's emoji. """ - __slots__ = ("id", "volume", "emoji", "_http") + __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( @@ -107,7 +109,7 @@ def __ne__( @property def file(self) -> Asset: """:class:`Asset`: Returns the sound's file.""" - return Asset._from_soundboard_sound(self, sound_id=self.id) + return Asset._from_soundboard_sound(self._state, sound_id=self.id) def __repr__(self) -> str: return f"" @@ -150,8 +152,7 @@ def __init__( http: HTTPClient, data: SoundboardSoundPayload, ) -> None: - self._state = state - super().__init__(data, http) + super().__init__(data, state, http) @override def _from_data( diff --git a/discord/state.py b/discord/state.py index a44fe75d20..716152ac8f 100644 --- a/discord/state.py +++ b/discord/state.py @@ -2008,7 +2008,7 @@ 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.http) + sound = PartialSoundboardSound(data, self, self.http) raw = VoiceChannelEffectSendEvent(data, self, sound) else: raw = VoiceChannelEffectSendEvent(data, self, None) From 90269f05bad684f058b2b0413717c5f6e51fc8a6 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 13:58:54 +0200 Subject: [PATCH 07/30] :sparkles: `VoiceChannel.send_soundboard_sound` --- discord/channel.py | 19 +++++++++++++++++++ discord/http.py | 20 +++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/discord/channel.py b/discord/channel.py index ba93c0a9ca..0a105c9f5e 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2014,6 +2014,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. diff --git a/discord/http.py b/discord/http.py index 111bb21cb3..8731983011 100644 --- a/discord/http.py +++ b/discord/http.py @@ -45,6 +45,7 @@ NotFound, ) from .gateway import DiscordClientWebSocketResponse +from .soundboard import PartialSoundboardSound, SoundboardSound from .utils import MISSING, warn_deprecated _log = logging.getLogger(__name__) @@ -54,7 +55,6 @@ from .enums import AuditLogAction, InteractionResponseType from .file import File - from .soundboard import SoundboardSound from .types import ( appinfo, application_role_connection, @@ -3251,3 +3251,21 @@ def edit_sound( json=payload, reason=reason, ) + + def send_soundboard_sound( + self, chanel_id: int, sound: PartialSoundboardSound + ) -> 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=chanel_id, + ), + json=payload, + ) From 35af4008f8a68947be91bdcc67d3d478993105e4 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 14:19:02 +0200 Subject: [PATCH 08/30] :sparkles: `SoundboardSound.edit` --- discord/http.py | 2 +- discord/soundboard.py | 73 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/discord/http.py b/discord/http.py index 8731983011..8b545b8d12 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3229,7 +3229,7 @@ def get_guild_sound( ) ) - def edit_sound( + def edit_guild_sound( self, guild_id: Snowflake, sound_Id: Snowflake, *, reason: str | None, **payload ): keys = ( diff --git a/discord/soundboard.py b/discord/soundboard.py index 455f2c58ac..7a8502a1f6 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -27,10 +27,10 @@ from typing import TYPE_CHECKING, Any, Coroutine -from typing_extensions import override, reveal_type +from typing_extensions import override from .asset import Asset -from .emoji import PartialEmoji +from .emoji import GuildEmoji, PartialEmoji, _EmojiTag from .mixins import Hashable from .types.channel import ( VoiceChannelEffectSendEvent as VoiceChannelEffectSendEventPayload, @@ -185,7 +185,76 @@ 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. + + Parameters + ---------- + name: :class:`str` + The new name of the sound. + volume: :class:`float` + The new volume of the sound. + emoji: Union[:class:`PartialEmoji`, :class:`str`] + The new emoji of the sound. + reason: :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, + } + 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 + + 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. + + Parameters + ---------- + reason: :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) From eac7e4be4384e0c988e550cfc2c450aef2b9114c Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 14:25:47 +0200 Subject: [PATCH 09/30] :adhesive_bandage: Small fix --- discord/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/state.py b/discord/state.py index 716152ac8f..f235941fcc 100644 --- a/discord/state.py +++ b/discord/state.py @@ -2030,7 +2030,7 @@ def parse_soundboard_sounds(self, data) -> None: async def _add_default_sounds(self): default_sounds = await self.http.get_default_sounds() for default_sound in default_sounds: - sound = SoundboardSound(http=self.http, data=default_sound) + sound = SoundboardSound(state=self, http=self.http, data=default_sound) self._add_sound(sound) def _add_sound(self, sound: SoundboardSound): From 3eb8dac11c55f43ea385f3a025b110946c02a72d Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 14:52:39 +0200 Subject: [PATCH 10/30] :memo: Docs --- discord/channel.py | 3 +++ discord/soundboard.py | 19 ++++++++++++++----- docs/api/events.rst | 11 +++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 0a105c9f5e..79a0f811d2 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -3266,6 +3266,9 @@ class VoiceChannelEffectSendEvent: .. versionadded:: 2.4 + .. versionchanged:: 2.7 + Added the `sound` attribute. + Attributes ---------- animation_type: :class:`int` diff --git a/discord/soundboard.py b/discord/soundboard.py index 7a8502a1f6..a9a07d55ce 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -61,6 +61,8 @@ class PartialSoundboardSound(Hashable): The sound's volume. emoji: :class:`PartialEmoji` The sound's emoji. + + .. versionadded:: 2.7 """ __slots__ = ("id", "volume", "emoji", "_http", "_state") @@ -127,13 +129,16 @@ class SoundboardSound(PartialSoundboardSound): name: :class:`str` The sound's name. available: :class:`bool` - Whether the sound is available. + Whether the sound is available. Could be ``False`` if the sound is not available. + This happens for example when the guild lost the boost level required to use the sound. emoji: :class:`PartialEmoji` The sound's emoji. - guild: :class:`Guild` - The guild the sound belongs to. - owner: :class:`Member` - The sound's owner. + guild: :class:`Guild` | :class:`None` + The guild the sound belongs to. Could be ``None`` if the sound is a default sound. + owner: :class:`User` + The sound's owner. Could be ``None`` if the sound is a default sound. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -195,6 +200,8 @@ def edit( ) -> Coroutine[Any, Any, SoundboardSound]: """Edits the sound. + .. versionadded:: 2.7 + Parameters ---------- name: :class:`str` @@ -245,6 +252,8 @@ def edit( def delete(self, *, reason: str | None = None) -> Coroutine[Any, Any, None]: """Deletes the sound. + .. versionadded:: 2.7 + Parameters ---------- reason: :class:`str` diff --git a/docs/api/events.rst b/docs/api/events.rst index 51652ad925..7ed5d9a41f 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1377,3 +1377,14 @@ 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` From 04d6e1594dce20985cc131e8bbcbdb35f944fafa Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 15:17:43 +0200 Subject: [PATCH 11/30] :recycle: Cleanup --- discord/channel.py | 11 ++++++----- discord/client.py | 2 +- discord/enums.py | 5 ++++- discord/guild.py | 4 +++- discord/http.py | 8 ++++---- discord/soundboard.py | 35 +++++++++++++++++------------------ discord/state.py | 1 - 7 files changed, 35 insertions(+), 31 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 79a0f811d2..6cb5fce14d 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -77,7 +77,6 @@ "PartialMessageable", "ForumChannel", "ForumTag", - # "VoiceChannelEffect", "VoiceChannelEffectSendEvent", ) @@ -3254,6 +3253,11 @@ def get_partial_message(self, message_id: int, /) -> PartialMessage: class VoiceChannelEffectAnimation(NamedTuple): + """Represents an animation that can be sent to a voice channel. + + .. versionadded:: 2.7 + """ + id: int type: VoiceChannelEffectAnimationType @@ -3264,10 +3268,7 @@ class VoiceChannelSoundEffect(PartialSoundboardSound): ... class VoiceChannelEffectSendEvent: """Represents the payload for an :func:`on_voice_channel_effect_send` - .. versionadded:: 2.4 - - .. versionchanged:: 2.7 - Added the `sound` attribute. + .. versionadded:: 2.7 Attributes ---------- diff --git a/discord/client.py b/discord/client.py index 276d52d1cf..f2d22f5505 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2275,7 +2275,7 @@ async def delete_emoji(self, emoji: Snowflake) -> None: def get_sound(self, sound_id: int) -> SoundboardSound | None: """Gets a :class:`.Sound` from the bot's sound cache. - .. versionadded:: 2.4 + .. versionadded:: 2.7 Parameters ---------- diff --git a/discord/enums.py b/discord/enums.py index fd671cb2ce..8f928af948 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1055,7 +1055,10 @@ class PollLayoutType(Enum): class VoiceChannelEffectAnimationType(Enum): - """Voice channel effect animation type""" + """Voice channel effect animation type + + .. versionadded:: 2.7 + """ premium = 0 basic = 1 diff --git a/discord/guild.py b/discord/guild.py index 44b373f04d..c1a5d47b79 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -821,7 +821,7 @@ def categories(self) -> list[CategoryChannel]: def sounds(self) -> list[SoundboardSound]: """A list of soundboard sounds that belong to this guild. - .. versionadded:: 2.5 + .. versionadded:: 2.7 This is sorted by the position and are in UI order from top to bottom. """ @@ -4316,6 +4316,8 @@ def entitlements( def get_sound(self, sound_id: int): """Returns a sound with the given ID. + .. versionadded :: 2.7 + Parameters ---------- sound_id: :class:`int` diff --git a/discord/http.py b/discord/http.py index 8b545b8d12..0b5bff972e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3230,7 +3230,7 @@ def get_guild_sound( ) def edit_guild_sound( - self, guild_id: Snowflake, sound_Id: Snowflake, *, reason: str | None, **payload + self, guild_id: Snowflake, sound_id: Snowflake, *, reason: str | None, **payload ): keys = ( "name", @@ -3246,14 +3246,14 @@ def edit_guild_sound( "PATCH", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, - sound_id=sound_Id, + sound_id=sound_id, ), json=payload, reason=reason, ) def send_soundboard_sound( - self, chanel_id: int, sound: PartialSoundboardSound + self, channel_id: int, sound: PartialSoundboardSound ) -> None: payload = { "sound_id": sound.id, @@ -3265,7 +3265,7 @@ def send_soundboard_sound( Route( "POST", "/channels/{channel_id}/send-soundboard-sound", - channel_id=chanel_id, + channel_id=channel_id, ), json=payload, ) diff --git a/discord/soundboard.py b/discord/soundboard.py index a9a07d55ce..4d1da9338b 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -30,7 +30,7 @@ from typing_extensions import override from .asset import Asset -from .emoji import GuildEmoji, PartialEmoji, _EmojiTag +from .emoji import PartialEmoji, _EmojiTag from .mixins import Hashable from .types.channel import ( VoiceChannelEffectSendEvent as VoiceChannelEffectSendEventPayload, @@ -53,16 +53,16 @@ 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` - The sound's emoji. - - .. versionadded:: 2.7 + emoji: :class:`PartialEmoji` | :class:`None` + The sound's emoji. Could be ``None`` if the sound has no emoji. """ __slots__ = ("id", "volume", "emoji", "_http", "_state") @@ -84,11 +84,14 @@ def _from_data( 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) - else: # From HTTP response (SoundboardSoundPayload) + 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, @@ -120,25 +123,25 @@ def __repr__(self) -> str: 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 happens for example when the guild lost the boost level required to use the sound. - emoji: :class:`PartialEmoji` - The sound's emoji. - guild: :class:`Guild` | :class:`None` - The guild the sound belongs to. Could be ``None`` if the sound is a default sound. - owner: :class:`User` + guild_id: :class:`int` | :class:`None` + The ID of the guild the sound belongs to. 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. - - .. versionadded:: 2.7 """ __slots__ = ( @@ -172,11 +175,7 @@ def _from_data( @cached_slot_property("_cs_guild") def guild(self) -> Guild | None: - """:class:`Guild`: The guild the sound belongs to. - - The :class:`Guild` object representing the guild the sound belongs to. - .. versionadded:: 2.7 - """ + """: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 diff --git a/discord/state.py b/discord/state.py index f235941fcc..c3adac8485 100644 --- a/discord/state.py +++ b/discord/state.py @@ -2004,7 +2004,6 @@ def create_message( return Message(state=self, channel=channel, data=data) def parse_voice_channel_effect_send(self, data) -> None: - __import__("json") if sound_id := int(data.get("sound_id", 0)): sound = self._get_sound(sound_id) if sound is None: From 93a52b3e14c72fbf951791537fb24389e91cf816 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 15:42:38 +0200 Subject: [PATCH 12/30] :memo: Add `soundboard.py` example :tada: --- examples/soundboard.py | 170 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 examples/soundboard.py diff --git a/examples/soundboard.py b/examples/soundboard.py new file mode 100644 index 0000000000..8fef00b184 --- /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(intents=discord.Intents.all()) + + +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 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, + ): + """Edit 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) From 437381661ae400df99f320f572e12f90ae2d9d42 Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 28 Oct 2024 08:43:00 +0100 Subject: [PATCH 13/30] :memo: Add objects to docs --- discord/channel.py | 2 +- docs/api/models.rst | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/discord/channel.py b/discord/channel.py index 6cb5fce14d..b53e5aeaf7 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -3285,7 +3285,7 @@ class VoiceChannelEffectSendEvent: channel: :class:`VoiceChannel` The voice channel that the sound is being sent in. data: :class:`dict` - The raw data sent by the gateway([#6025](https://github.com/discord/discord-api-docs/pull/6025)). + The raw data sent by the gateway. """ __slots__ = ( diff --git a/docs/api/models.rst b/docs/api/models.rst index 0238e2fc77..4aa277b230 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -494,6 +494,20 @@ Stickers .. autoclass:: GuildSticker() :members: +Soundboard +---------- + +.. attributetable:: PartialSoundboardSound + +.. autoclass:: PartialSoundboardSound() + :members: + +.. attributetable:: SoundboardSound + +.. autoclass:: SoundboardSound() + :members: + :inherited-members: + Events ------ @@ -577,6 +591,11 @@ Events .. autoclass:: RawVoiceChannelStatusUpdateEvent() :members: +.. attributetable:: VoiceChannelEffectSendEvent + +.. autoclass:: VoiceChannelEffectSendEvent() + :members: + Webhooks From 9a08df507400886922fd85d42c3cfa23af295ac8 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 25 Oct 2024 15:50:04 +0200 Subject: [PATCH 14/30] :memo: CHANGELOG.md --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d77dee89d3..8b3e6cecbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,15 @@ These changes are available on the `master` branch, but have not yet been releas ([#2587](https://github.com/Pycord-Development/pycord/pull/2587/)) - Added optional `filter` parameter to `utils.basic_autocomplete()`. ([#2590](https://github.com/Pycord-Development/pycord/pull/2590)) +- Added soundboard support :tada: + - Manage guild soundboard sounds with `Guild.fetch_sounds()`, `Guild.create_sound()`, + `SoundboardSound.edit()`, and `SoundboardSound.delete()` + - Access Discord's 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)) ### Fixed From 636273ab5b63e36210e051897f55e3c0abeb9b04 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 3 Nov 2024 16:10:00 +0000 Subject: [PATCH 15/30] style(pre-commit): auto fixes from pre-commit.com hooks --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03695ed704..650263b5e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ These changes are available on the `master` branch, but have not yet been releas - 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)) + ([#2623](https://github.com/Pycord-Development/pycord/pull/2623)) ### Fixed From 8238d7c19511112b0aea6c0682139a42f77bbbfd Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 7 Nov 2024 09:48:11 +0100 Subject: [PATCH 16/30] :bug: `partial_emoji` could be unbound --- discord/soundboard.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/discord/soundboard.py b/discord/soundboard.py index 4d1da9338b..8d913270fb 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -230,13 +230,12 @@ def edit( "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) - else: - partial_emoji = None if partial_emoji is not None: if partial_emoji.id is None: From a6009657d4c6a94a65721e8f558ac70f82444f2a Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 7 Nov 2024 09:50:01 +0100 Subject: [PATCH 17/30] :sparkles: Soundboard gateway events --- discord/state.py | 36 ++++++++++++++++++++++++++ docs/api/events.rst | 62 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/discord/state.py b/discord/state.py index c3adac8485..5e75a65e9d 100644 --- a/discord/state.py +++ b/discord/state.py @@ -87,6 +87,7 @@ from .types.guild import Guild as GuildPayload from .types.message import Message as MessagePayload from .types.poll import Poll as PollPayload + from .types.soundboard import SounboardSound as SoundboardSoundPayload from .types.sticker import GuildSticker as GuildStickerPayload from .types.user import User as UserPayload from .voice_client import VoiceClient @@ -2017,6 +2018,11 @@ def parse_voice_channel_effect_send(self, data) -> None: 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"]: @@ -2026,6 +2032,36 @@ def parse_soundboard_sounds(self, data) -> None: ) ) + 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) + async def _add_default_sounds(self): default_sounds = await self.http.get_default_sounds() for default_sound in default_sounds: diff --git a/docs/api/events.rst b/docs/api/events.rst index 7ed5d9a41f..35c6a3a812 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1388,3 +1388,65 @@ Voice Channel Effects :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 looses 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 looses 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_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` From 40c9093252fade1060d9a997a513342bad90c426 Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 7 Nov 2024 10:02:56 +0100 Subject: [PATCH 18/30] :bug: Add types and raw delete event --- discord/guild.py | 2 +- discord/http.py | 6 +++--- discord/raw_models.py | 12 ++++++++++++ discord/state.py | 11 +++++++---- docs/api/events.rst | 9 +++++++++ 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index c1a5d47b79..0dc9f7663d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -626,7 +626,7 @@ async def create_sound( 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. diff --git a/discord/http.py b/discord/http.py index ecc64c845e..9a3fed2a68 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3191,7 +3191,7 @@ def delete_sound( reason=reason, ) - def get_default_sounds(self): + def get_default_sounds(self) -> Response[list[SoundboardSoundPayload]]: return self.request(Route("GET", "/soundboard-default-sounds")) def create_guild_sound( @@ -3234,7 +3234,7 @@ def get_guild_sound( def edit_guild_sound( self, guild_id: Snowflake, sound_id: Snowflake, *, reason: str | None, **payload - ): + ) -> Response[SoundboardSoundPayload]: keys = ( "name", "volume", @@ -3257,7 +3257,7 @@ def edit_guild_sound( def send_soundboard_sound( self, channel_id: int, sound: PartialSoundboardSound - ) -> None: + ) -> Response[None]: payload = { "sound_id": sound.id, } diff --git a/discord/raw_models.py b/discord/raw_models.py index dfa3895725..93d9dd3b1a 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -87,6 +87,7 @@ "RawAuditLogEntryEvent", "RawVoiceChannelStatusUpdateEvent", "RawMessagePollVoteEvent", + "RawSoundboardSoundDeleteEvent", ) @@ -847,3 +848,14 @@ 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 a :func:`on_raw_soundboard_sound_delete`""" + + __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/state.py b/discord/state.py index 5e75a65e9d..88ac8cc3a1 100644 --- a/discord/state.py +++ b/discord/state.py @@ -87,7 +87,7 @@ from .types.guild import Guild as GuildPayload from .types.message import Message as MessagePayload from .types.poll import Poll as PollPayload - from .types.soundboard import SounboardSound as SoundboardSoundPayload + from .types.soundboard import SoundboardSoundDeletePayload from .types.sticker import GuildSticker as GuildStickerPayload from .types.user import User as UserPayload from .voice_client import VoiceClient @@ -2061,17 +2061,20 @@ def parse_guild_soundboard_sound_delete(self, data): 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): + 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): + def _add_sound(self, sound: SoundboardSound) -> None: self._sounds[sound.id] = sound - def _remove_sound(self, sound: SoundboardSound): + def _remove_sound(self, sound: SoundboardSound) -> None: self._sounds.pop(sound.id, None) @property diff --git a/docs/api/events.rst b/docs/api/events.rst index 35c6a3a812..b5038f3c4b 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1442,6 +1442,15 @@ Soundboard Sound :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. From 0528a8cab06c5df5c1e6be6a557b3b359acdaa32 Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 7 Nov 2024 10:52:04 +0100 Subject: [PATCH 19/30] :memo: Fix typo --- discord/guild.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 0dc9f7663d..0ab978a2cc 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4313,7 +4313,7 @@ def entitlements( exclude_ended=exclude_ended, ) - def get_sound(self, sound_id: int): + def get_sound(self, sound_id: int) -> Soundboard | None: """Returns a sound with the given ID. .. versionadded :: 2.7 @@ -4325,7 +4325,7 @@ def get_sound(self, sound_id: int): Returns ------- - Optional[:class:`Sound`] + Optional[:class:`SoundboardSound`] The sound or ``None`` if not found. """ return self._sounds.get(sound_id) From 0bfe575d5830d3223a4f914e0b2063dd4c6f031f Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 7 Nov 2024 10:55:07 +0100 Subject: [PATCH 20/30] :memo: Fix CHANGELOG.md --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 650263b5e0..086b2cd2d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,14 +36,14 @@ These changes are available on the `master` branch, but have not yet been releas `Permissions.use_external_sounds` and `Permissions.view_creator_monetization_analytics`. ([#2620](https://github.com/Pycord-Development/pycord/pull/2620)) -- Added soundboard features +- Added soundboard features: - Manage guild soundboard sounds with `Guild.fetch_sounds()`, `Guild.create_sound()`, - `SoundboardSound.edit()`, and `SoundboardSound.delete()` - - Access Discord's 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 + `SoundboardSound.edit()`, and `SoundboardSound.delete()`. + - Access Discord's 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` + `Guild.soundboard_limit`. ([#2623](https://github.com/Pycord-Development/pycord/pull/2623)) ### Fixed From e8ebba91abab6389c731c0815bd9871efc40a7c1 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 8 Nov 2024 19:07:58 +0100 Subject: [PATCH 21/30] :coffin: This is dead --- discord/state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/state.py b/discord/state.py index 88ac8cc3a1..a904a51020 100644 --- a/discord/state.py +++ b/discord/state.py @@ -87,7 +87,6 @@ from .types.guild import Guild as GuildPayload from .types.message import Message as MessagePayload from .types.poll import Poll as PollPayload - from .types.soundboard import SoundboardSoundDeletePayload from .types.sticker import GuildSticker as GuildStickerPayload from .types.user import User as UserPayload from .voice_client import VoiceClient From 6f71350a304e25e64637e3823ff444410d8daa12 Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 10 Dec 2024 22:26:20 +0100 Subject: [PATCH 22/30] :heavy_plus_sign: Add `typing_extensions` to reqs --- requirements/_.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/_.txt b/requirements/_.txt index 5305a96bd1..e8be976f05 100644 --- a/requirements/_.txt +++ b/requirements/_.txt @@ -1,2 +1,2 @@ aiohttp>=3.6.0,<4.0 -typing_extensions>=4,<5; python_version < "3.11" +typing_extensions>=4.5.0,<5 From 2bd05aa097b20a12872088aa344bbe544eee44fb Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 12 Dec 2024 13:22:49 +0100 Subject: [PATCH 23/30] :pencil2: Grammar and stuff --- CHANGELOG.md | 4 ++-- discord/channel.py | 8 ++++---- discord/raw_models.py | 2 +- discord/soundboard.py | 4 ++-- docs/api/events.rst | 4 ++-- examples/soundboard.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c096cc08c6..b81ff3ae01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,10 +36,10 @@ These changes are available on the `master` branch, but have not yet been releas `Permissions.use_external_sounds` and `Permissions.view_creator_monetization_analytics`. ([#2620](https://github.com/Pycord-Development/pycord/pull/2620)) -- Added soundboard features: +- Added the following soundboard related features: - Manage guild soundboard sounds with `Guild.fetch_sounds()`, `Guild.create_sound()`, `SoundboardSound.edit()`, and `SoundboardSound.delete()`. - - Access Discord's default sounds with `Client.fetch_default_sounds()`. + - 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 diff --git a/discord/channel.py b/discord/channel.py index b53e5aeaf7..70089e5271 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -3277,13 +3277,13 @@ class VoiceChannelEffectSendEvent: animation_id: :class:`int` The ID of the animation that is being sent. sound: Optional[:class:`SoundboardSound`] - The sound that is being sent, might be None if the effect is not a sound effect. + The sound that is being sent, could be ``None`` if the effect is not a sound effect. guild: :class:`Guild` - The guild that the sound is being sent in. + The guild in which the sound is being sent. user: :class:`Member` - The member that is sending the sound. + The member that sent the sound. channel: :class:`VoiceChannel` - The voice channel that the sound is being sent in. + The voice channel in which the sound is being sent. data: :class:`dict` The raw data sent by the gateway. """ diff --git a/discord/raw_models.py b/discord/raw_models.py index 93d9dd3b1a..6bf33d4977 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -851,7 +851,7 @@ def __init__(self, data: MessagePollVoteEvent, added: bool) -> None: class RawSoundboardSoundDeleteEvent(_RawReprMixin): - """Represents the payload for a :func:`on_raw_soundboard_sound_delete`""" + """Represents the payload for an :func:`on_raw_soundboard_sound_delete`""" __slots__ = ("sound_id", "guild_id", "data") diff --git a/discord/soundboard.py b/discord/soundboard.py index 8d913270fb..3d77d7976a 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -137,9 +137,9 @@ class SoundboardSound(PartialSoundboardSound): The sound's name. available: :class:`bool` Whether the sound is available. Could be ``False`` if the sound is not available. - This happens for example when the guild lost the boost level required to use the sound. + 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 the sound belongs to. Could be :class:`None` if the sound is a default sound. + 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. """ diff --git a/docs/api/events.rst b/docs/api/events.rst index b5038f3c4b..d33b65eb14 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1394,7 +1394,7 @@ 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 looses a boost level and some sounds become unavailable. + This is called, for example, when a guild loses a boost level and some sounds become unavailable. .. versionadded:: 2.7 @@ -1406,7 +1406,7 @@ Soundboard Sound .. 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 looses a boost level and some sounds become unavailable. + This is called, for example, when a guild loses a boost level and some sounds become unavailable. .. versionadded:: 2.7 diff --git a/examples/soundboard.py b/examples/soundboard.py index 8fef00b184..fa47066f72 100644 --- a/examples/soundboard.py +++ b/examples/soundboard.py @@ -11,7 +11,7 @@ load_dotenv() TOKEN = os.getenv("TOKEN") -bot = discord.Bot(intents=discord.Intents.all()) +bot = discord.Bot() class SoundboardCog(discord.Cog): From e7f271ed17af6099101fb7b88060a99dc976cdbf Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 17 Dec 2024 08:30:41 +0100 Subject: [PATCH 24/30] :pencil2: Grammar --- CHANGELOG.md | 2 +- discord/channel.py | 2 +- discord/enums.py | 2 +- discord/guild.py | 2 +- discord/raw_models.py | 5 ++++- examples/soundboard.py | 4 ++-- 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b81ff3ae01..a6b0cd2b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ These changes are available on the `master` branch, but have not yet been releas `Permissions.use_external_sounds` and `Permissions.view_creator_monetization_analytics`. ([#2620](https://github.com/Pycord-Development/pycord/pull/2620)) -- Added the following soundboard related features: +- 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()`. diff --git a/discord/channel.py b/discord/channel.py index 70089e5271..4bbb5a9d90 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -3266,7 +3266,7 @@ class VoiceChannelSoundEffect(PartialSoundboardSound): ... class VoiceChannelEffectSendEvent: - """Represents the payload for an :func:`on_voice_channel_effect_send` + """Represents the payload for an :func:`on_voice_channel_effect_send`. .. versionadded:: 2.7 diff --git a/discord/enums.py b/discord/enums.py index d351e96255..0a8f871407 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1056,7 +1056,7 @@ class PollLayoutType(Enum): class VoiceChannelEffectAnimationType(Enum): - """Voice channel effect animation type + """Voice channel effect animation type. .. versionadded:: 2.7 """ diff --git a/discord/guild.py b/discord/guild.py index 0ab978a2cc..2e2167ba9d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -639,7 +639,7 @@ async def create_sound( The name of the sound. sound: :class:`bytes` The :term:`py:bytes-like object` representing the sound data. - Only MP3 sound files that don't exceed the duration of 5.2s are supported. + 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: Union[:class:`PartialEmoji`, :class:`GuildEmoji`, :class:`str`] diff --git a/discord/raw_models.py b/discord/raw_models.py index 6bf33d4977..d61c584d87 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -851,7 +851,10 @@ def __init__(self, data: MessagePollVoteEvent, added: bool) -> None: class RawSoundboardSoundDeleteEvent(_RawReprMixin): - """Represents the payload for an :func:`on_raw_soundboard_sound_delete`""" + """Represents the payload for an :func:`on_raw_soundboard_sound_delete`. + + .. versionadded 2.7 + """ __slots__ = ("sound_id", "guild_id", "data") diff --git a/examples/soundboard.py b/examples/soundboard.py index fa47066f72..d19faa666d 100644 --- a/examples/soundboard.py +++ b/examples/soundboard.py @@ -32,7 +32,7 @@ async def on_voice_channel_effect_send( @discord.slash_command() async def list_sounds(self, ctx: discord.ApplicationContext): - """Lists all available sounds in the guild.""" + """Lists all the available sounds in the guild.""" await ctx.defer() # Fetch both default and guild-specific sounds @@ -98,7 +98,7 @@ async def edit_sound( new_emoji: str | None = None, new_volume: float | None = None, ): - """Edit an existing sound in the guild's soundboard.""" + """Edits an existing sound in the guild's soundboard.""" await ctx.defer() # Find the sound by name From a25ccdb50876dad5c06439ba9777832cd15e4c5d Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 17 Dec 2024 08:37:06 +0100 Subject: [PATCH 25/30] :memo: Add `VoiceChannelEffectAnimationType` --- docs/api/enums.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/api/enums.rst b/docs/api/enums.rst index cd48a85cf5..15062e7d76 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2500,3 +2500,17 @@ of :class:`enum.Enum`. .. attribute:: private_channel 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. From 8dd4b4feed460d5282147d1a82c6c56566e3f08d Mon Sep 17 00:00:00 2001 From: Paillat Date: Wed, 18 Dec 2024 17:54:49 +0100 Subject: [PATCH 26/30] chore: :alien: Update base max filesize to `10` Mb (#2671) * :alien: Update base max filesize to `10` Mb * :memo: CHANGELOG.md --- CHANGELOG.md | 2 ++ discord/guild.py | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 861f66ec0f..3c8d779160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,8 @@ These changes are available on the `master` branch, but have not yet been releas - Replaced audioop (deprecated module) implementation of `PCMVolumeTransformer.read` method with a pure Python equivalent. ([#2176](https://github.com/Pycord-Development/pycord/pull/2176)) +- Updated `Guild.filesize_limit` to 10 Mb instead of 25 Mb following Discord's API + changes. ([#2671](https://github.com/Pycord-Development/pycord/pull/2671)) ### Deprecated diff --git a/discord/guild.py b/discord/guild.py index b1e937d07b..337abd31c0 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -289,11 +289,11 @@ class Guild(Hashable): ) _PREMIUM_GUILD_LIMITS: ClassVar[dict[int | None, _GuildLimit]] = { - None: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), - 0: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), - 1: _GuildLimit(emoji=100, stickers=15, bitrate=128e3, filesize=26214400), - 2: _GuildLimit(emoji=150, stickers=30, bitrate=256e3, filesize=52428800), - 3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104857600), + 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), } def __init__(self, *, data: GuildPayload, state: ConnectionState): From b6b9220289d4785947c7c85655582a1bcbe2656b Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 6 Jan 2025 09:31:42 +0100 Subject: [PATCH 27/30] :memo: Requested changes --- discord/client.py | 6 +++--- discord/guild.py | 4 ++-- discord/soundboard.py | 11 +++++------ discord/types/soundboard.py | 1 - 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/discord/client.py b/discord/client.py index a016c15957..e8ab2e1a9c 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2293,8 +2293,8 @@ def get_sound(self, sound_id: int) -> SoundboardSound | None: Returns ------- - :class:`.Sound` - The sound from the ID. + Optional[:class:`.SoundboardSound`] + The sound with the given ID. """ return self._connection._get_sound(sound_id) @@ -2315,7 +2315,7 @@ async def fetch_default_sounds(self) -> list[SoundboardSound]: Returns ------- - List[:class:`.Sound`] + List[:class:`.SoundboardSound`] The bot's default sounds. """ data = await self._connection.http.get_default_sounds() diff --git a/discord/guild.py b/discord/guild.py index 46e66ff9ad..33e9f25941 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -621,7 +621,7 @@ async def create_sound( Creates a :class:`SoundboardSound` in the guild. You must have :attr:`Permissions.manage_expressions` permission to use this. - .. versionadded:: 2.4 + .. versionadded:: 2.7 Parameters ---------- @@ -632,7 +632,7 @@ async def create_sound( 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: Union[:class:`PartialEmoji`, :class:`GuildEmoji`, :class:`str`] + 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. diff --git a/discord/soundboard.py b/discord/soundboard.py index 3d77d7976a..6fae037090 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -1,7 +1,6 @@ """ The MIT License (MIT) -Copyright (c) 2015-2021 Rapptz Copyright (c) 2021-present Pycord Development Permission is hereby granted, free of charge, to any person obtaining a @@ -203,13 +202,13 @@ def edit( Parameters ---------- - name: :class:`str` + name: Optional[:class:`str`] The new name of the sound. - volume: :class:`float` + volume: Optional[:class:`float`] The new volume of the sound. - emoji: Union[:class:`PartialEmoji`, :class:`str`] + emoji: Optional[Union[:class:`PartialEmoji`, :class:`str`]] The new emoji of the sound. - reason: :class:`str` + reason: Optional[:class:`str`] The reason for editing the sound. Shows up in the audit log. Returns @@ -254,7 +253,7 @@ def delete(self, *, reason: str | None = None) -> Coroutine[Any, Any, None]: Parameters ---------- - reason: :class:`str` + reason: Optional[:class:`str`] The reason for deleting the sound. Shows up in the audit log. Raises diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py index 431fa822ff..9a4c19b0ed 100644 --- a/discord/types/soundboard.py +++ b/discord/types/soundboard.py @@ -1,7 +1,6 @@ """ The MIT License (MIT) -Copyright (c) 2015-2021 Rapptz Copyright (c) 2021-present Pycord Development Permission is hereby granted, free of charge, to any person obtaining a From 2a705bb1340af9d46aca796ad8781cf03d33de08 Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 6 Jan 2025 09:39:34 +0100 Subject: [PATCH 28/30] :adhesive_bandage: Merge broke stuff --- discord/guild.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 33e9f25941..4ec97713f6 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -292,11 +292,21 @@ class Guild(Hashable): ) _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): From cc9cecfb2ade47e8e80c99eae3b119e1fba77625 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 12:45:57 +0000 Subject: [PATCH 29/30] style(pre-commit): auto fixes from pre-commit.com hooks --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 63e72488fc..d206bb78b8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -493,7 +493,7 @@ def write_new(): r".*/old_changelog.*", r"migrating_to_v1.*", r"migrating_to_v2.*", - r"old_changelog.*" + r"old_changelog.*", ] linkcheck_anchors_ignore_for_url = [r"https://github.com/Delitefully/DiscordLists"] From c01a39a566ef0801078bbf078214ebfa1b3955ec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 19:04:10 +0000 Subject: [PATCH 30/30] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/enums.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/enums.py b/discord/enums.py index afceb496d8..c0a1d3270f 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1073,6 +1073,7 @@ class VoiceChannelEffectAnimationType(Enum): premium = 0 basic = 1 + class MessageReferenceType(Enum): """The type of the message reference object"""