From 6695f5b849b1c1315e4263a262cffcf925421a85 Mon Sep 17 00:00:00 2001 From: Dasupergrasskakjd Date: Tue, 16 Jan 2024 20:03:25 +0000 Subject: [PATCH] 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 26b584ba7e..1824d23abf 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)