From 56a9a7ded25a1b19879bb5a65dd851d98bdbf738 Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sun, 9 Apr 2023 16:26:18 +0200 Subject: [PATCH 01/30] Initial commit Finished: VC effect + VC sound effect Started: Soundboard support --- discord/__init__.py | 1 + discord/channel.py | 156 +++++++++++++++++++++++++++++++++++- discord/client.py | 21 +++++ discord/enums.py | 6 ++ discord/http.py | 6 ++ discord/soundboard.py | 132 ++++++++++++++++++++++++++++++ discord/state.py | 8 ++ discord/types/channel.py | 13 +++ discord/types/emoji.py | 2 + discord/types/gateway.py | 3 +- discord/types/soundboard.py | 39 +++++++++ docs/api.rst | 63 +++++++++++++++ 12 files changed, 447 insertions(+), 3 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 39fdb26737ec..f9a7dc475619 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -68,6 +68,7 @@ from .components import * from .threads import * from .automod import * +from .soundboard import * class VersionInfo(NamedTuple): diff --git a/discord/channel.py b/discord/channel.py index 3c93832f3230..77e82efad225 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -47,7 +47,16 @@ import discord.abc from .scheduled_event import ScheduledEvent from .permissions import PermissionOverwrite, Permissions -from .enums import ChannelType, ForumLayoutType, ForumOrderType, PrivacyLevel, try_enum, VideoQualityMode, EntityType +from .enums import ( + ChannelType, + ForumLayoutType, + ForumOrderType, + PrivacyLevel, + try_enum, + VideoQualityMode, + EntityType, + VoiceChannelEffectAnimationType, +) from .mixins import Hashable from . import utils from .utils import MISSING @@ -58,6 +67,9 @@ from .partial_emoji import _EmojiTag, PartialEmoji from .flags import ChannelFlags from .http import handle_message_parameters +from .object import Object +from .soundboard import BaseSoundboardSound +from .utils import snowflake_time __all__ = ( 'TextChannel', @@ -69,6 +81,8 @@ 'ForumChannel', 'GroupChannel', 'PartialMessageable', + 'VoiceChannelEffect', + 'VoiceChannelSoundEffect', ) if TYPE_CHECKING: @@ -76,7 +90,6 @@ from .types.threads import ThreadArchiveDuration from .role import Role - from .object import Object from .member import Member, VoiceState from .abc import Snowflake, SnowflakeTime from .embeds import Embed @@ -99,8 +112,10 @@ GroupDMChannel as GroupChannelPayload, ForumChannel as ForumChannelPayload, ForumTag as ForumTagPayload, + VoiceChannelEffect as VoiceChannelEffectPayload, ) from .types.snowflake import SnowflakeList + from .types.soundboard import BaseSoundboardSound as BaseSoundboardSoundPayload OverwriteKeyT = TypeVar('OverwriteKeyT', Role, BaseUser, Object, Union[Role, Member, Object]) @@ -110,6 +125,143 @@ class ThreadWithMessage(NamedTuple): message: Message +class VoiceChannelEffectAnimation(NamedTuple): + id: int + type: VoiceChannelEffectAnimationType + + +class VoiceChannelSoundEffect(BaseSoundboardSound): + """Represents a Discord voice channel sound effect. + + .. versionadded:: 2.3 + + .. container:: operations + + .. describe:: x == y + + Checks if two sound effects are equal. + + .. describe:: x != y + + Checks if two sound effects are not equal. + + .. describe:: hash(x) + + Returns the sound effect's hash. + + Attributes + ------------ + id: :class:`int` + The ID of the sound. + volume: :class:`float` + The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). + override_path: Optional[:class:`str`] + The override path of the sound (e.g. 'default_quack.mp3'). + """ + + __slots__ = ('_state',) + + def __init__(self, *, state: ConnectionState, id: int, volume: float, override_path: Optional[str]): + self._state: ConnectionState = state + data: BaseSoundboardSoundPayload = { + 'sound_id': id, + 'volume': volume, + 'override_path': override_path, + } + super().__init__(data=data) + + def __repr__(self) -> str: + attrs = [ + ('id', self.id), + ('volume', self.volume), + ] + inner = ' '.join('%s=%r' % t for t in attrs) + return f"<{self.__class__.__name__} {inner}>" + + @property + def created_at(self) -> Optional[datetime.datetime]: + """:class:`datetime.datetime`: Returns the snowflake's creation time in UTC. + Returns ``None`` if it's a default sound.""" + if self.is_default(): + return None + else: + return snowflake_time(self.id) + + async def is_default(self) -> bool: + """|coro| + + Checks if the sound is a default sound. + + Returns + --------- + :class:`bool` + Whether it's a default sound or not. + """ + default_sounds = await self._state.http.get_default_soundboard_sounds() + default_sounds = [int(sound['sound_id']) for sound in default_sounds] + + return self.id in default_sounds + + +class VoiceChannelEffect: + """Represents a Discord voice channel effect. + + .. versionadded:: 2.3 + + Attributes + ------------ + channel: :class:`VoiceChannel` + The channel in which the effect is sent. + user: :class:`Member` + The user who sent the effect. + animation: Optional[:class:`VoiceChannelEffectAnimation`] + The animation the effect has. Returns ``None`` if the effect has no animation. + emoji: Optional[:class:`PartialEmoji`] + The emoji of the effect. + sound: Optional[:class:`VoiceChannelSoundEffect`] + The sound of the effect. Returns ``None`` if it's an emoji effect. + """ + + __slots__ = ('channel', 'user', 'animation', 'emoji', 'sound') + + def __init__(self, *, state: ConnectionState, data: VoiceChannelEffectPayload, guild: Guild): + self.channel: VoiceChannel = guild.get_channel(int(data['channel_id'])) # type: ignore # will always be a VoiceChannel + self.user: Member = guild.get_member(int(data['user_id'])) # type: ignore # will always be a Member + self.animation: Optional[VoiceChannelEffectAnimation] = None + + animation_id = data.get('animation_id') + if animation_id is not None: + animation_type = try_enum(VoiceChannelEffectAnimationType, data['animation_type']) # type: ignore # cannot be None here + self.animation = VoiceChannelEffectAnimation(id=animation_id, type=animation_type) + + emoji = data['emoji'] + self.emoji: Optional[PartialEmoji] = PartialEmoji.from_dict(emoji) if emoji is not None else None + self.sound: Optional[VoiceChannelSoundEffect] = None + + sound_id: Optional[int] = utils._get_as_snowflake(data, 'sound_id') + if sound_id is not None: + sound_volume = data['sound_volume'] # type: ignore # sound_volume cannot be None here + sound_override_path = data.get('sound_override_path') + self.sound = VoiceChannelSoundEffect( + state=state, id=sound_id, volume=sound_volume, override_path=sound_override_path + ) + + def __repr__(self) -> str: + attrs = [ + ('channel', self.channel), + ('user', self.user), + ('animation', self.animation), + ('emoji', self.emoji), + ('sound', self.sound), + ] + inner = ' '.join('%s=%r' % t for t in attrs) + return f"<{self.__class__.__name__} {inner}>" + + def is_sound(self) -> bool: + """:class:`bool`: Whether the effect is a sound or not.""" + return self.sound is not None + + class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): """Represents a Discord guild text channel. diff --git a/discord/client.py b/discord/client.py index 298959b212dd..54c0b240b258 100644 --- a/discord/client.py +++ b/discord/client.py @@ -75,6 +75,7 @@ from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory +from .soundboard import DefaultSoundboardSound if TYPE_CHECKING: from types import TracebackType @@ -2642,6 +2643,26 @@ async def fetch_premium_sticker_packs(self) -> List[StickerPack]: data = await self.http.list_premium_sticker_packs() return [StickerPack(state=self._connection, data=pack) for pack in data['sticker_packs']] + async def fetch_default_soundboard_sounds(self) -> List[DefaultSoundboardSound]: + """|coro| + + Retrieves all default soundboard sounds. + + .. versionadded:: 2.3 + + Raises + ------- + HTTPException + Retrieving the default soundboard sounds failed. + + Returns + --------- + List[:class:`.DefaultSoundboardSound`] + All default soundboard sounds. + """ + data = await self.http.get_default_soundboard_sounds() + return [DefaultSoundboardSound(data=sound) for sound in data] + async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| diff --git a/discord/enums.py b/discord/enums.py index 94ca8c726589..0f18e9381bbe 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -68,6 +68,7 @@ 'AutoModRuleActionType', 'ForumLayoutType', 'ForumOrderType', + 'VoiceChannelEffectAnimationType', ) if TYPE_CHECKING: @@ -757,6 +758,11 @@ class ForumOrderType(Enum): creation_date = 1 +class VoiceChannelEffectAnimationType(Enum): + premium = 0 + normal = 1 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/http.py b/discord/http.py index 22336b3234f4..226de51f397b 100644 --- a/discord/http.py +++ b/discord/http.py @@ -90,6 +90,7 @@ scheduled_event, sticker, welcome_screen, + soundboard, ) from .types.snowflake import Snowflake, SnowflakeList @@ -2370,6 +2371,11 @@ def delete_auto_moderation_rule( reason=reason, ) + # Soundboard + + def get_default_soundboard_sounds(self) -> Response[List[soundboard.SoundboardSound]]: + return self.request(Route('GET', '/soundboard-default-sounds')) + # Misc def application_info(self) -> Response[appinfo.AppInfo]: diff --git a/discord/soundboard.py b/discord/soundboard.py new file mode 100644 index 000000000000..153b164c03f9 --- /dev/null +++ b/discord/soundboard.py @@ -0,0 +1,132 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional +from .mixins import Hashable +from .partial_emoji import PartialEmoji + +if TYPE_CHECKING: + from .types.soundboard import ( + BaseSoundboardSound as BaseSoundboardSoundPayload, + SoundboardSound as SoundboardSoundPayload, + ) + +__all__ = ('DefaultSoundboardSound',) + + +class BaseSoundboardSound(Hashable): + """Represents a generic Discord soundboard sound. + + .. versionadded:: 2.3 + + .. container:: operations + + .. describe:: x == y + + Checks if two sounds are equal. + + .. describe:: x != y + + Checks if two sounds are not equal. + + .. describe:: hash(x) + + Returns the sound's hash. + + Attributes + ------------ + id: :class:`int` + The ID of the sound. + volume: :class:`float` + The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). + override_path: Optional[:class:`str`] + The override path of the sound (e.g. 'default_quack.mp3'). + """ + + __slots__ = ('id', 'volume', 'override_path') + + def __init__(self, *, data: BaseSoundboardSoundPayload): + self.id: int = int(data['sound_id']) + self.volume: float = data['volume'] + self.override_path: Optional[str] = data['override_path'] + + def __eq__(self, other: object) -> bool: + if isinstance(other, self.__class__): + return self.id == other.id + return NotImplemented + + __hash__ = Hashable.__hash__ + + +class DefaultSoundboardSound(BaseSoundboardSound): + """Represents a Discord default soundboard sound. + + .. versionadded:: 2.3 + + .. container:: operations + + .. describe:: x == y + + Checks if two sounds are equal. + + .. describe:: x != y + + Checks if two sounds are not equal. + + .. describe:: hash(x) + + Returns the sound's hash. + + Attributes + ------------ + id: :class:`int` + The ID of the sound. + volume: :class:`float` + The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). + override_path: Optional[:class:`str`] + The override path of the sound (e.g. 'default_quack.mp3'). + name: :class:`str` + The name of the sound. + emoji: :class:`PartialEmoji` + The emoji of the sound. + """ + + __slots__ = ('name', 'emoji') + + def __init__(self, *, data: SoundboardSoundPayload): + self.name: str = data['name'] + self.emoji: PartialEmoji = PartialEmoji(name=data['emoji_name']) + super().__init__(data=data) + + def __repr__(self) -> str: + attrs = [ + ('id', self.id), + ('name', self.name), + ('volume', self.volume), + ('emoji', self.emoji), + ] + inner = ' '.join('%s=%r' % t for t in attrs) + return f"<{self.__class__.__name__} {inner}>" diff --git a/discord/state.py b/discord/state.py index 8b556f28c629..4eabf9501b8c 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1538,6 +1538,14 @@ def parse_voice_state_update(self, data: gw.VoiceStateUpdateEvent) -> None: else: _log.debug('VOICE_STATE_UPDATE referencing an unknown member ID: %s. Discarding.', data['user_id']) + def parse_voice_channel_effect_send(self, data: gw.VoiceChannelEffectSendEvent): + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + effect = VoiceChannelEffect(state=self, data=data, guild=guild) + self.dispatch('voice_channel_effect', effect) + else: + _log.debug('VOICE_CHANNEL_EFFECT_SEND referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_voice_server_update(self, data: gw.VoiceServerUpdateEvent) -> None: key_id = int(data['guild_id']) diff --git a/discord/types/channel.py b/discord/types/channel.py index 421232b45972..aac6ebeaaa74 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -28,6 +28,7 @@ from .user import PartialUser from .snowflake import Snowflake from .threads import ThreadMetadata, ThreadMember, ThreadArchiveDuration, ThreadType +from .emoji import PartialEmoji OverwriteType = Literal[0, 1] @@ -89,6 +90,18 @@ class VoiceChannel(_BaseTextChannel): video_quality_mode: NotRequired[VideoQualityMode] +class VoiceChannelEffect(TypedDict): + guild_id: Snowflake + channel_id: Snowflake + user_id: Snowflake + emoji: Optional[PartialEmoji] + animation_type: NotRequired[int] + animation_id: NotRequired[int] + sound_id: NotRequired[Union[int, str]] + sound_volume: NotRequired[float] + sound_override_path: NotRequired[Optional[str]] + + class CategoryChannel(_BaseGuildChannel): type: Literal[4] diff --git a/discord/types/emoji.py b/discord/types/emoji.py index d54690c14417..85e7097576ca 100644 --- a/discord/types/emoji.py +++ b/discord/types/emoji.py @@ -23,6 +23,7 @@ """ from typing import Optional, TypedDict +from typing_extensions import NotRequired from .snowflake import Snowflake, SnowflakeList from .user import User @@ -30,6 +31,7 @@ class PartialEmoji(TypedDict): id: Optional[Snowflake] name: Optional[str] + animated: NotRequired[bool] class Emoji(PartialEmoji, total=False): diff --git a/discord/types/gateway.py b/discord/types/gateway.py index a87b101f0f2b..6948c7726589 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -30,7 +30,7 @@ from .voice import GuildVoiceState from .integration import BaseIntegration, IntegrationApplication from .role import Role -from .channel import ChannelType, StageInstance +from .channel import ChannelType, StageInstance, VoiceChannelEffect from .interactions import Interaction from .invite import InviteTargetType from .emoji import Emoji, PartialEmoji @@ -311,6 +311,7 @@ class _GuildScheduledEventUsersEvent(TypedDict): GuildScheduledEventUserAdd = GuildScheduledEventUserRemove = _GuildScheduledEventUsersEvent VoiceStateUpdateEvent = GuildVoiceState +VoiceChannelEffectSendEvent = VoiceChannelEffect class VoiceServerUpdateEvent(TypedDict): diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py new file mode 100644 index 000000000000..c13ae984b5ea --- /dev/null +++ b/discord/types/soundboard.py @@ -0,0 +1,39 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from typing import TypedDict, Optional, Union +from .snowflake import Snowflake + + +class BaseSoundboardSound(TypedDict): + sound_id: Union[str, int] + volume: float + override_path: Optional[str] + + +class SoundboardSound(BaseSoundboardSound): + name: str + emoji_id: Optional[Snowflake] + emoji_name: str + user_id: Snowflake diff --git a/docs/api.rst b/docs/api.rst index 316dbda14c48..146cc79739ca 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1388,6 +1388,17 @@ Voice :param after: The voice state after the changes. :type after: :class:`VoiceState` +.. function:: on_voice_channel_effect(effect) + + Called when a :class:`Member` sends a :class:`VoiceChannelEffect` in a voice channel the bot is in. + + This requires :attr:`Intents.voice_states` to be enabled. + + .. versionadded:: 2.3 + + :param effect: The effect that is sent. + :type effect: :class:`VoiceChannelEffect` + .. _discord-api-utils: Utility Functions @@ -3325,6 +3336,21 @@ of :class:`enum.Enum`. Sort forum posts by creation time (from most recent to oldest). +.. class:: VoiceChannelEffectAnimationType + + Represents the animation type of a voice channel effect. + + .. versionadded:: 2.3 + + .. attribute:: premium + + A fun animation, sent by a Nitro subscriber. + + .. attribute:: normal + + The standard animation. + + .. _discord-api-audit-logs: Audit Log Data @@ -4398,6 +4424,35 @@ VoiceChannel :members: :inherited-members: +.. attributetable:: VoiceChannelEffect + +.. autoclass:: VoiceChannelEffect() + :members: + :inherited-members: + +.. class:: VoiceChannelEffectAnimation + + A namedtuple which represents a voice channel effect animation. + + .. versionadded:: 2.3 + + .. attribute:: id + + The ID of the animation. + + :type: :class:`int` + .. attribute:: type + + The type of the animation. + + :type: :class:`VoiceChannelEffectAnimationType` + +.. attributetable:: VoiceChannelSoundEffect + +.. autoclass:: VoiceChannelSoundEffect() + :members: + :inherited-members: + StageChannel ~~~~~~~~~~~~~ @@ -4564,6 +4619,14 @@ GuildSticker .. autoclass:: GuildSticker() :members: +DefaultSoundboardSound +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: DefaultSoundboardSound + +.. autoclass:: DefaultSoundboardSound() + :members: + ShardInfo ~~~~~~~~~~~ From 92f0e4d88242a3b7745a7a1aa7425cb5f06e0d8b Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sun, 9 Apr 2023 23:30:40 +0200 Subject: [PATCH 02/30] Add support for soundboard sound events --- discord/guild.py | 3 ++ discord/soundboard.py | 96 +++++++++++++++++++++++++++++++++++-- discord/state.py | 36 ++++++++++++++ discord/types/gateway.py | 9 ++++ discord/types/soundboard.py | 12 +++++ docs/api.rst | 37 ++++++++++++++ 6 files changed, 190 insertions(+), 3 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 0f91eebe1c45..59f95f174b87 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -133,6 +133,7 @@ from .types.snowflake import SnowflakeList from .types.widget import EditWidgetSettings from .message import EmojiInputType + from .soundboard import SoundboardSound VocalGuildChannel = Union[VoiceChannel, StageChannel] GuildChannel = Union[VocalGuildChannel, ForumChannel, TextChannel, CategoryChannel] @@ -315,6 +316,7 @@ class Guild(Hashable): 'approximate_member_count', 'approximate_presence_count', 'premium_progress_bar_enabled', + '_soundboard_sounds', ) _PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = { @@ -332,6 +334,7 @@ def __init__(self, *, data: GuildPayload, state: ConnectionState) -> None: self._threads: Dict[int, Thread] = {} self._stage_instances: Dict[int, StageInstance] = {} self._scheduled_events: Dict[int, ScheduledEvent] = {} + self._soundboard_sounds: Dict[int, SoundboardSound] = {} self._state: ConnectionState = state self._member_count: Optional[int] = None self._from_data(data) diff --git a/discord/soundboard.py b/discord/soundboard.py index 153b164c03f9..0079998a62e3 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -25,16 +25,22 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional + +from . import utils from .mixins import Hashable from .partial_emoji import PartialEmoji +from .user import User if TYPE_CHECKING: from .types.soundboard import ( BaseSoundboardSound as BaseSoundboardSoundPayload, + DefaultSoundboardSound as DefaultSoundboardSoundPayload, SoundboardSound as SoundboardSoundPayload, ) + from .state import ConnectionState + from .guild import Guild -__all__ = ('DefaultSoundboardSound',) +__all__ = ('DefaultSoundboardSound', 'SoundboardSound') class BaseSoundboardSound(Hashable): @@ -70,8 +76,8 @@ class BaseSoundboardSound(Hashable): def __init__(self, *, data: BaseSoundboardSoundPayload): self.id: int = int(data['sound_id']) - self.volume: float = data['volume'] self.override_path: Optional[str] = data['override_path'] + self._update(data) def __eq__(self, other: object) -> bool: if isinstance(other, self.__class__): @@ -80,6 +86,9 @@ def __eq__(self, other: object) -> bool: __hash__ = Hashable.__hash__ + def _update(self, data: BaseSoundboardSoundPayload): + self.volume: float = data['volume'] + class DefaultSoundboardSound(BaseSoundboardSound): """Represents a Discord default soundboard sound. @@ -116,7 +125,7 @@ class DefaultSoundboardSound(BaseSoundboardSound): __slots__ = ('name', 'emoji') - def __init__(self, *, data: SoundboardSoundPayload): + def __init__(self, *, data: DefaultSoundboardSoundPayload): self.name: str = data['name'] self.emoji: PartialEmoji = PartialEmoji(name=data['emoji_name']) super().__init__(data=data) @@ -130,3 +139,84 @@ def __repr__(self) -> str: ] inner = ' '.join('%s=%r' % t for t in attrs) return f"<{self.__class__.__name__} {inner}>" + + +class SoundboardSound(BaseSoundboardSound): + """Represents a Discord soundboard sound. + + .. versionadded:: 2.3 + + .. container:: operations + + .. describe:: x == y + + Checks if two sounds are equal. + + .. describe:: x != y + + Checks if two sounds are not equal. + + .. describe:: hash(x) + + Returns the sound's hash. + + Attributes + ------------ + id: :class:`int` + The ID of the sound. + volume: :class:`float` + The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). + override_path: Optional[:class:`str`] + The override path of the sound (e.g. 'default_quack.mp3'). + name: :class:`str` + The name of the sound. + emoji: :class:`PartialEmoji` + The emoji of the sound. + guild_id: Optional[:class:`int`] + The ID of the guild in which the sound is uploaded. + user: Optional[:class:`User`] + The user who uploaded the sound. + available: :class:`bool` + Whether the sound is available or not. + """ + + __slots__ = ('_state', 'guild_id', 'name', 'emoji', 'user', 'available') + + def __init__(self, *, state: ConnectionState, data: SoundboardSoundPayload): + super().__init__(data=data) + self._state: ConnectionState = state + self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') + + user = data.get('user') + self.user: Optional[User] = User(state=self._state, data=user) if user is not None else None + + self._update(data) + + def __repr__(self) -> str: + attrs = [ + ('id', self.id), + ('name', self.name), + ('volume', self.volume), + ('emoji', self.emoji), + ('user', self.user), + ] + inner = ' '.join('%s=%r' % t for t in attrs) + return f"<{self.__class__.__name__} {inner}>" + + def _update(self, data: SoundboardSoundPayload): + super()._update(data) + + self.name: str = data['name'] + self.emoji: Optional[PartialEmoji] = None + + emoji_id = utils._get_as_snowflake(data, 'emoji_id') + emoji_name = data['emoji_name'] + if emoji_id is not None or emoji_name is not None: + self.emoji = PartialEmoji(id=emoji_id, name=emoji_name) + + self.available: bool = data['available'] + + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`Guild`]: The guild in which the sound is uploaded.""" + return self._state._get_guild(self.guild_id) diff --git a/discord/state.py b/discord/state.py index 4eabf9501b8c..ec7e815041a8 100644 --- a/discord/state.py +++ b/discord/state.py @@ -76,6 +76,7 @@ from .automod import AutoModRule, AutoModAction from .audit_logs import AuditLogEntry from ._types import ClientT +from .soundboard import SoundboardSound if TYPE_CHECKING: from .abc import PrivateChannel @@ -1508,6 +1509,41 @@ def parse_guild_scheduled_event_user_remove(self, data: gw.GuildScheduledEventUs else: _log.debug('SCHEDULED_EVENT_USER_REMOVE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_guild_soundboard_sound_create(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + sound = SoundboardSound(state=self, data=data) + guild._soundboard_sounds[sound.id] = sound + self.dispatch('soundboard_sound_create', sound) + else: + _log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + sound = guild._soundboard_sounds.get(int(data['sound_id'])) + if sound is not None: + print(data) + old_sound = copy.copy(sound) + sound._update(data) + self.dispatch('soundboard_sound_update', old_sound, sound) + else: + _log.warning('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown sound ID: %s. Discarding.', data['sound_id']) + else: + _log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_soundboard_sound_delete(self, data: gw.GuildSoundBoardSoundDeleteEvent) -> None: + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + try: + sound = guild._soundboard_sounds.pop(int(data['sound_id'])) + except KeyError: + pass + else: + self.dispatch('soundboard_sound_delete', sound) + else: + _log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_application_command_permissions_update(self, data: GuildApplicationCommandPermissionsPayload): raw = RawAppCommandPermissionsUpdateEvent(data=data, state=self) self.dispatch('raw_app_command_permissions_update', raw) diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 6948c7726589..6e55fb475d59 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -44,6 +44,7 @@ from .threads import Thread, ThreadMember from .scheduled_event import GuildScheduledEvent from .audit_log import AuditLogEntry +from .soundboard import SoundboardSound class SessionStartLimit(TypedDict): @@ -344,3 +345,11 @@ class AutoModerationActionExecution(TypedDict): class GuildAuditLogEntryCreate(AuditLogEntry): guild_id: Snowflake + + +GuildSoundBoardSoundCreateEvent = SoundboardSound + + +class GuildSoundBoardSoundDeleteEvent(TypedDict): + sound_id: Snowflake + guild_id: Snowflake diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py index c13ae984b5ea..f8558487ade5 100644 --- a/discord/types/soundboard.py +++ b/discord/types/soundboard.py @@ -23,7 +23,9 @@ """ from typing import TypedDict, Optional, Union + from .snowflake import Snowflake +from .user import User class BaseSoundboardSound(TypedDict): @@ -32,8 +34,18 @@ class BaseSoundboardSound(TypedDict): override_path: Optional[str] +class DefaultSoundboardSound(BaseSoundboardSound): + name: str + emoji_id: Optional[Snowflake] + emoji_name: str + user_id: Snowflake + + class SoundboardSound(BaseSoundboardSound): name: str emoji_id: Optional[Snowflake] emoji_name: str user_id: Snowflake + available: bool + guild_id: Snowflake + user: User diff --git a/docs/api.rst b/docs/api.rst index 146cc79739ca..0f1e975c0313 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1203,6 +1203,35 @@ Scheduled Events :type user: :class:`User` +Soundboard +~~~~~~~~~~~ + +.. function:: on_soundboard_sound_create(sound) + on_soundboard_sound_delete(sound) + + Called when a :class:`SoundboardSound` is created or deleted. + + .. versionadded:: 2.3 + + :param sound: The soundboard sound that was created or deleted. + :type sound: :class:`SoundboardSound` + +.. function:: on_soundboard_sound_update(before, after) + + Called when a :class:`SoundboardSound` is updated. + + The following examples illustrate when this event is called: + + - The name is changed. + - The emoji is changed. + - The volume is changed. + + .. versionadded:: 2.3 + + :param sound: The soundboard sound that was updated. + :type sound: :class:`SoundboardSound` + + Stages ~~~~~~~ @@ -4619,6 +4648,14 @@ GuildSticker .. autoclass:: GuildSticker() :members: +SoundboardSound +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: SoundboardSound + +.. autoclass:: SoundboardSound() + :members: + DefaultSoundboardSound ~~~~~~~~~~~~~~~~~~~~~~~ From e95280b4f2875572cafeafb6021ad940bf4d58ad Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sun, 9 Apr 2023 23:40:28 +0200 Subject: [PATCH 03/30] Add guild feature SOUNDBOARD --- discord/types/guild.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/types/guild.py b/discord/types/guild.py index 1ff2854aabbc..6c08006d5808 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -84,6 +84,7 @@ class UnavailableGuild(TypedDict): 'VERIFIED', 'VIP_REGIONS', 'WELCOME_SCREEN_ENABLED', + 'SOUNDBOARD', ] From 8706ebf292cb079edd50b6438290f4e4a14e4fc2 Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Tue, 11 Apr 2023 21:59:27 +0200 Subject: [PATCH 04/30] Remove print from debugging --- discord/state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/state.py b/discord/state.py index ec7e815041a8..d6247e72ff2a 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1523,7 +1523,6 @@ def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundCreat if guild is not None: sound = guild._soundboard_sounds.get(int(data['sound_id'])) if sound is not None: - print(data) old_sound = copy.copy(sound) sound._update(data) self.dispatch('soundboard_sound_update', old_sound, sound) From 9aade035d000554f7305c114c4a93d65ea012c08 Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sat, 15 Apr 2023 22:37:42 +0200 Subject: [PATCH 05/30] Add audit log events + docs change in SoundboardSound --- discord/audit_logs.py | 6 ++++ discord/enums.py | 6 ++++ discord/soundboard.py | 2 +- discord/types/audit_log.py | 13 ++++++++ docs/api.rst | 64 +++++++++++++++++++++++++++++++++++--- 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 47f397a8a2af..7fcc470614b9 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -263,6 +263,10 @@ def _transform_automod_actions(entry: AuditLogEntry, data: List[AutoModerationAc return [AutoModRuleAction.from_data(action) for action in data] +def _transform_default_emoji(entry: AuditLogEntry, data: str) -> PartialEmoji: + return PartialEmoji(name=data) + + E = TypeVar('E', bound=enums.Enum) @@ -370,6 +374,8 @@ class AuditLogChanges: 'available_tags': (None, _transform_forum_tags), 'flags': (None, _transform_overloaded_flags), 'default_reaction_emoji': (None, _transform_default_reaction), + 'emoji_name': ('emoji', _transform_default_emoji), + 'user_id': ('user', _transform_member_id) } # fmt: on diff --git a/discord/enums.py b/discord/enums.py index 0f18e9381bbe..10bd5833613a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -362,6 +362,9 @@ class AuditLogAction(Enum): thread_update = 111 thread_delete = 112 app_command_permission_update = 121 + soundboard_sound_create = 130 + soundboard_sound_update = 131 + soundboard_sound_delete = 132 automod_rule_create = 140 automod_rule_update = 141 automod_rule_delete = 142 @@ -428,6 +431,9 @@ def category(self) -> Optional[AuditLogActionCategory]: AuditLogAction.automod_block_message: None, AuditLogAction.automod_flag_message: None, AuditLogAction.automod_timeout_member: None, + AuditLogAction.soundboard_sound_create: AuditLogActionCategory.create, + AuditLogAction.soundboard_sound_update: AuditLogActionCategory.update, + AuditLogAction.soundboard_sound_delete: AuditLogActionCategory.delete, } # fmt: on return lookup[self] diff --git a/discord/soundboard.py b/discord/soundboard.py index 0079998a62e3..5c54b2ee4a34 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -177,7 +177,7 @@ class SoundboardSound(BaseSoundboardSound): user: Optional[:class:`User`] The user who uploaded the sound. available: :class:`bool` - Whether the sound is available or not. + Whether this sound is available for use. """ __slots__ = ('_state', 'guild_id', 'name', 'emoji', 'user', 'available') diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index 4401bc784193..b76434f767f6 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -87,6 +87,9 @@ 111, 112, 121, + 130, + 131, + 132, 140, 141, 142, @@ -109,6 +112,7 @@ class _AuditLogChange_Str(TypedDict): 'permissions', 'tags', 'unicode_emoji', + 'emoji_name', ] new_value: str old_value: str @@ -133,6 +137,8 @@ class _AuditLogChange_Snowflake(TypedDict): 'channel_id', 'inviter_id', 'guild_id', + 'user_id', + 'sound_id', ] new_value: Snowflake old_value: Snowflake @@ -180,6 +186,12 @@ class _AuditLogChange_Int(TypedDict): old_value: int +class _AuditLogChange_Float(TypedDict): + key: Literal['volume'] + new_value: float + old_value: float + + class _AuditLogChange_ListRole(TypedDict): key: Literal['$add', '$remove'] new_value: List[Role] @@ -281,6 +293,7 @@ class _AuditLogChange_DefaultReactionEmoji(TypedDict): _AuditLogChange_AssetHash, _AuditLogChange_Snowflake, _AuditLogChange_Int, + _AuditLogChange_Float, _AuditLogChange_Bool, _AuditLogChange_ListRole, _AuditLogChange_MFALevel, diff --git a/docs/api.rst b/docs/api.rst index 0f1e975c0313..cb2c52b74123 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2838,6 +2838,42 @@ of :class:`enum.Enum`. .. versionadded:: 2.1 + .. attribute:: soundboard_sound_create + + A soundboard sound was created. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.emoji` + - :attr:`~AuditLogDiff.volume` + + .. versionadded:: 2.3 + + .. attribute:: soundboard_sound_update + + A soundboard sound was updated. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.emoji` + - :attr:`~AuditLogDiff.volume` + + .. versionadded:: 2.3 + + .. attribute:: soundboard_sound_delete + + A soundboard sound was deleted. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.name` + - :attr:`~AuditLogDiff.emoji` + - :attr:`~AuditLogDiff.volume` + + .. versionadded:: 2.3 + .. class:: AuditLogActionCategory Represents the category that the :class:`AuditLogAction` belongs to. @@ -3839,11 +3875,12 @@ AuditLogDiff .. attribute:: emoji - The name of the emoji that represents a sticker being changed. + The emoji which represents one of the following: - See also :attr:`GuildSticker.emoji`. + * :attr:`GuildSticker.emoji` + * :attr:`SoundboardSound.emoji` - :type: :class:`str` + :type: Union[:class:`str`, :class:`PartialEmoji`] .. attribute:: unicode_emoji @@ -3864,9 +3901,10 @@ AuditLogDiff .. attribute:: available - The availability of a sticker being changed. + The availability of one of the following being changed: - See also :attr:`GuildSticker.available` + * :attr:`GuildSticker.available` + * :attr:`SoundboardSound.available` :type: :class:`bool` @@ -4051,6 +4089,22 @@ AuditLogDiff :type: :class:`ChannelFlags` + .. attribute:: user + + The user that represents the uploader of a soundboard sound. + + See also :attr:`SoundboardSound.user` + + :type: Union[:class:`Member`, :class:`User`] + + .. attribute:: volume + + The volume of a soundboard sound. + + See also :attr:`SoundboardSound.volume` + + :type: :class:`float` + .. this is currently missing the following keys: reason and application_id I'm not sure how to port these From bc6e8ac0bcadc6a3b6c1682e783865b494d7d94b Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Thu, 26 Oct 2023 22:50:21 +0200 Subject: [PATCH 06/30] Further implementing soundboard sounds --- discord/asset.py | 8 ++ discord/channel.py | 42 +++-------- discord/client.py | 12 +-- discord/enums.py | 2 +- discord/gateway.py | 33 +++++---- discord/guild.py | 95 ++++++++++++++++++++++++ discord/http.py | 53 ++++++++++++- discord/soundboard.py | 143 +++++++++++++++++++++++++++++------- discord/state.py | 3 + discord/types/channel.py | 8 +- discord/types/guild.py | 3 +- discord/types/soundboard.py | 17 ++--- docs/api.rst | 8 +- 13 files changed, 333 insertions(+), 94 deletions(-) diff --git a/discord/asset.py b/discord/asset.py index d88ebb945d44..75053ab3a326 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -326,6 +326,14 @@ def _from_user_banner(cls, state: _State, user_id: int, banner_hash: str) -> Sel animated=animated, ) + @classmethod + def _from_soundboard_sound(cls, state: _State, sound_id: int) -> Self: + 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 cc3209e09217..3b74aef6180e 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -69,7 +69,6 @@ from .http import handle_message_parameters from .object import Object from .soundboard import BaseSoundboardSound -from .utils import snowflake_time __all__ = ( 'TextChannel', @@ -134,7 +133,7 @@ class VoiceChannelEffectAnimation(NamedTuple): class VoiceChannelSoundEffect(BaseSoundboardSound): """Represents a Discord voice channel sound effect. - .. versionadded:: 2.3 + .. versionadded:: 2.4 .. container:: operations @@ -156,20 +155,16 @@ class VoiceChannelSoundEffect(BaseSoundboardSound): The ID of the sound. volume: :class:`float` The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). - override_path: Optional[:class:`str`] - The override path of the sound (e.g. 'default_quack.mp3'). """ __slots__ = ('_state',) - def __init__(self, *, state: ConnectionState, id: int, volume: float, override_path: Optional[str]): - self._state: ConnectionState = state + def __init__(self, *, state: ConnectionState, id: int, volume: float): data: BaseSoundboardSoundPayload = { 'sound_id': id, 'volume': volume, - 'override_path': override_path, } - super().__init__(data=data) + super().__init__(state=state, data=data) def __repr__(self) -> str: attrs = [ @@ -181,33 +176,23 @@ def __repr__(self) -> str: @property def created_at(self) -> Optional[datetime.datetime]: - """:class:`datetime.datetime`: Returns the snowflake's creation time in UTC. + """Optional[:class:`datetime.datetime`]: Returns the snowflake's creation time in UTC. Returns ``None`` if it's a default sound.""" if self.is_default(): return None else: - return snowflake_time(self.id) + return utils.snowflake_time(self.id) - async def is_default(self) -> bool: - """|coro| - - Checks if the sound is a default sound. - - Returns - --------- - :class:`bool` - Whether it's a default sound or not. - """ - default_sounds = await self._state.http.get_default_soundboard_sounds() - default_sounds = [int(sound['sound_id']) for sound in default_sounds] - - return self.id in default_sounds + def is_default(self) -> bool: + """:class:`bool`: Whether it's a default sound or not.""" + # if it's smaller than the Discord Epoch it cannot be a snowflake + return self.id < 1420070400000 class VoiceChannelEffect: """Represents a Discord voice channel effect. - .. versionadded:: 2.3 + .. versionadded:: 2.4 Attributes ------------ @@ -235,17 +220,14 @@ def __init__(self, *, state: ConnectionState, data: VoiceChannelEffectPayload, g animation_type = try_enum(VoiceChannelEffectAnimationType, data['animation_type']) # type: ignore # cannot be None here self.animation = VoiceChannelEffectAnimation(id=animation_id, type=animation_type) - emoji = data['emoji'] + emoji = data.get('emoji') self.emoji: Optional[PartialEmoji] = PartialEmoji.from_dict(emoji) if emoji is not None else None self.sound: Optional[VoiceChannelSoundEffect] = None sound_id: Optional[int] = utils._get_as_snowflake(data, 'sound_id') if sound_id is not None: sound_volume = data['sound_volume'] # type: ignore # sound_volume cannot be None here - sound_override_path = data.get('sound_override_path') - self.sound = VoiceChannelSoundEffect( - state=state, id=sound_id, volume=sound_volume, override_path=sound_override_path - ) + self.sound = VoiceChannelSoundEffect(state=state, id=sound_id, volume=sound_volume) def __repr__(self) -> str: attrs = [ diff --git a/discord/client.py b/discord/client.py index 607f7618200d..e6e9696acaf1 100644 --- a/discord/client.py +++ b/discord/client.py @@ -77,7 +77,7 @@ from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory -from .soundboard import DefaultSoundboardSound +from .soundboard import SoundboardDefaultSound if TYPE_CHECKING: from types import TracebackType @@ -2889,12 +2889,12 @@ async def fetch_premium_sticker_packs(self) -> List[StickerPack]: data = await self.http.list_premium_sticker_packs() return [StickerPack(state=self._connection, data=pack) for pack in data['sticker_packs']] - async def fetch_default_soundboard_sounds(self) -> List[DefaultSoundboardSound]: + async def fetch_soundboard_default_sounds(self) -> List[SoundboardDefaultSound]: """|coro| Retrieves all default soundboard sounds. - .. versionadded:: 2.3 + .. versionadded:: 2.4 Raises ------- @@ -2903,11 +2903,11 @@ async def fetch_default_soundboard_sounds(self) -> List[DefaultSoundboardSound]: Returns --------- - List[:class:`.DefaultSoundboardSound`] + List[:class:`SoundboardDefaultSound`] All default soundboard sounds. """ - data = await self.http.get_default_soundboard_sounds() - return [DefaultSoundboardSound(data=sound) for sound in data] + data = await self.http.get_soundboard_default_sounds() + return [SoundboardDefaultSound(state=self._connection, data=sound) for sound in data] async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| diff --git a/discord/enums.py b/discord/enums.py index 3dee5d978532..38d130c94631 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -809,7 +809,7 @@ class EntitlementOwnerType(Enum): class VoiceChannelEffectAnimationType(Enum): premium = 0 - normal = 1 + basic = 1 def create_unknown_value(cls: Type[E], val: Any) -> E: diff --git a/discord/gateway.py b/discord/gateway.py index dc52828134a9..6747c0d10c83 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -276,6 +276,8 @@ class DiscordWebSocket: a connection issue. GUILD_SYNC Send only. Requests a guild sync. + REQUEST_SOUNDBOARD_SOUNDS + Send only. Requests the soundboard sounds for a list of guilds. gateway The gateway we are currently connected to. token @@ -295,19 +297,20 @@ class DiscordWebSocket: # fmt: off DEFAULT_GATEWAY = yarl.URL('wss://gateway.discord.gg/') - DISPATCH = 0 - HEARTBEAT = 1 - IDENTIFY = 2 - PRESENCE = 3 - VOICE_STATE = 4 - VOICE_PING = 5 - RESUME = 6 - RECONNECT = 7 - REQUEST_MEMBERS = 8 - INVALIDATE_SESSION = 9 - HELLO = 10 - HEARTBEAT_ACK = 11 - GUILD_SYNC = 12 + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + PRESENCE = 3 + VOICE_STATE = 4 + VOICE_PING = 5 + RESUME = 6 + RECONNECT = 7 + REQUEST_MEMBERS = 8 + INVALIDATE_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + GUILD_SYNC = 12 + REQUEST_SOUNDBOARD_SOUNDS = 31 # fmt: on def __init__(self, socket: aiohttp.ClientWebSocketResponse, *, loop: asyncio.AbstractEventLoop) -> None: @@ -574,6 +577,10 @@ async def received_message(self, msg: Any, /) -> None: else: func(data) + print(f"{event}\n" + f"----------------------------\n" + f"{data}\n\n") + # remove the dispatched listeners removed = [] for index, entry in enumerate(self._dispatch_listeners): diff --git a/discord/guild.py b/discord/guild.py index 683211bcf066..80985c53f4e9 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -553,6 +553,11 @@ def _from_data(self, guild: GuildPayload) -> None: scheduled_event = ScheduledEvent(data=s, state=self._state) self._scheduled_events[scheduled_event.id] = scheduled_event + if 'soundboard_sounds' in guild: + for s in guild['soundboard_sounds']: + soundboard_sound = SoundboardSound(data=s, state=self._state) + self._soundboard_sounds[soundboard_sound.id] = soundboard_sound + @property def channels(self) -> Sequence[GuildChannel]: """Sequence[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild.""" @@ -1002,6 +1007,31 @@ def get_scheduled_event(self, scheduled_event_id: int, /) -> Optional[ScheduledE """ return self._scheduled_events.get(scheduled_event_id) + @property + def soundboard_sounds(self) -> Sequence[SoundboardSound]: + """Sequence[:class:`SoundboardSound`]: Returns a sequence of the guild's soundboard sounds. + + .. versionadded:: 2.4 + """ + return utils.SequenceProxy(self._soundboard_sounds.values()) + + def get_soundboard_sound(self, sound_id: int, /) -> Optional[SoundboardSound]: + """Returns a soundboard sound with the given ID. + + .. versionadded:: 2.4 + + Parameters + ----------- + sound_id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`SoundboardSound`] + The soundboard sound or ``None`` if not found. + """ + return self._scheduled_events.get(sound_id) + @property def owner(self) -> Optional[Member]: """Optional[:class:`Member`]: The member that owns the guild.""" @@ -4295,3 +4325,68 @@ async def create_automod_rule( ) return AutoModRule(data=data, guild=self, state=self._state) + + async def create_soundboard_sound( + self, + *, + name: str, + sound: bytes, + volume: Optional[float] = None, + emoji: Optional[EmojiInputType] = None, + reason: Optional[str] = None, + ) -> SoundboardSound: + """|coro| + + Creates a :class:`SoundboardSound` for the guild. + You must have :attr:`Permissions.manage_expressions` to do this. + + Parameters + ---------- + name: :class:`str` + The name of the sound. Must be between 2 and 32 characters. + sound: :class:`bytes` + The :term:`py:bytes-like object` representing the sound data. + Only MP3 sound files that don't exceed the duration of 5.2s are supported. + volume: Optional[:class:`float`] + The volume of the sound. Must be between 0 and 1. + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]] + The emoji of the sound. + reason: Optional[:class:`str`] + The reason for creating the sound. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to create a soundboard sound. + HTTPException + Creating the soundboard sound failed. + + Returns + ------- + :class:`SoundboardSound` + The newly created soundboard sound. + """ + payload: Dict[str, Any] = { + 'name': name, + 'sound': utils._bytes_to_base64_data(sound), + 'volume': volume, + 'emoji_id': None, + 'emoji_name': None, + } + + if emoji is not None: + if isinstance(emoji, _EmojiTag): + partial_emoji = emoji._to_partial() + elif isinstance(emoji, str): + partial_emoji = PartialEmoji.from_str(emoji) + else: + partial_emoji = None + + if partial_emoji is not None: + if partial_emoji.id is None: + payload['emoji_name'] = partial_emoji.name + else: + payload['emoji_id'] = partial_emoji.id + + data = await self._state.http.create_soundboard_sound(self.id, reason=reason, **payload) + return SoundboardSound(state=self._state, data=data) diff --git a/discord/http.py b/discord/http.py index 80e5e97b478a..d759f6a9944a 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2454,9 +2454,60 @@ def delete_entitlement(self, application_id: Snowflake, entitlement_id: Snowflak # Soundboard - def get_default_soundboard_sounds(self) -> Response[List[soundboard.SoundboardSound]]: + def get_soundboard_default_sounds(self) -> Response[List[soundboard.SoundboardDefaultSound]]: return self.request(Route('GET', '/soundboard-default-sounds')) + def create_soundboard_sound( + self, guild_id: Snowflake, *, reason: Optional[str], **payload: Any + ) -> Response[soundboard.SoundboardSound]: + valid_keys = ( + 'name', + 'sound', + 'volume', + 'emoji_id', + 'emoji_name', + ) + + payload = {k: v for k, v in payload.items() if k in valid_keys and v is not None} + + return self.request( + Route('POST', '/guilds/{guild_id}/soundboard-sounds', guild_id=guild_id), json=payload, reason=reason + ) + + def edit_soundboard_sound( + self, guild_id: Snowflake, sound_id: Snowflake, *, reason: Optional[str], **payload: Any + ) -> Response[soundboard.SoundboardSound]: + valid_keys = ( + 'name', + 'volume', + 'emoji_id', + 'emoji_name', + ) + + payload = {k: v for k, v in payload.items() if k in valid_keys} + + return self.request( + Route( + 'PATCH', + '/guilds/{guild_id}/soundboard-sounds/{sound_id}/soundboard-sound-object', + guild_id=guild_id, + sound_id=sound_id, + ), + json=payload, + reason=reason, + ) + + def delete_soundboard_sound(self, guild_id: Snowflake, sound_id: Snowflake, *, reason: Optional[str]) -> Response[None]: + return self.request( + Route( + 'DELETE', + '/guilds/{guild_id}/soundboard-sounds/{sound_id}/soundboard-sound-object', + guild_id=guild_id, + sound_id=sound_id, + ), + reason=reason, + ) + # Misc def application_info(self) -> Response[appinfo.AppInfo]: diff --git a/discord/soundboard.py b/discord/soundboard.py index 5c54b2ee4a34..e9c30ddcb69e 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -28,25 +28,31 @@ from . import utils from .mixins import Hashable -from .partial_emoji import PartialEmoji +from .partial_emoji import PartialEmoji, _EmojiTag from .user import User +from .utils import MISSING if TYPE_CHECKING: + import datetime + from typing import Dict, Any + from .types.soundboard import ( BaseSoundboardSound as BaseSoundboardSoundPayload, - DefaultSoundboardSound as DefaultSoundboardSoundPayload, + SoundboardDefaultSound as SoundboardDefaultSoundPayload, SoundboardSound as SoundboardSoundPayload, ) from .state import ConnectionState from .guild import Guild + from .asset import Asset + from .message import EmojiInputType -__all__ = ('DefaultSoundboardSound', 'SoundboardSound') +__all__ = ('SoundboardDefaultSound', 'SoundboardSound') class BaseSoundboardSound(Hashable): """Represents a generic Discord soundboard sound. - .. versionadded:: 2.3 + .. versionadded:: 2.4 .. container:: operations @@ -68,15 +74,13 @@ class BaseSoundboardSound(Hashable): The ID of the sound. volume: :class:`float` The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). - override_path: Optional[:class:`str`] - The override path of the sound (e.g. 'default_quack.mp3'). """ - __slots__ = ('id', 'volume', 'override_path') + __slots__ = ('_state', 'id', 'volume') - def __init__(self, *, data: BaseSoundboardSoundPayload): + def __init__(self, *, state: ConnectionState, data: BaseSoundboardSoundPayload): + self._state: ConnectionState = state self.id: int = int(data['sound_id']) - self.override_path: Optional[str] = data['override_path'] self._update(data) def __eq__(self, other: object) -> bool: @@ -84,16 +88,22 @@ def __eq__(self, other: object) -> bool: return self.id == other.id return NotImplemented - __hash__ = Hashable.__hash__ + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) def _update(self, data: BaseSoundboardSoundPayload): self.volume: float = data['volume'] + @property + def file(self) -> Asset: + """:class:`Asset`: Returns the sound file asset.""" + return Asset._from_soundboard_sound(self._state, self.id) + -class DefaultSoundboardSound(BaseSoundboardSound): - """Represents a Discord default soundboard sound. +class SoundboardDefaultSound(BaseSoundboardSound): + """Represents a Discord soundboard default sound. - .. versionadded:: 2.3 + .. versionadded:: 2.4 .. container:: operations @@ -115,8 +125,6 @@ class DefaultSoundboardSound(BaseSoundboardSound): The ID of the sound. volume: :class:`float` The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). - override_path: Optional[:class:`str`] - The override path of the sound (e.g. 'default_quack.mp3'). name: :class:`str` The name of the sound. emoji: :class:`PartialEmoji` @@ -125,10 +133,10 @@ class DefaultSoundboardSound(BaseSoundboardSound): __slots__ = ('name', 'emoji') - def __init__(self, *, data: DefaultSoundboardSoundPayload): + def __init__(self, *, state: ConnectionState, data: SoundboardDefaultSoundPayload): self.name: str = data['name'] - self.emoji: PartialEmoji = PartialEmoji(name=data['emoji_name']) - super().__init__(data=data) + self.emoji: PartialEmoji = PartialEmoji(name=data['emoji_name'], id=utils._get_as_snowflake(data, 'emoji_id')) + super().__init__(state=state, data=data) def __repr__(self) -> str: attrs = [ @@ -144,7 +152,7 @@ def __repr__(self) -> str: class SoundboardSound(BaseSoundboardSound): """Represents a Discord soundboard sound. - .. versionadded:: 2.3 + .. versionadded:: 2.4 .. container:: operations @@ -166,8 +174,6 @@ class SoundboardSound(BaseSoundboardSound): The ID of the sound. volume: :class:`float` The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). - override_path: Optional[:class:`str`] - The override path of the sound (e.g. 'default_quack.mp3'). name: :class:`str` The name of the sound. emoji: :class:`PartialEmoji` @@ -183,13 +189,11 @@ class SoundboardSound(BaseSoundboardSound): __slots__ = ('_state', 'guild_id', 'name', 'emoji', 'user', 'available') def __init__(self, *, state: ConnectionState, data: SoundboardSoundPayload): - super().__init__(data=data) - self._state: ConnectionState = state - self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') + super().__init__(state=state, data=data) + self.guild_id: int = int(data['guild_id']) user = data.get('user') self.user: Optional[User] = User(state=self._state, data=user) if user is not None else None - self._update(data) def __repr__(self) -> str: @@ -212,7 +216,7 @@ def _update(self, data: SoundboardSoundPayload): emoji_id = utils._get_as_snowflake(data, 'emoji_id') emoji_name = data['emoji_name'] if emoji_id is not None or emoji_name is not None: - self.emoji = PartialEmoji(id=emoji_id, name=emoji_name) + self.emoji = PartialEmoji(id=emoji_id, name=emoji_name) # type: ignore # emoji_name cannot be None here self.available: bool = data['available'] @@ -220,3 +224,90 @@ def _update(self, data: SoundboardSoundPayload): def guild(self) -> Optional[Guild]: """Optional[:class:`Guild`]: The guild in which the sound is uploaded.""" return self._state._get_guild(self.guild_id) + + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the snowflake's creation time in UTC.""" + return utils.snowflake_time(self.id) + + async def edit( + self, + name: str = MISSING, + volume: Optional[float] = MISSING, + emoji: Optional[EmojiInputType] = MISSING, + reason: Optional[str] = None, + ): + """|coro| + + Edits the soundboard sound. + + You must have :attr:`~Permissions.manage_expressions` to edit the sound. + + Parameters + ---------- + name: :class:`str` + The new name of the sound. Must be between 2 and 32 characters. + volume: Optional[:class:`float`] + The new volume of the sound. Must be between 0 and 1. + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]] + The new emoji of the sound. + reason: Optional[:class:`str`] + The reason for editing this sound. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to edit the soundboard sound. + HTTPException + Editing the soundboard sound failed. + + Returns + ------- + :class:`SoundboardSound` + The newly updated soundboard sound. + """ + + payload: Dict[str, Any] = {} + + if name is not MISSING: + payload['name'] = name + + if volume is not MISSING: + payload['volume'] = volume + + if emoji is not MISSING: + if emoji is None: + payload['emoji_id'] = None + payload['emoji_name'] = None + else: + if isinstance(emoji, _EmojiTag): + partial_emoji = emoji._to_partial() + elif isinstance(emoji, str): + partial_emoji = PartialEmoji.from_str(emoji) + else: + partial_emoji = None + + if partial_emoji is not None: + if partial_emoji.id is None: + payload['emoji_name'] = partial_emoji.name + else: + payload['emoji_id'] = partial_emoji.id + + data = await self._state.http.edit_soundboard_sound(self.guild_id, self.id, reason=reason, **payload) + return SoundboardSound(state=self._state, data=data) + + async def delete(self, *, reason: Optional[str] = None) -> None: + """|coro| + + Deletes the soundboard sound. + + You must have :attr:`~Permissions.manage_expressions` to delete the sound. + + Raises + ------- + Forbidden + You do not have permissions to delete the soundboard sound. + HTTPException + Deleting the soundboard sound failed. + """ + await self._state.http.delete_soundboard_sound(self.guild_id, self.id, reason=reason) diff --git a/discord/state.py b/discord/state.py index b73b76ff7e7f..949a3245847e 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1528,6 +1528,7 @@ def parse_guild_scheduled_event_user_remove(self, data: gw.GuildScheduledEventUs _log.debug('SCHEDULED_EVENT_USER_REMOVE referencing unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_create(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: + print("SOUNDBOARD_SOUND_CREATE\n" "-----------------------\n" f"{data}\n") guild = self._get_guild(int(data['guild_id'])) if guild is not None: sound = SoundboardSound(state=self, data=data) @@ -1537,6 +1538,7 @@ def parse_guild_soundboard_sound_create(self, data: gw.GuildSoundBoardSoundCreat _log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: + print("SOUNDBOARD_SOUND_UPDATE\n" "-----------------------\n" f"{data}\n") guild = self._get_guild(int(data['guild_id'])) if guild is not None: sound = guild._soundboard_sounds.get(int(data['sound_id'])) @@ -1550,6 +1552,7 @@ def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundCreat _log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_delete(self, data: gw.GuildSoundBoardSoundDeleteEvent) -> None: + print("SOUNDBOARD_SOUND_DELETE\n" "-----------------------\n" f"{data}\n") guild = self._get_guild(int(data['guild_id'])) if guild is not None: try: diff --git a/discord/types/channel.py b/discord/types/channel.py index e04b250e54a1..4b593e55426a 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -90,16 +90,18 @@ class VoiceChannel(_BaseTextChannel): video_quality_mode: NotRequired[VideoQualityMode] +VoiceChannelEffectAnimationType = Literal[0, 1] + + class VoiceChannelEffect(TypedDict): guild_id: Snowflake channel_id: Snowflake user_id: Snowflake - emoji: Optional[PartialEmoji] - animation_type: NotRequired[int] + emoji: NotRequired[Optional[PartialEmoji]] + animation_type: NotRequired[VoiceChannelEffectAnimationType] animation_id: NotRequired[int] sound_id: NotRequired[Union[int, str]] sound_volume: NotRequired[float] - sound_override_path: NotRequired[Optional[str]] class CategoryChannel(_BaseGuildChannel): diff --git a/discord/types/guild.py b/discord/types/guild.py index 645afbd710bd..55b18c614716 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -37,6 +37,7 @@ from .emoji import Emoji from .user import User from .threads import Thread +from .soundboard import SoundboardSound class Ban(TypedDict): @@ -149,7 +150,7 @@ class Guild(_BaseGuildPreview): max_members: NotRequired[int] premium_subscription_count: NotRequired[int] max_video_channel_users: NotRequired[int] - + soundboard_sounds: NotRequired[List[SoundboardSound]] class InviteGuild(Guild, total=False): welcome_screen: WelcomeScreen diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py index f8558487ade5..0e256915cd68 100644 --- a/discord/types/soundboard.py +++ b/discord/types/soundboard.py @@ -29,23 +29,22 @@ class BaseSoundboardSound(TypedDict): - sound_id: Union[str, int] + sound_id: Union[Snowflake, str] # basic string number when it's a default sound volume: float - override_path: Optional[str] -class DefaultSoundboardSound(BaseSoundboardSound): +class SoundboardSound(BaseSoundboardSound): name: str + emoji_name: Optional[str] emoji_id: Optional[Snowflake] - emoji_name: str user_id: Snowflake + available: bool + guild_id: Snowflake + user: User -class SoundboardSound(BaseSoundboardSound): +class SoundboardDefaultSound(BaseSoundboardSound): name: str - emoji_id: Optional[Snowflake] emoji_name: str + emoji_id: Optional[Snowflake] user_id: Snowflake - available: bool - guild_id: Snowflake - user: User diff --git a/docs/api.rst b/docs/api.rst index ad717d7e3d43..3b9fe36a1eb8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1477,7 +1477,7 @@ Voice This requires :attr:`Intents.voice_states` to be enabled. - .. versionadded:: 2.3 + .. versionadded:: 2.4 :param effect: The effect that is sent. :type effect: :class:`VoiceChannelEffect` @@ -3591,13 +3591,13 @@ of :class:`enum.Enum`. Represents the animation type of a voice channel effect. - .. versionadded:: 2.3 + .. versionadded:: 2.4 .. attribute:: premium A fun animation, sent by a Nitro subscriber. - .. attribute:: normal + .. attribute:: basic The standard animation. @@ -4741,7 +4741,7 @@ VoiceChannel A namedtuple which represents a voice channel effect animation. - .. versionadded:: 2.3 + .. versionadded:: 2.4 .. attribute:: id From c1b0d48add44fdeb2d1aa572b1e2a3058e71dffe Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sun, 29 Oct 2023 22:31:48 +0100 Subject: [PATCH 07/30] Add request_soundboard_sounds, general code and docs fixes --- discord/channel.py | 2 +- discord/client.py | 2 +- discord/gateway.py | 15 +++++-- discord/guild.py | 47 +++++++++++++++++--- discord/soundboard.py | 11 +++-- discord/state.py | 93 +++++++++++++++++++++++++++++++++++----- discord/types/gateway.py | 5 +++ discord/types/guild.py | 1 + docs/api.rst | 30 ++++++++----- 9 files changed, 170 insertions(+), 36 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 3b74aef6180e..1ad87a2c548b 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -186,7 +186,7 @@ def created_at(self) -> Optional[datetime.datetime]: def is_default(self) -> bool: """:class:`bool`: Whether it's a default sound or not.""" # if it's smaller than the Discord Epoch it cannot be a snowflake - return self.id < 1420070400000 + return self.id < utils.DISCORD_EPOCH class VoiceChannelEffect: diff --git a/discord/client.py b/discord/client.py index e6e9696acaf1..abc4451c285c 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2903,7 +2903,7 @@ async def fetch_soundboard_default_sounds(self) -> List[SoundboardDefaultSound]: Returns --------- - List[:class:`SoundboardDefaultSound`] + List[:class:`.SoundboardDefaultSound`] All default soundboard sounds. """ data = await self.http.get_soundboard_default_sounds() diff --git a/discord/gateway.py b/discord/gateway.py index 6747c0d10c83..bfc72d531ae9 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -577,10 +577,6 @@ async def received_message(self, msg: Any, /) -> None: else: func(data) - print(f"{event}\n" - f"----------------------------\n" - f"{data}\n\n") - # remove the dispatched listeners removed = [] for index, entry in enumerate(self._dispatch_listeners): @@ -759,6 +755,17 @@ async def voice_state( _log.debug('Updating our voice state to %s.', payload) await self.send_as_json(payload) + async def request_soundboard_sounds(self, guild_ids: List[int]) -> None: + payload = { + 'op': self.REQUEST_SOUNDBOARD_SOUNDS, + 'd': { + 'guild_ids': guild_ids, + }, + } + + _log.debug('Sending "%s" to request soundboard sounds', payload) + await self.send_as_json(payload) + async def close(self, code: int = 4000) -> None: if self._keep_alive: self._keep_alive.stop() diff --git a/discord/guild.py b/discord/guild.py index 80985c53f4e9..4ffebb5054b2 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -93,6 +93,7 @@ from .welcome_screen import WelcomeScreen, WelcomeChannel from .automod import AutoModRule, AutoModTrigger, AutoModRuleAction from .partial_emoji import _EmojiTag, PartialEmoji +from .soundboard import SoundboardSound __all__ = ( @@ -134,7 +135,6 @@ from .types.widget import EditWidgetSettings from .types.audit_log import AuditLogEvent from .message import EmojiInputType - from .soundboard import SoundboardSound VocalGuildChannel = Union[VoiceChannel, StageChannel] GuildChannel = Union[VocalGuildChannel, ForumChannel, TextChannel, CategoryChannel] @@ -384,6 +384,12 @@ def _filter_threads(self, channel_ids: Set[int]) -> Dict[int, Thread]: del self._threads[k] return to_remove + def _add_soundboard_sound(self, sound: SoundboardSound, /) -> None: + self._soundboard_sounds[sound.id] = sound + + def _remove_soundboard_sound(self, sound: SoundboardSound, /) -> None: + self._soundboard_sounds.pop(sound.id, None) + def __str__(self) -> str: return self.name or '' @@ -556,7 +562,7 @@ def _from_data(self, guild: GuildPayload) -> None: if 'soundboard_sounds' in guild: for s in guild['soundboard_sounds']: soundboard_sound = SoundboardSound(data=s, state=self._state) - self._soundboard_sounds[soundboard_sound.id] = soundboard_sound + self._add_soundboard_sound(soundboard_sound) @property def channels(self) -> Sequence[GuildChannel]: @@ -1030,7 +1036,7 @@ def get_soundboard_sound(self, sound_id: int, /) -> Optional[SoundboardSound]: Optional[:class:`SoundboardSound`] The soundboard sound or ``None`` if not found. """ - return self._scheduled_events.get(sound_id) + return self._soundboard_sounds.get(sound_id) @property def owner(self) -> Optional[Member]: @@ -4331,7 +4337,7 @@ async def create_soundboard_sound( *, name: str, sound: bytes, - volume: Optional[float] = None, + volume: float = 1, emoji: Optional[EmojiInputType] = None, reason: Optional[str] = None, ) -> SoundboardSound: @@ -4340,6 +4346,8 @@ async def create_soundboard_sound( Creates a :class:`SoundboardSound` for the guild. You must have :attr:`Permissions.manage_expressions` to do this. + .. versionadded:: 2.4 + Parameters ---------- name: :class:`str` @@ -4347,8 +4355,8 @@ async def create_soundboard_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: Optional[:class:`float`] - The volume of the sound. Must be between 0 and 1. + volume: :class:`float` + The volume of the sound. Must be between 0 and 1. Defaults to ``1``. emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]] The emoji of the sound. reason: Optional[:class:`str`] @@ -4390,3 +4398,30 @@ async def create_soundboard_sound( data = await self._state.http.create_soundboard_sound(self.id, reason=reason, **payload) return SoundboardSound(state=self._state, data=data) + + async def request_soundboard_sounds(self, *, cache: bool = True) -> List[SoundboardSound]: + """|coro| + + Requests the soundboard sounds of the guild. + + This is a websocket operation and can be slow. + + .. versionadded:: 2.4 + + Parameters + ---------- + cache: :class:`bool` + Whether to cache the soundboard sounds internally. Defaults to ``True``. + + Raises + ------- + asyncio.TimeoutError + The query timed out waiting for the sounds. + + Returns + -------- + List[:class:`SoundboardSound`] + A list of guilds with it's requested soundboard sounds. + """ + + return await self._state.request_soundboard_sounds(self, cache=cache) diff --git a/discord/soundboard.py b/discord/soundboard.py index e9c30ddcb69e..472b378fd539 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -46,7 +46,7 @@ from .asset import Asset from .message import EmojiInputType -__all__ = ('SoundboardDefaultSound', 'SoundboardSound') +__all__ = ('BaseSoundboardSound', 'SoundboardDefaultSound', 'SoundboardSound') class BaseSoundboardSound(Hashable): @@ -176,8 +176,8 @@ class SoundboardSound(BaseSoundboardSound): The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%). name: :class:`str` The name of the sound. - emoji: :class:`PartialEmoji` - The emoji of the sound. + emoji: Optional[:class:`PartialEmoji`] + The emoji of the sound. ``None`` if no emoji is set. guild_id: Optional[:class:`int`] The ID of the guild in which the sound is uploaded. user: Optional[:class:`User`] @@ -303,6 +303,11 @@ async def delete(self, *, reason: Optional[str] = None) -> None: You must have :attr:`~Permissions.manage_expressions` to delete the sound. + Parameters + ----------- + reason: Optional[:class:`str`] + The reason for deleting this sound. Shows up on the audit log. + Raises ------- Forbidden diff --git a/discord/state.py b/discord/state.py index 949a3245847e..5e56ab1863a3 100644 --- a/discord/state.py +++ b/discord/state.py @@ -155,6 +155,53 @@ def done(self) -> None: future.set_result(self.buffer) +class SoundboardSoundRequest: + def __init__( + self, + guild_id: int, + loop: asyncio.AbstractEventLoop, + resolver: Callable[[int], Any], + *, + cache: bool = True, + ) -> None: + self.guild_id: int = guild_id + self.loop: asyncio.AbstractEventLoop = loop + self.resolver: Callable[[int], Any] = resolver + self.cache: bool = cache + self.buffer: List[SoundboardSound] = [] + self.waiters: List[asyncio.Future[List[SoundboardSound]]] = [] + + def add_soundboard_sounds(self, sounds: List[SoundboardSound]) -> None: + self.buffer.extend(sounds) + if self.cache: + guild = self.resolver(self.guild_id) + if guild is None: + return + + for sound in sounds: + existing = guild.get_soundboard_sound(sound.id) + if existing is None: + guild._add_soundboard_sound(sound) + + async def wait(self) -> List[SoundboardSound]: + future = self.loop.create_future() + self.waiters.append(future) + try: + return await future + finally: + self.waiters.remove(future) + + def get_future(self) -> asyncio.Future[List[SoundboardSound]]: + future = self.loop.create_future() + self.waiters.append(future) + return future + + def done(self) -> None: + for future in self.waiters: + if not future.done(): + future.set_result(self.buffer) + + _log = logging.getLogger(__name__) @@ -206,6 +253,7 @@ def __init__( self.allowed_mentions: Optional[AllowedMentions] = allowed_mentions self._chunk_requests: Dict[Union[int, str], ChunkRequest] = {} + self._soundboard_sounds_requests: Dict[int, SoundboardSoundRequest] = {} activity = options.get('activity', None) if activity: @@ -316,6 +364,16 @@ def process_chunk_requests(self, guild_id: int, nonce: Optional[str], members: L for key in removed: del self._chunk_requests[key] + def process_soundboard_sounds_request(self, guild_id: int, sounds: List[SoundboardSound]) -> None: + request = self._soundboard_sounds_requests.get(guild_id) + if request is None: + return + + request.add_soundboard_sounds(sounds) + request.done() + + del self._soundboard_sounds_requests[guild_id] + def call_handlers(self, key: str, *args: Any, **kwargs: Any) -> None: try: func = self.handlers[key] @@ -549,6 +607,22 @@ async def query_members( _log.warning('Timed out waiting for chunks with query %r and limit %d for guild_id %d', query, limit, guild_id) raise + async def request_soundboard_sounds(self, guild: Guild, cache: bool) -> List[SoundboardSound]: + guild_id = guild.id + ws = self._get_websocket(guild_id) + if ws is None: + raise RuntimeError('Somehow do not have a websocket for this guild_id') + + request = SoundboardSoundRequest(guild_id, self.loop, self._get_guild, cache=cache) + self._soundboard_sounds_requests[request.guild_id] = request + + try: + await ws.request_soundboard_sounds(guild_ids=[guild_id]) + return await asyncio.wait_for(request.wait(), timeout=30) + except asyncio.TimeoutError: + _log.warning('Timed out waiting for soundboard sounds request') + raise + async def _delay_ready(self) -> None: try: states = [] @@ -1528,20 +1602,18 @@ def parse_guild_scheduled_event_user_remove(self, data: gw.GuildScheduledEventUs _log.debug('SCHEDULED_EVENT_USER_REMOVE referencing unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_create(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: - print("SOUNDBOARD_SOUND_CREATE\n" "-----------------------\n" f"{data}\n") guild = self._get_guild(int(data['guild_id'])) if guild is not None: sound = SoundboardSound(state=self, data=data) - guild._soundboard_sounds[sound.id] = sound + guild._add_soundboard_sound(sound) self.dispatch('soundboard_sound_create', sound) else: _log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: - print("SOUNDBOARD_SOUND_UPDATE\n" "-----------------------\n" f"{data}\n") guild = self._get_guild(int(data['guild_id'])) if guild is not None: - sound = guild._soundboard_sounds.get(int(data['sound_id'])) + sound = guild.get_soundboard_sound(int(data['sound_id'])) if sound is not None: old_sound = copy.copy(sound) sound._update(data) @@ -1552,18 +1624,19 @@ def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundCreat _log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_delete(self, data: gw.GuildSoundBoardSoundDeleteEvent) -> None: - print("SOUNDBOARD_SOUND_DELETE\n" "-----------------------\n" f"{data}\n") guild = self._get_guild(int(data['guild_id'])) if guild is not None: - try: - sound = guild._soundboard_sounds.pop(int(data['sound_id'])) - except KeyError: - pass - else: + sound = guild.get_soundboard_sound(int(data['guild_id'])) + if sound is not None: + guild._remove_soundboard_sound(sound) self.dispatch('soundboard_sound_delete', sound) else: _log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_soundboard_sounds(self, data: gw.SoundboardSoundsRequestEvent) -> None: + sounds = [SoundboardSound(state=self, data=sound) for sound in data['soundboard_sounds']] + self.process_soundboard_sounds_request(int(data['guild_id']), sounds) + def parse_application_command_permissions_update(self, data: GuildApplicationCommandPermissionsPayload): raw = RawAppCommandPermissionsUpdateEvent(data=data, state=self) self.dispatch('raw_app_command_permissions_update', raw) diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 9536e809be49..ca6d5679cb4d 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -360,3 +360,8 @@ class GuildAuditLogEntryCreate(AuditLogEntry): EntitlementCreateEvent = EntitlementUpdateEvent = EntitlementDeleteEvent = Entitlement + + +class SoundboardSoundsRequestEvent(TypedDict): + guild_id: Snowflake + soundboard_sounds: List[SoundboardSound] diff --git a/discord/types/guild.py b/discord/types/guild.py index 55b18c614716..4d691460c9ee 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -152,6 +152,7 @@ class Guild(_BaseGuildPreview): max_video_channel_users: NotRequired[int] soundboard_sounds: NotRequired[List[SoundboardSound]] + class InviteGuild(Guild, total=False): welcome_screen: WelcomeScreen diff --git a/docs/api.rst b/docs/api.rst index 3b9fe36a1eb8..7a3ffb57921a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1265,7 +1265,7 @@ Soundboard Called when a :class:`SoundboardSound` is created or deleted. - .. versionadded:: 2.3 + .. versionadded:: 2.4 :param sound: The soundboard sound that was created or deleted. :type sound: :class:`SoundboardSound` @@ -1280,7 +1280,7 @@ Soundboard - The emoji is changed. - The volume is changed. - .. versionadded:: 2.3 + .. versionadded:: 2.4 :param sound: The soundboard sound that was updated. :type sound: :class:`SoundboardSound` @@ -2930,7 +2930,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.emoji` - :attr:`~AuditLogDiff.volume` - .. versionadded:: 2.3 + .. versionadded:: 2.4 .. attribute:: soundboard_sound_update @@ -2942,7 +2942,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.emoji` - :attr:`~AuditLogDiff.volume` - .. versionadded:: 2.3 + .. versionadded:: 2.4 .. attribute:: soundboard_sound_delete @@ -2954,7 +2954,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.emoji` - :attr:`~AuditLogDiff.volume` - .. versionadded:: 2.3 + .. versionadded:: 2.4 .. class:: AuditLogActionCategory @@ -4926,20 +4926,28 @@ GuildSticker .. autoclass:: GuildSticker() :members: -SoundboardSound +BaseSoundboardSound ~~~~~~~~~~~~~~~~~~~~~~~ -.. attributetable:: SoundboardSound +.. attributetable:: BaseSoundboardSound -.. autoclass:: SoundboardSound() +.. autoclass:: BaseSoundboardSound() + :members: + +SoundboardDefaultSound +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: SoundboardDefaultSound + +.. autoclass:: SoundboardDefaultSound() :members: -DefaultSoundboardSound +SoundboardSound ~~~~~~~~~~~~~~~~~~~~~~~ -.. attributetable:: DefaultSoundboardSound +.. attributetable:: SoundboardSound -.. autoclass:: DefaultSoundboardSound() +.. autoclass:: SoundboardSound() :members: ShardInfo From 2c9c2f2c35d192f72e1befb1de414a192a19e025 Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Tue, 31 Oct 2023 22:08:01 +0100 Subject: [PATCH 08/30] Fix errors, add missing things --- discord/guild.py | 4 ++-- discord/http.py | 6 +++--- discord/soundboard.py | 36 +++++++++++++++++++++--------------- discord/state.py | 28 ++++++++++++++++++---------- discord/types/soundboard.py | 3 ++- discord/utils.py | 8 +++++--- 6 files changed, 51 insertions(+), 34 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 4ffebb5054b2..60e1b8263939 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -561,7 +561,7 @@ def _from_data(self, guild: GuildPayload) -> None: if 'soundboard_sounds' in guild: for s in guild['soundboard_sounds']: - soundboard_sound = SoundboardSound(data=s, state=self._state) + soundboard_sound = SoundboardSound(guild=self, data=s, state=self._state) self._add_soundboard_sound(soundboard_sound) @property @@ -4397,7 +4397,7 @@ async def create_soundboard_sound( payload['emoji_id'] = partial_emoji.id data = await self._state.http.create_soundboard_sound(self.id, reason=reason, **payload) - return SoundboardSound(state=self._state, data=data) + return SoundboardSound(guild=self, state=self._state, data=data) async def request_soundboard_sounds(self, *, cache: bool = True) -> List[SoundboardSound]: """|coro| diff --git a/discord/http.py b/discord/http.py index d759f6a9944a..065668fd9ad5 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1590,7 +1590,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 ValueError: if initial_bytes.startswith(b'{'): mime_type = 'application/json' @@ -2489,7 +2489,7 @@ def edit_soundboard_sound( return self.request( Route( 'PATCH', - '/guilds/{guild_id}/soundboard-sounds/{sound_id}/soundboard-sound-object', + '/guilds/{guild_id}/soundboard-sounds/{sound_id}', guild_id=guild_id, sound_id=sound_id, ), @@ -2501,7 +2501,7 @@ def delete_soundboard_sound(self, guild_id: Snowflake, sound_id: Snowflake, *, r return self.request( Route( 'DELETE', - '/guilds/{guild_id}/soundboard-sounds/{sound_id}/soundboard-sound-object', + '/guilds/{guild_id}/soundboard-sounds/{sound_id}', guild_id=guild_id, sound_id=sound_id, ), diff --git a/discord/soundboard.py b/discord/soundboard.py index 472b378fd539..865eaf0bb495 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -31,6 +31,7 @@ from .partial_emoji import PartialEmoji, _EmojiTag from .user import User from .utils import MISSING +from .asset import Asset if TYPE_CHECKING: import datetime @@ -43,7 +44,6 @@ ) from .state import ConnectionState from .guild import Guild - from .asset import Asset from .message import EmojiInputType __all__ = ('BaseSoundboardSound', 'SoundboardDefaultSound', 'SoundboardSound') @@ -178,22 +178,25 @@ class SoundboardSound(BaseSoundboardSound): The name of the sound. emoji: Optional[:class:`PartialEmoji`] The emoji of the sound. ``None`` if no emoji is set. - guild_id: Optional[:class:`int`] + guild: :class:`Guild` + The guild in which the sound is uploaded. + guild_id: :class:`int` The ID of the guild in which the sound is uploaded. - user: Optional[:class:`User`] - The user who uploaded the sound. + user_id: :class:`int` + The ID of the user who uploaded the sound. available: :class:`bool` Whether this sound is available for use. """ - __slots__ = ('_state', 'guild_id', 'name', 'emoji', 'user', 'available') + __slots__ = ('_state', 'guild_id', 'name', 'emoji', '_user', 'available', 'user_id', 'guild') - def __init__(self, *, state: ConnectionState, data: SoundboardSoundPayload): + def __init__(self, *, guild: Guild, state: ConnectionState, data: SoundboardSoundPayload): super().__init__(state=state, data=data) - self.guild_id: int = int(data['guild_id']) + self.guild = guild + self.guild_id: int = guild.id + self.user_id: int = int(data['user_id']) + self._user = data.get('user') - user = data.get('user') - self.user: Optional[User] = User(state=self._state, data=user) if user is not None else None self._update(data) def __repr__(self) -> str: @@ -220,16 +223,19 @@ def _update(self, data: SoundboardSoundPayload): self.available: bool = data['available'] - @property - def guild(self) -> Optional[Guild]: - """Optional[:class:`Guild`]: The guild in which the sound is uploaded.""" - return self._state._get_guild(self.guild_id) - @property def created_at(self) -> datetime.datetime: """:class:`datetime.datetime`: Returns the snowflake's creation time in UTC.""" return utils.snowflake_time(self.id) + @property + def user(self) -> Optional[User]: + """Optional[:class:`User`]: The user who uploaded the sound.""" + if self._user is None: + return self._state.get_user(self.user_id) + + return User(state=self._state, data=self._user) + async def edit( self, name: str = MISSING, @@ -294,7 +300,7 @@ async def edit( payload['emoji_id'] = partial_emoji.id data = await self._state.http.edit_soundboard_sound(self.guild_id, self.id, reason=reason, **payload) - return SoundboardSound(state=self._state, data=data) + return SoundboardSound(guild=self.guild, state=self._state, data=data) async def delete(self, *, reason: Optional[str] = None) -> None: """|coro| diff --git a/discord/state.py b/discord/state.py index 5e56ab1863a3..5b38473ffbe1 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1602,31 +1602,34 @@ def parse_guild_scheduled_event_user_remove(self, data: gw.GuildScheduledEventUs _log.debug('SCHEDULED_EVENT_USER_REMOVE referencing unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_create(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: - guild = self._get_guild(int(data['guild_id'])) + guild_id = int(data['guild_id']) # type: ignore # can't be None here + guild = self._get_guild(guild_id) if guild is not None: - sound = SoundboardSound(state=self, data=data) + sound = SoundboardSound(guild=guild, state=self, data=data) guild._add_soundboard_sound(sound) self.dispatch('soundboard_sound_create', sound) else: - _log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + _log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing unknown guild ID: %s. Discarding.', guild_id) def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: - guild = self._get_guild(int(data['guild_id'])) + guild_id = int(data['guild_id']) # type: ignore # can't be None here + guild = self._get_guild(guild_id) if guild is not None: - sound = guild.get_soundboard_sound(int(data['sound_id'])) + sound_id = int(data['sound_id']) + sound = guild.get_soundboard_sound(sound_id) if sound is not None: old_sound = copy.copy(sound) sound._update(data) self.dispatch('soundboard_sound_update', old_sound, sound) else: - _log.warning('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown sound ID: %s. Discarding.', data['sound_id']) + _log.warning('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown sound ID: %s. Discarding.', sound_id) else: - _log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + _log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown guild ID: %s. Discarding.', guild_id) def parse_guild_soundboard_sound_delete(self, data: gw.GuildSoundBoardSoundDeleteEvent) -> None: guild = self._get_guild(int(data['guild_id'])) if guild is not None: - sound = guild.get_soundboard_sound(int(data['guild_id'])) + sound = guild.get_soundboard_sound(int(data['sound_id'])) if sound is not None: guild._remove_soundboard_sound(sound) self.dispatch('soundboard_sound_delete', sound) @@ -1634,8 +1637,13 @@ def parse_guild_soundboard_sound_delete(self, data: gw.GuildSoundBoardSoundDelet _log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown guild ID: %s. Discarding.', data['guild_id']) def parse_soundboard_sounds(self, data: gw.SoundboardSoundsRequestEvent) -> None: - sounds = [SoundboardSound(state=self, data=sound) for sound in data['soundboard_sounds']] - self.process_soundboard_sounds_request(int(data['guild_id']), sounds) + guild_id = int(data['guild_id']) + guild = self._get_guild(guild_id) + if guild is not None: + sounds = [SoundboardSound(guild=guild, state=self, data=sound) for sound in data['soundboard_sounds']] + self.process_soundboard_sounds_request(guild_id, sounds) + else: + _log.debug('SOUNDBOARD_SOUNDS referencing unknown guild ID: %s. Discarding.', guild_id) def parse_application_command_permissions_update(self, data: GuildApplicationCommandPermissionsPayload): raw = RawAppCommandPermissionsUpdateEvent(data=data, state=self) diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py index 0e256915cd68..edf236f43887 100644 --- a/discord/types/soundboard.py +++ b/discord/types/soundboard.py @@ -23,6 +23,7 @@ """ from typing import TypedDict, Optional, Union +from typing_extensions import NotRequired from .snowflake import Snowflake from .user import User @@ -39,7 +40,7 @@ class SoundboardSound(BaseSoundboardSound): emoji_id: Optional[Snowflake] user_id: Snowflake available: bool - guild_id: Snowflake + guild_id: NotRequired[Snowflake] user: User diff --git a/discord/utils.py b/discord/utils.py index 33a4020a2504..c114e9f4a229 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -609,7 +609,7 @@ def _get_as_snowflake(data: Any, key: str) -> Optional[int]: 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'): @@ -618,13 +618,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 ValueError('Unsupported image type given') + raise ValueError('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 b3d5199754c34561995aabf1c3186eac32d9343e Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Thu, 2 Nov 2023 20:11:48 +0100 Subject: [PATCH 09/30] Add get_soundboard_sound and soundboard_sounds property to Client --- discord/client.py | 27 ++++++++++++++++++++++++++- discord/guild.py | 6 ++++++ discord/state.py | 17 +++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index abc4451c285c..ef171029394f 100644 --- a/discord/client.py +++ b/discord/client.py @@ -77,7 +77,7 @@ from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory -from .soundboard import SoundboardDefaultSound +from .soundboard import SoundboardDefaultSound, SoundboardSound if TYPE_CHECKING: from types import TracebackType @@ -366,6 +366,14 @@ def stickers(self) -> Sequence[GuildSticker]: """ return self._connection.stickers + @property + def soundboard_sounds(self) -> Sequence[SoundboardSound]: + """Sequence[:class:`.SoundboardSound`]: The soundboard sounds that the connected client has. + + .. versionadded:: 2.4 + """ + return self._connection.soundboard_sounds + @property def cached_messages(self) -> Sequence[Message]: """Sequence[:class:`.Message`]: Read-only list of messages the connected client has cached. @@ -1085,6 +1093,23 @@ def get_sticker(self, id: int, /) -> Optional[GuildSticker]: """ return self._connection.get_sticker(id) + def get_soundboard_sound(self, id: int, /) -> Optional[SoundboardSound]: + """Returns a soundboard sound with the given ID. + + .. versionadded:: 2.4 + + Parameters + ---------- + id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`.SoundboardSound`] + The soundboard sound or ``None`` if not found. + """ + return self._connection.get_soundboard_sound(id) + def get_all_channels(self) -> Generator[GuildChannel, None, None]: """A generator that retrieves every :class:`.abc.GuildChannel` the client can 'access'. diff --git a/discord/guild.py b/discord/guild.py index 60e1b8263939..a07008b8498b 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1038,6 +1038,12 @@ def get_soundboard_sound(self, sound_id: int, /) -> Optional[SoundboardSound]: """ return self._soundboard_sounds.get(sound_id) + def _resolve_soundboard_sound(self, id: Optional[int], /) -> Optional[SoundboardSound]: + if id is None: + return + + return self._soundboard_sounds.get(id) + @property def owner(self) -> Optional[Member]: """Optional[:class:`Member`]: The member that owns the guild.""" diff --git a/discord/state.py b/discord/state.py index 5b38473ffbe1..043e7aef724a 100644 --- a/discord/state.py +++ b/discord/state.py @@ -501,6 +501,14 @@ def emojis(self) -> Sequence[Emoji]: def stickers(self) -> Sequence[GuildSticker]: return utils.SequenceProxy(self._stickers.values()) + @property + def soundboard_sounds(self) -> Sequence[SoundboardSound]: + all_sounds = [] + for guild in self.guilds: + all_sounds.extend(guild.soundboard_sounds) + + return utils.SequenceProxy(all_sounds) + def get_emoji(self, emoji_id: Optional[int]) -> Optional[Emoji]: # the keys of self._emojis are ints return self._emojis.get(emoji_id) # type: ignore @@ -1768,6 +1776,15 @@ def get_channel(self, id: Optional[int]) -> Optional[Union[Channel, Thread]]: def create_message(self, *, channel: MessageableChannel, data: MessagePayload) -> Message: return Message(state=self, channel=channel, data=data) + def get_soundboard_sound(self, id: Optional[int]) -> Optional[SoundboardSound]: + if id is None: + return + + for guild in self.guilds: + sound = guild._resolve_soundboard_sound(id) + if sound is not None: + return sound + class AutoShardedConnectionState(ConnectionState[ClientT]): def __init__(self, *args: Any, **kwargs: Any) -> None: From 3ee03bc57d8a305ced0896c94f8cf08619b095af Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sat, 27 Jan 2024 13:15:07 +0100 Subject: [PATCH 10/30] Make user optional in VoiceChannelEffect --- discord/channel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index a886115a3113..f6f6d89c31a8 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -198,8 +198,8 @@ class VoiceChannelEffect: ------------ channel: :class:`VoiceChannel` The channel in which the effect is sent. - user: :class:`Member` - The user who sent the effect. + user: Optional[:class:`Member`] + The user who sent the effect. ``None`` if not found in cache. animation: Optional[:class:`VoiceChannelEffectAnimation`] The animation the effect has. Returns ``None`` if the effect has no animation. emoji: Optional[:class:`PartialEmoji`] @@ -212,7 +212,7 @@ class VoiceChannelEffect: def __init__(self, *, state: ConnectionState, data: VoiceChannelEffectPayload, guild: Guild): self.channel: VoiceChannel = guild.get_channel(int(data['channel_id'])) # type: ignore # will always be a VoiceChannel - self.user: Member = guild.get_member(int(data['user_id'])) # type: ignore # will always be a Member + self.user: Optional[Member] = guild.get_member(int(data['user_id'])) self.animation: Optional[VoiceChannelEffectAnimation] = None animation_id = data.get('animation_id') From 95f828c8e5c266b8acbcd7205a82459cc4541e73 Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sat, 27 Jan 2024 13:20:06 +0100 Subject: [PATCH 11/30] Coerce sound_volume --- discord/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/channel.py b/discord/channel.py index f6f6d89c31a8..b70de540f465 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -226,7 +226,7 @@ def __init__(self, *, state: ConnectionState, data: VoiceChannelEffectPayload, g sound_id: Optional[int] = utils._get_as_snowflake(data, 'sound_id') if sound_id is not None: - sound_volume = data['sound_volume'] # type: ignore # sound_volume cannot be None here + sound_volume = data.get('sound_volume') or 0.0 self.sound = VoiceChannelSoundEffect(state=state, id=sound_id, volume=sound_volume) def __repr__(self) -> str: From c801b0e044fd0ed73b3ec778f4caef81e16dc4e1 Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sat, 27 Jan 2024 13:46:36 +0100 Subject: [PATCH 12/30] Change representation of VoiceChannelSoundEffect Co-Authored-By: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/channel.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index b70de540f465..fcc40e0447eb 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -167,12 +167,7 @@ def __init__(self, *, state: ConnectionState, id: int, volume: float): super().__init__(state=state, data=data) def __repr__(self) -> str: - attrs = [ - ('id', self.id), - ('volume', self.volume), - ] - inner = ' '.join('%s=%r' % t for t in attrs) - return f"<{self.__class__.__name__} {inner}>" + return f"<{self.__class__.__name__} id={self.id} volume={self.volume}>" @property def created_at(self) -> Optional[datetime.datetime]: From b68392d49470f933b5baeadbb78eab7a3670c351 Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sat, 27 Jan 2024 14:01:38 +0100 Subject: [PATCH 13/30] Make BaseSoundboardSound inherit from AssetMixin --- discord/asset.py | 8 -------- discord/soundboard.py | 10 +++++----- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/discord/asset.py b/discord/asset.py index 019e1179c1a5..d08635632015 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -335,14 +335,6 @@ def _from_user_banner(cls, state: _State, user_id: int, banner_hash: str) -> Sel animated=animated, ) - @classmethod - def _from_soundboard_sound(cls, state: _State, sound_id: int) -> Self: - 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/soundboard.py b/discord/soundboard.py index 865eaf0bb495..161157e331bf 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -31,7 +31,7 @@ from .partial_emoji import PartialEmoji, _EmojiTag from .user import User from .utils import MISSING -from .asset import Asset +from .asset import Asset, AssetMixin if TYPE_CHECKING: import datetime @@ -49,7 +49,7 @@ __all__ = ('BaseSoundboardSound', 'SoundboardDefaultSound', 'SoundboardSound') -class BaseSoundboardSound(Hashable): +class BaseSoundboardSound(Hashable, AssetMixin): """Represents a generic Discord soundboard sound. .. versionadded:: 2.4 @@ -95,9 +95,9 @@ def _update(self, data: BaseSoundboardSoundPayload): self.volume: float = data['volume'] @property - def file(self) -> Asset: - """:class:`Asset`: Returns the sound file asset.""" - return Asset._from_soundboard_sound(self._state, self.id) + def url(self) -> str: + """:class:`str`: Returns the URL of the sound.""" + return f'{Asset.BASE}/soundboard-sounds/{self.id}' class SoundboardDefaultSound(BaseSoundboardSound): From b78805753152fe314b53e6e21d447624cee17b0f Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sat, 27 Jan 2024 14:12:48 +0100 Subject: [PATCH 14/30] Add new helper function _get_mime_type_for_audio --- discord/guild.py | 2 +- discord/http.py | 2 +- discord/utils.py | 18 +++++++++++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 20c383a87452..6ec35949e614 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4467,7 +4467,7 @@ async def create_soundboard_sound( """ payload: Dict[str, Any] = { 'name': name, - 'sound': utils._bytes_to_base64_data(sound), + 'sound': utils._bytes_to_base64_data(sound, audio=True), 'volume': volume, 'emoji_id': None, 'emoji_name': None, diff --git a/discord/http.py b/discord/http.py index 596b1a4438bc..2660bb9889cf 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1604,7 +1604,7 @@ def create_guild_sticker( initial_bytes = file.fp.read(16) try: - mime_type = utils._get_mime_type_for_file(initial_bytes) + mime_type = utils._get_mime_type_for_image(initial_bytes) except ValueError: if initial_bytes.startswith(b'{'): mime_type = 'application/json' diff --git a/discord/utils.py b/discord/utils.py index c114e9f4a229..908c0b356c8c 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -609,7 +609,7 @@ def _get_as_snowflake(data: Any, key: str) -> Optional[int]: return value and int(value) -def _get_mime_type_for_file(data: bytes): +def _get_mime_type_for_image(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'): @@ -618,15 +618,23 @@ def _get_mime_type_for_file(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'): + else: + raise ValueError('Unsupported image type given') + + +def _get_mime_type_for_audio(data: bytes): + if data.startswith(b'\x49\x44\x33') or data.startswith(b'\xff\xfb'): return 'audio/mpeg' else: - raise ValueError('Unsupported file type given') + raise ValueError('Unsupported audio type given') -def _bytes_to_base64_data(data: bytes) -> str: +def _bytes_to_base64_data(data: bytes, *, audio: bool = False) -> str: fmt = 'data:{mime};base64,{data}' - mime = _get_mime_type_for_file(data) + if audio: + mime = _get_mime_type_for_audio(data) + else: + mime = _get_mime_type_for_image(data) b64 = b64encode(data).decode('ascii') return fmt.format(mime=mime, data=b64) From 0f61f5eac38ddd90eca7d8c38b707d20bb30dbcc Mon Sep 17 00:00:00 2001 From: Puncher <65789180+Puncher1@users.noreply.github.com> Date: Sat, 27 Jan 2024 14:18:40 +0100 Subject: [PATCH 15/30] Change from Sequence to List for soundboard_sounds --- discord/client.py | 4 ++-- discord/state.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/client.py b/discord/client.py index ef171029394f..32087a23d772 100644 --- a/discord/client.py +++ b/discord/client.py @@ -367,8 +367,8 @@ def stickers(self) -> Sequence[GuildSticker]: return self._connection.stickers @property - def soundboard_sounds(self) -> Sequence[SoundboardSound]: - """Sequence[:class:`.SoundboardSound`]: The soundboard sounds that the connected client has. + def soundboard_sounds(self) -> List[SoundboardSound]: + """List[:class:`.SoundboardSound`]: The soundboard sounds that the connected client has. .. versionadded:: 2.4 """ diff --git a/discord/state.py b/discord/state.py index 60eb8b0a7b0c..c6a237df4584 100644 --- a/discord/state.py +++ b/discord/state.py @@ -514,12 +514,12 @@ def stickers(self) -> Sequence[GuildSticker]: return utils.SequenceProxy(self._stickers.values()) @property - def soundboard_sounds(self) -> Sequence[SoundboardSound]: + def soundboard_sounds(self) -> List[SoundboardSound]: all_sounds = [] for guild in self.guilds: all_sounds.extend(guild.soundboard_sounds) - return utils.SequenceProxy(all_sounds) + return all_sounds def get_emoji(self, emoji_id: Optional[int]) -> Optional[Emoji]: # the keys of self._emojis are ints From 3f49bc4358a055aa0d0a8d9714db72b02d5fd353 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Tue, 6 Aug 2024 16:37:35 +0200 Subject: [PATCH 16/30] Fix lint --- discord/enums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/enums.py b/discord/enums.py index 341e9e602132..9000c8c04576 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -841,7 +841,7 @@ class ReactionType(Enum): normal = 0 burst = 1 - + class VoiceChannelEffectAnimationType(Enum): premium = 0 basic = 1 From 51ed284a0c9d5f13f42451755c9003eaf728fb45 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Tue, 6 Aug 2024 16:38:44 +0200 Subject: [PATCH 17/30] Remove emoji_id from default sound emoji_id is always None for default sounds --- discord/soundboard.py | 2 +- discord/types/soundboard.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/discord/soundboard.py b/discord/soundboard.py index 161157e331bf..181e7b0a0a78 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -135,7 +135,7 @@ class SoundboardDefaultSound(BaseSoundboardSound): def __init__(self, *, state: ConnectionState, data: SoundboardDefaultSoundPayload): self.name: str = data['name'] - self.emoji: PartialEmoji = PartialEmoji(name=data['emoji_name'], id=utils._get_as_snowflake(data, 'emoji_id')) + self.emoji: PartialEmoji = PartialEmoji(name=data['emoji_name']) super().__init__(state=state, data=data) def __repr__(self) -> str: diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py index edf236f43887..18f21128a3c1 100644 --- a/discord/types/soundboard.py +++ b/discord/types/soundboard.py @@ -47,5 +47,4 @@ class SoundboardSound(BaseSoundboardSound): class SoundboardDefaultSound(BaseSoundboardSound): name: str emoji_name: str - emoji_id: Optional[Snowflake] user_id: Snowflake From a051cde5fb58756d41bd7a57b37bb901850f9126 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Wed, 7 Aug 2024 12:22:27 +0200 Subject: [PATCH 18/30] Partially remove user_id user_id is still used in the GUILD_CREATE event --- discord/soundboard.py | 11 +++++------ discord/types/soundboard.py | 5 ++--- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/discord/soundboard.py b/discord/soundboard.py index 181e7b0a0a78..ddc8ad4b3217 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -182,19 +182,17 @@ class SoundboardSound(BaseSoundboardSound): The guild in which the sound is uploaded. guild_id: :class:`int` The ID of the guild in which the sound is uploaded. - user_id: :class:`int` - The ID of the user who uploaded the sound. available: :class:`bool` Whether this sound is available for use. """ - __slots__ = ('_state', 'guild_id', 'name', 'emoji', '_user', 'available', 'user_id', 'guild') + __slots__ = ('_state', 'guild_id', 'name', 'emoji', '_user', 'available', '_user_id', 'guild') def __init__(self, *, guild: Guild, state: ConnectionState, data: SoundboardSoundPayload): super().__init__(state=state, data=data) self.guild = guild self.guild_id: int = guild.id - self.user_id: int = int(data['user_id']) + self._user_id = utils._get_as_snowflake(data, 'user_id') self._user = data.get('user') self._update(data) @@ -232,8 +230,9 @@ def created_at(self) -> datetime.datetime: def user(self) -> Optional[User]: """Optional[:class:`User`]: The user who uploaded the sound.""" if self._user is None: - return self._state.get_user(self.user_id) - + if self._user_id is None: + return None + return self._state.get_user(self._user_id) return User(state=self._state, data=self._user) async def edit( diff --git a/discord/types/soundboard.py b/discord/types/soundboard.py index 18f21128a3c1..4910df8082f5 100644 --- a/discord/types/soundboard.py +++ b/discord/types/soundboard.py @@ -38,13 +38,12 @@ class SoundboardSound(BaseSoundboardSound): name: str emoji_name: Optional[str] emoji_id: Optional[Snowflake] - user_id: Snowflake + user_id: NotRequired[Snowflake] available: bool guild_id: NotRequired[Snowflake] - user: User + user: NotRequired[User] class SoundboardDefaultSound(BaseSoundboardSound): name: str emoji_name: str - user_id: Snowflake From 6b0c1c1d206c46b99cc609b07f69eb817fa82ee3 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Wed, 7 Aug 2024 13:03:23 +0200 Subject: [PATCH 19/30] Change versionadded from 2.4 to 2.5 --- discord/channel.py | 4 ++-- discord/client.py | 6 +++--- discord/guild.py | 8 ++++---- discord/soundboard.py | 6 +++--- docs/api.rst | 16 ++++++++-------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 2317d35407b0..1ceef9eb8654 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -133,7 +133,7 @@ class VoiceChannelEffectAnimation(NamedTuple): class VoiceChannelSoundEffect(BaseSoundboardSound): """Represents a Discord voice channel sound effect. - .. versionadded:: 2.4 + .. versionadded:: 2.5 .. container:: operations @@ -187,7 +187,7 @@ def is_default(self) -> bool: class VoiceChannelEffect: """Represents a Discord voice channel effect. - .. versionadded:: 2.4 + .. versionadded:: 2.5 Attributes ------------ diff --git a/discord/client.py b/discord/client.py index 98188781a95a..0590285384a8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -375,7 +375,7 @@ def stickers(self) -> Sequence[GuildSticker]: def soundboard_sounds(self) -> List[SoundboardSound]: """List[:class:`.SoundboardSound`]: The soundboard sounds that the connected client has. - .. versionadded:: 2.4 + .. versionadded:: 2.5 """ return self._connection.soundboard_sounds @@ -1103,7 +1103,7 @@ def get_sticker(self, id: int, /) -> Optional[GuildSticker]: def get_soundboard_sound(self, id: int, /) -> Optional[SoundboardSound]: """Returns a soundboard sound with the given ID. - .. versionadded:: 2.4 + .. versionadded:: 2.5 Parameters ---------- @@ -2950,7 +2950,7 @@ async def fetch_soundboard_default_sounds(self) -> List[SoundboardDefaultSound]: Retrieves all default soundboard sounds. - .. versionadded:: 2.4 + .. versionadded:: 2.5 Raises ------- diff --git a/discord/guild.py b/discord/guild.py index 0d2842165965..5c2dbe5bf698 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1014,14 +1014,14 @@ def get_scheduled_event(self, scheduled_event_id: int, /) -> Optional[ScheduledE def soundboard_sounds(self) -> Sequence[SoundboardSound]: """Sequence[:class:`SoundboardSound`]: Returns a sequence of the guild's soundboard sounds. - .. versionadded:: 2.4 + .. versionadded:: 2.5 """ return utils.SequenceProxy(self._soundboard_sounds.values()) def get_soundboard_sound(self, sound_id: int, /) -> Optional[SoundboardSound]: """Returns a soundboard sound with the given ID. - .. versionadded:: 2.4 + .. versionadded:: 2.5 Parameters ----------- @@ -4483,7 +4483,7 @@ async def create_soundboard_sound( Creates a :class:`SoundboardSound` for the guild. You must have :attr:`Permissions.manage_expressions` to do this. - .. versionadded:: 2.4 + .. versionadded:: 2.5 Parameters ---------- @@ -4543,7 +4543,7 @@ async def request_soundboard_sounds(self, *, cache: bool = True) -> List[Soundbo This is a websocket operation and can be slow. - .. versionadded:: 2.4 + .. versionadded:: 2.5 Parameters ---------- diff --git a/discord/soundboard.py b/discord/soundboard.py index ddc8ad4b3217..17758f8a2360 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -52,7 +52,7 @@ class BaseSoundboardSound(Hashable, AssetMixin): """Represents a generic Discord soundboard sound. - .. versionadded:: 2.4 + .. versionadded:: 2.5 .. container:: operations @@ -103,7 +103,7 @@ def url(self) -> str: class SoundboardDefaultSound(BaseSoundboardSound): """Represents a Discord soundboard default sound. - .. versionadded:: 2.4 + .. versionadded:: 2.5 .. container:: operations @@ -152,7 +152,7 @@ def __repr__(self) -> str: class SoundboardSound(BaseSoundboardSound): """Represents a Discord soundboard sound. - .. versionadded:: 2.4 + .. versionadded:: 2.5 .. container:: operations diff --git a/docs/api.rst b/docs/api.rst index e7a7f76da127..94773ccc4ad4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1306,7 +1306,7 @@ Soundboard Called when a :class:`SoundboardSound` is created or deleted. - .. versionadded:: 2.4 + .. versionadded:: 2.5 :param sound: The soundboard sound that was created or deleted. :type sound: :class:`SoundboardSound` @@ -1321,7 +1321,7 @@ Soundboard - The emoji is changed. - The volume is changed. - .. versionadded:: 2.4 + .. versionadded:: 2.5 :param sound: The soundboard sound that was updated. :type sound: :class:`SoundboardSound` @@ -1518,7 +1518,7 @@ Voice This requires :attr:`Intents.voice_states` to be enabled. - .. versionadded:: 2.4 + .. versionadded:: 2.5 :param effect: The effect that is sent. :type effect: :class:`VoiceChannelEffect` @@ -2995,7 +2995,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.emoji` - :attr:`~AuditLogDiff.volume` - .. versionadded:: 2.4 + .. versionadded:: 2.5 .. attribute:: soundboard_sound_update @@ -3007,7 +3007,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.emoji` - :attr:`~AuditLogDiff.volume` - .. versionadded:: 2.4 + .. versionadded:: 2.5 .. attribute:: soundboard_sound_delete @@ -3019,7 +3019,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.emoji` - :attr:`~AuditLogDiff.volume` - .. versionadded:: 2.4 + .. versionadded:: 2.5 .. class:: AuditLogActionCategory @@ -3743,7 +3743,7 @@ of :class:`enum.Enum`. Represents the animation type of a voice channel effect. - .. versionadded:: 2.4 + .. versionadded:: 2.5 .. attribute:: premium @@ -4918,7 +4918,7 @@ VoiceChannel A namedtuple which represents a voice channel effect animation. - .. versionadded:: 2.4 + .. versionadded:: 2.5 .. attribute:: id From eb792aaa4b9d4e009819664672a59c4d08a648d0 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Wed, 7 Aug 2024 17:48:32 +0200 Subject: [PATCH 20/30] Add support for get methods --- discord/guild.py | 42 ++++++++++++++++++++++++++++++++++++++++++ discord/http.py | 8 ++++++++ 2 files changed, 50 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index 5c2dbe5bf698..8aa05999fe2d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4469,6 +4469,48 @@ def dms_paused(self) -> bool: return self.dms_paused_until > utils.utcnow() + async def fetch_soundboard_sound(self, sound_id: int, /) -> SoundboardSound: + """|coro| + + Retrieves a :class:`SoundboardSound` with the specified ID. + + .. versionadded:: 2.5 + + Raises + ------- + NotFound + The sound requested could not be found. + HTTPException + Retrieving the sound failed. + + Returns + -------- + :class:`SoundboardSound` + The retrieved sound. + """ + data = await self._state.http.get_soundboard_sound(self.id, sound_id) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def fetch_soundboard_sounds(self) -> List[SoundboardSound]: + """|coro| + + Retrieves a list of all soundboard sounds for the guild. + + .. versionadded:: 2.5 + + Raises + ------- + HTTPException + Retrieving the sounds failed. + + Returns + -------- + List[:class:`SoundboardSound`] + The retrieved soundboard sounds. + """ + data = await self._state.http.get_soundboard_sounds(self.id) + return [SoundboardSound(guild=self, state=self._state, data=sound) for sound in data['items']] + async def create_soundboard_sound( self, *, diff --git a/discord/http.py b/discord/http.py index e5b593751dc7..6c89ed72a8cc 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2509,6 +2509,14 @@ def delete_entitlement(self, application_id: Snowflake, entitlement_id: Snowflak def get_soundboard_default_sounds(self) -> Response[List[soundboard.SoundboardDefaultSound]]: return self.request(Route('GET', '/soundboard-default-sounds')) + def get_soundboard_sound(self, guild_id: Snowflake, sound_id: Snowflake) -> Response[soundboard.SoundboardSound]: + return self.request( + Route('GET', '/guilds/{guild_id}/soundboard-sounds/{sound_id}', guild_id=guild_id, sound_id=sound_id) + ) + + def get_soundboard_sounds(self, guild_id: Snowflake) -> Response[Dict[str, List[soundboard.SoundboardSound]]]: + return self.request(Route('GET', '/guilds/{guild_id}/soundboard-sounds', guild_id=guild_id)) + def create_soundboard_sound( self, guild_id: Snowflake, *, reason: Optional[str], **payload: Any ) -> Response[soundboard.SoundboardSound]: From 2b97985920eddb442f89a5ca61edd0cbd583f5e1 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Thu, 15 Aug 2024 12:36:25 +0200 Subject: [PATCH 21/30] Remove guild_id attr --- discord/soundboard.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/discord/soundboard.py b/discord/soundboard.py index 17758f8a2360..ab52de72d391 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -180,18 +180,15 @@ class SoundboardSound(BaseSoundboardSound): The emoji of the sound. ``None`` if no emoji is set. guild: :class:`Guild` The guild in which the sound is uploaded. - guild_id: :class:`int` - The ID of the guild in which the sound is uploaded. available: :class:`bool` Whether this sound is available for use. """ - __slots__ = ('_state', 'guild_id', 'name', 'emoji', '_user', 'available', '_user_id', 'guild') + __slots__ = ('_state', 'name', 'emoji', '_user', 'available', '_user_id', 'guild') def __init__(self, *, guild: Guild, state: ConnectionState, data: SoundboardSoundPayload): super().__init__(state=state, data=data) self.guild = guild - self.guild_id: int = guild.id self._user_id = utils._get_as_snowflake(data, 'user_id') self._user = data.get('user') @@ -298,7 +295,7 @@ async def edit( else: payload['emoji_id'] = partial_emoji.id - data = await self._state.http.edit_soundboard_sound(self.guild_id, self.id, reason=reason, **payload) + data = await self._state.http.edit_soundboard_sound(self.guild.id, self.id, reason=reason, **payload) return SoundboardSound(guild=self.guild, state=self._state, data=data) async def delete(self, *, reason: Optional[str] = None) -> None: @@ -320,4 +317,4 @@ async def delete(self, *, reason: Optional[str] = None) -> None: HTTPException Deleting the soundboard sound failed. """ - await self._state.http.delete_soundboard_sound(self.guild_id, self.id, reason=reason) + await self._state.http.delete_soundboard_sound(self.guild.id, self.id, reason=reason) From 004bbdac7652d3cbcb7c3ccfba1d67e72ded6aac Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Thu, 15 Aug 2024 13:24:17 +0200 Subject: [PATCH 22/30] Add VoiceChannel.send_sound --- discord/channel.py | 32 +++++++++++++++++++++++++++++++- discord/http.py | 8 ++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/discord/channel.py b/discord/channel.py index 1ceef9eb8654..37f0ab6fa718 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -68,7 +68,7 @@ from .flags import ChannelFlags from .http import handle_message_parameters from .object import Object -from .soundboard import BaseSoundboardSound +from .soundboard import BaseSoundboardSound, SoundboardDefaultSound __all__ = ( 'TextChannel', @@ -116,6 +116,7 @@ ) from .types.snowflake import SnowflakeList from .types.soundboard import BaseSoundboardSound as BaseSoundboardSoundPayload + from .soundboard import SoundboardSound OverwriteKeyT = TypeVar('OverwriteKeyT', Role, BaseUser, Object, Union[Role, Member, Object]) @@ -1585,6 +1586,35 @@ async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optiona # the payload will always be the proper channel payload return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore + async def send_sound(self, sound: Union[SoundboardSound, SoundboardDefaultSound], /) -> None: + """|coro| + + Sends a soundboard sound for this channel. + + You must have :attr:`~Permissions.speak` and :attr:`~Permissions.use_soundboard` to do this. + Additionally, you must have :attr:`~Permissions.use_external_sounds` if the sound is from + a different guild. + + .. versionadded:: 2.5 + + Parameters + ----------- + sound: Union[:class:`SoundboardSound`, :class:`SoundboardDefaultSound`] + The sound to send for this channel. + + Raises + ------- + Forbidden + You do not have permissions to send a sound for this channel. + HTTPException + Sending the sound failed. + """ + payload = {'sound_id': sound.id} + if not isinstance(sound, SoundboardDefaultSound) and self.guild.id != sound.guild.id: + payload['source_guild_id'] = sound.guild.id + + await self._state.http.send_soundboard_sound(self.id, **payload) + class StageChannel(VocalGuildChannel): """Represents a Discord guild stage channel. diff --git a/discord/http.py b/discord/http.py index 6c89ed72a8cc..57006b83ac2b 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2568,6 +2568,14 @@ def delete_soundboard_sound(self, guild_id: Snowflake, sound_id: Snowflake, *, r reason=reason, ) + def send_soundboard_sound(self, channel_id: Snowflake, **payload: Any) -> Response[None]: + valid_keys = ('sound_id', 'source_guild_id') + payload = {k: v for k, v in payload.items() if k in valid_keys} + print(payload) + return self.request( + (Route('POST', '/channels/{channel_id}/send-soundboard-sound', channel_id=channel_id)), json=payload + ) + # Misc def application_info(self) -> Response[appinfo.AppInfo]: From cca10131c46baf5e816c809ed6368c1383d6af1d Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Thu, 15 Aug 2024 13:42:08 +0200 Subject: [PATCH 23/30] [docs] Add notes to fetch_soundboard_sound(s) --- discord/guild.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index 8aa05999fe2d..9be3dcb6cd9d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4476,6 +4476,15 @@ async def fetch_soundboard_sound(self, sound_id: int, /) -> SoundboardSound: .. versionadded:: 2.5 + .. note:: + + Using this, in order to receive :attr:`SoundboardSound.user`, you must have :attr:`~Permissions.create_expressions` + or :attr:`~Permissions.manage_expressions`. + + .. note:: + + This method is an API call. For general usage, consider :attr:`get_soundboard_sound` instead. + Raises ------- NotFound @@ -4498,6 +4507,15 @@ async def fetch_soundboard_sounds(self) -> List[SoundboardSound]: .. versionadded:: 2.5 + .. note:: + + Using this, in order to receive :attr:`SoundboardSound.user`, you must have :attr:`~Permissions.create_expressions` + or :attr:`~Permissions.manage_expressions`. + + .. note:: + + This method is an API call. For general usage, consider :attr:`soundboard_sounds` instead. + Raises ------- HTTPException From fb3552dd131d6c4e116d68d4da90bf7c9452a6b5 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Thu, 15 Aug 2024 13:44:57 +0200 Subject: [PATCH 24/30] [docs] Update perms and file formats for create sound --- discord/guild.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 9be3dcb6cd9d..29a196c44e45 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4541,7 +4541,7 @@ async def create_soundboard_sound( """|coro| Creates a :class:`SoundboardSound` for the guild. - You must have :attr:`Permissions.manage_expressions` to do this. + You must have :attr:`Permissions.create_expressions` to do this. .. versionadded:: 2.5 @@ -4551,7 +4551,7 @@ async def create_soundboard_sound( The name of the sound. Must be between 2 and 32 characters. sound: :class:`bytes` The :term:`py:bytes-like object` representing the sound data. - Only MP3 sound files that don't exceed the duration of 5.2s are supported. + Only MP3 and OGG sound files that don't exceed the duration of 5.2s are supported. volume: :class:`float` The volume of the sound. Must be between 0 and 1. Defaults to ``1``. emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]] From 5d701437044ec57ec6fb32660278b35f00fb22ec Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Thu, 15 Aug 2024 17:12:37 +0200 Subject: [PATCH 25/30] Make edit parameters keyword-only --- discord/soundboard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/soundboard.py b/discord/soundboard.py index ab52de72d391..9e1d6c8c2080 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -234,6 +234,7 @@ def user(self) -> Optional[User]: async def edit( self, + *, name: str = MISSING, volume: Optional[float] = MISSING, emoji: Optional[EmojiInputType] = MISSING, From 5136d092cdfa5f8408722e0b02d22630a930e274 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Thu, 15 Aug 2024 17:14:23 +0200 Subject: [PATCH 26/30] [docs] Update required perms for edit and delete --- discord/soundboard.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/discord/soundboard.py b/discord/soundboard.py index 9e1d6c8c2080..3351aacb78ff 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -245,6 +245,8 @@ async def edit( Edits the soundboard sound. You must have :attr:`~Permissions.manage_expressions` to edit the sound. + If the sound was created by the client, you must have either :attr:`~Permissions.manage_expressions` + or :attr:`~Permissions.create_expressions`. Parameters ---------- @@ -305,6 +307,8 @@ async def delete(self, *, reason: Optional[str] = None) -> None: Deletes the soundboard sound. You must have :attr:`~Permissions.manage_expressions` to delete the sound. + If the sound was created by the client, you must have either :attr:`~Permissions.manage_expressions` + or :attr:`~Permissions.create_expressions`. Parameters ----------- From d68216d92e73625c7ba94d33ff5e9ace8573d008 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Thu, 15 Aug 2024 21:43:50 +0200 Subject: [PATCH 27/30] Update events includes new event GUILD_SOUNDBOARD_SOUNDS_UPDATE --- discord/state.py | 35 ++++++++++++++++++++++++++++------- discord/types/gateway.py | 3 ++- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/discord/state.py b/discord/state.py index d32b9fe5c7e5..9dfeda0e9128 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1654,30 +1654,51 @@ def parse_guild_soundboard_sound_create(self, data: gw.GuildSoundBoardSoundCreat else: _log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing unknown guild ID: %s. Discarding.', guild_id) - def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None: + def _update_and_dispatch_sound_update(self, sound: SoundboardSound, data: gw.GuildSoundBoardSoundUpdateEvent): + old_sound = copy.copy(sound) + sound._update(data) + self.dispatch('soundboard_sound_update', old_sound, sound) + + def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundUpdateEvent) -> None: guild_id = int(data['guild_id']) # type: ignore # can't be None here guild = self._get_guild(guild_id) if guild is not None: sound_id = int(data['sound_id']) sound = guild.get_soundboard_sound(sound_id) if sound is not None: - old_sound = copy.copy(sound) - sound._update(data) - self.dispatch('soundboard_sound_update', old_sound, sound) + self._update_and_dispatch_sound_update(sound, data) else: _log.warning('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown sound ID: %s. Discarding.', sound_id) else: _log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown guild ID: %s. Discarding.', guild_id) def parse_guild_soundboard_sound_delete(self, data: gw.GuildSoundBoardSoundDeleteEvent) -> None: - guild = self._get_guild(int(data['guild_id'])) + guild_id = int(data['guild_id']) + guild = self._get_guild(guild_id) if guild is not None: - sound = guild.get_soundboard_sound(int(data['sound_id'])) + sound_id = int(data['sound_id']) + sound = guild.get_soundboard_sound(sound_id) if sound is not None: guild._remove_soundboard_sound(sound) self.dispatch('soundboard_sound_delete', sound) + else: + _log.warning('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown sound ID: %s. Discarding.', sound_id) else: - _log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + _log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown guild ID: %s. Discarding.', guild_id) + + def parse_guild_soundboard_sounds_update(self, data: gw.GuildSoundBoardSoundsUpdateEvent) -> None: + for raw_sound in data: + guild_id = int(raw_sound['guild_id']) # type: ignore # can't be None here + guild = self._get_guild(guild_id) + if guild is not None: + sound_id = int(raw_sound['sound_id']) + sound = guild.get_soundboard_sound(sound_id) + if sound is not None: + self._update_and_dispatch_sound_update(sound, raw_sound) + else: + _log.warning('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing unknown sound ID: %s. Discarding.', sound_id) + else: + _log.debug('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing unknown guild ID: %s. Discarding.', guild_id) def parse_soundboard_sounds(self, data: gw.SoundboardSoundsRequestEvent) -> None: guild_id = int(data['guild_id']) diff --git a/discord/types/gateway.py b/discord/types/gateway.py index fd9fcda7b00b..facb696ac0b7 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -322,7 +322,8 @@ class _GuildScheduledEventUsersEvent(TypedDict): VoiceStateUpdateEvent = GuildVoiceState VoiceChannelEffectSendEvent = VoiceChannelEffect -GuildSoundBoardSoundCreateEvent = SoundboardSound +GuildSoundBoardSoundCreateEvent = GuildSoundBoardSoundUpdateEvent = SoundboardSound +GuildSoundBoardSoundsUpdateEvent = List[SoundboardSound] class GuildSoundBoardSoundDeleteEvent(TypedDict): From 62c8db67002ca13f7cdea46c22a88ab3ad52a31b Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Thu, 15 Aug 2024 21:44:48 +0200 Subject: [PATCH 28/30] Update intent flags to expressions --- discord/flags.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/discord/flags.py b/discord/flags.py index 3d31e3a58a0c..66989fe467ae 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -871,34 +871,52 @@ def bans(self): @alias_flag_value def emojis(self): - """:class:`bool`: Alias of :attr:`.emojis_and_stickers`. + """:class:`bool`: Alias of :attr:`.expressions`. .. versionchanged:: 2.0 Changed to an alias. """ return 1 << 3 - @flag_value + @alias_flag_value def emojis_and_stickers(self): - """:class:`bool`: Whether guild emoji and sticker related events are enabled. + """:class:`bool`: Alias of :attr:`.expressions`. .. versionadded:: 2.0 + .. versionchanged:: 2.5 + Changed to an alias. + """ + return 1 << 3 + + @flag_value + def expressions(self): + """:class:`bool`: Whether guild emoji, sticker, and soundboard sound related events are enabled. + + .. versionadded:: 2.5 + This corresponds to the following events: - :func:`on_guild_emojis_update` - :func:`on_guild_stickers_update` + - :func:`on_soundboard_sound_create` + - :func:`on_soundboard_sound_update` + - :func:`on_soundboard_sound_delete` This also corresponds to the following attributes and classes in terms of cache: - :class:`Emoji` - :class:`GuildSticker` + - :class:`SoundboardSound` - :meth:`Client.get_emoji` - :meth:`Client.get_sticker` + - :meth:`Client.get_soundboard_sound` - :meth:`Client.emojis` - :meth:`Client.stickers` + - :meth:`Client.soundboard_sounds` - :attr:`Guild.emojis` - :attr:`Guild.stickers` + - :attr:`Guild.soundboard_sounds` """ return 1 << 3 From 39a1ac302536fc8f4301f7df5dca4ae3ed0770aa Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Sun, 15 Sep 2024 20:47:32 +0200 Subject: [PATCH 29/30] Remove requesting sounds via gateway --- discord/gateway.py | 14 ------- discord/guild.py | 27 ------------- discord/state.py | 83 ---------------------------------------- discord/types/gateway.py | 5 --- 4 files changed, 129 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index e719c9edb3a5..e6fb7d8bfac5 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -276,8 +276,6 @@ class DiscordWebSocket: a connection issue. GUILD_SYNC Send only. Requests a guild sync. - REQUEST_SOUNDBOARD_SOUNDS - Send only. Requests the soundboard sounds for a list of guilds. gateway The gateway we are currently connected to. token @@ -310,7 +308,6 @@ class DiscordWebSocket: HELLO = 10 HEARTBEAT_ACK = 11 GUILD_SYNC = 12 - REQUEST_SOUNDBOARD_SOUNDS = 31 # fmt: on def __init__(self, socket: aiohttp.ClientWebSocketResponse, *, loop: asyncio.AbstractEventLoop) -> None: @@ -755,17 +752,6 @@ async def voice_state( _log.debug('Updating our voice state to %s.', payload) await self.send_as_json(payload) - async def request_soundboard_sounds(self, guild_ids: List[int]) -> None: - payload = { - 'op': self.REQUEST_SOUNDBOARD_SOUNDS, - 'd': { - 'guild_ids': guild_ids, - }, - } - - _log.debug('Sending "%s" to request soundboard sounds', payload) - await self.send_as_json(payload) - async def close(self, code: int = 4000) -> None: if self._keep_alive: self._keep_alive.stop() diff --git a/discord/guild.py b/discord/guild.py index 29a196c44e45..7efac795dbcb 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4595,30 +4595,3 @@ async def create_soundboard_sound( data = await self._state.http.create_soundboard_sound(self.id, reason=reason, **payload) return SoundboardSound(guild=self, state=self._state, data=data) - - async def request_soundboard_sounds(self, *, cache: bool = True) -> List[SoundboardSound]: - """|coro| - - Requests the soundboard sounds of the guild. - - This is a websocket operation and can be slow. - - .. versionadded:: 2.5 - - Parameters - ---------- - cache: :class:`bool` - Whether to cache the soundboard sounds internally. Defaults to ``True``. - - Raises - ------- - asyncio.TimeoutError - The query timed out waiting for the sounds. - - Returns - -------- - List[:class:`SoundboardSound`] - A list of guilds with it's requested soundboard sounds. - """ - - return await self._state.request_soundboard_sounds(self, cache=cache) diff --git a/discord/state.py b/discord/state.py index 9dfeda0e9128..0176a0082574 100644 --- a/discord/state.py +++ b/discord/state.py @@ -158,53 +158,6 @@ def done(self) -> None: future.set_result(self.buffer) -class SoundboardSoundRequest: - def __init__( - self, - guild_id: int, - loop: asyncio.AbstractEventLoop, - resolver: Callable[[int], Any], - *, - cache: bool = True, - ) -> None: - self.guild_id: int = guild_id - self.loop: asyncio.AbstractEventLoop = loop - self.resolver: Callable[[int], Any] = resolver - self.cache: bool = cache - self.buffer: List[SoundboardSound] = [] - self.waiters: List[asyncio.Future[List[SoundboardSound]]] = [] - - def add_soundboard_sounds(self, sounds: List[SoundboardSound]) -> None: - self.buffer.extend(sounds) - if self.cache: - guild = self.resolver(self.guild_id) - if guild is None: - return - - for sound in sounds: - existing = guild.get_soundboard_sound(sound.id) - if existing is None: - guild._add_soundboard_sound(sound) - - async def wait(self) -> List[SoundboardSound]: - future = self.loop.create_future() - self.waiters.append(future) - try: - return await future - finally: - self.waiters.remove(future) - - def get_future(self) -> asyncio.Future[List[SoundboardSound]]: - future = self.loop.create_future() - self.waiters.append(future) - return future - - def done(self) -> None: - for future in self.waiters: - if not future.done(): - future.set_result(self.buffer) - - _log = logging.getLogger(__name__) @@ -256,7 +209,6 @@ def __init__( self.allowed_mentions: Optional[AllowedMentions] = allowed_mentions self._chunk_requests: Dict[Union[int, str], ChunkRequest] = {} - self._soundboard_sounds_requests: Dict[int, SoundboardSoundRequest] = {} activity = options.get('activity', None) if activity: @@ -367,16 +319,6 @@ def process_chunk_requests(self, guild_id: int, nonce: Optional[str], members: L for key in removed: del self._chunk_requests[key] - def process_soundboard_sounds_request(self, guild_id: int, sounds: List[SoundboardSound]) -> None: - request = self._soundboard_sounds_requests.get(guild_id) - if request is None: - return - - request.add_soundboard_sounds(sounds) - request.done() - - del self._soundboard_sounds_requests[guild_id] - def clear_chunk_requests(self, shard_id: int | None) -> None: removed = [] for key, request in self._chunk_requests.items(): @@ -641,22 +583,6 @@ async def query_members( _log.warning('Timed out waiting for chunks with query %r and limit %d for guild_id %d', query, limit, guild_id) raise - async def request_soundboard_sounds(self, guild: Guild, cache: bool) -> List[SoundboardSound]: - guild_id = guild.id - ws = self._get_websocket(guild_id) - if ws is None: - raise RuntimeError('Somehow do not have a websocket for this guild_id') - - request = SoundboardSoundRequest(guild_id, self.loop, self._get_guild, cache=cache) - self._soundboard_sounds_requests[request.guild_id] = request - - try: - await ws.request_soundboard_sounds(guild_ids=[guild_id]) - return await asyncio.wait_for(request.wait(), timeout=30) - except asyncio.TimeoutError: - _log.warning('Timed out waiting for soundboard sounds request') - raise - async def _delay_ready(self) -> None: try: states = [] @@ -1700,15 +1626,6 @@ def parse_guild_soundboard_sounds_update(self, data: gw.GuildSoundBoardSoundsUpd else: _log.debug('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing unknown guild ID: %s. Discarding.', guild_id) - def parse_soundboard_sounds(self, data: gw.SoundboardSoundsRequestEvent) -> None: - guild_id = int(data['guild_id']) - guild = self._get_guild(guild_id) - if guild is not None: - sounds = [SoundboardSound(guild=guild, state=self, data=sound) for sound in data['soundboard_sounds']] - self.process_soundboard_sounds_request(guild_id, sounds) - else: - _log.debug('SOUNDBOARD_SOUNDS referencing unknown guild ID: %s. Discarding.', guild_id) - def parse_application_command_permissions_update(self, data: GuildApplicationCommandPermissionsPayload): raw = RawAppCommandPermissionsUpdateEvent(data=data, state=self) self.dispatch('raw_app_command_permissions_update', raw) diff --git a/discord/types/gateway.py b/discord/types/gateway.py index facb696ac0b7..974ceb20460b 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -372,8 +372,3 @@ class PollVoteActionEvent(TypedDict): message_id: Snowflake guild_id: NotRequired[Snowflake] answer_id: int - - -class SoundboardSoundsRequestEvent(TypedDict): - guild_id: Snowflake - soundboard_sounds: List[SoundboardSound] From 2b609746bbf7f32c4aeae6fd8edeb5e056a409b2 Mon Sep 17 00:00:00 2001 From: Andrin <65789180+Puncher1@users.noreply.github.com> Date: Tue, 17 Sep 2024 11:27:23 +0200 Subject: [PATCH 30/30] Add MORE_SOUNDBOARD guild feature --- discord/types/guild.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/types/guild.py b/discord/types/guild.py index 8b72dccbdf0c..e0a1f3e54438 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -92,6 +92,7 @@ class IncidentData(TypedDict): 'WELCOME_SCREEN_ENABLED', 'RAID_ALERTS_DISABLED', 'SOUNDBOARD', + 'MORE_SOUNDBOARD', ]