Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: soundboard #1068

Open
wants to merge 45 commits into
base: feature/voice-channel-effect-send
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
00d2304
feat: add `SOUNDBOARD` guild feature
shiftinv Jul 1, 2023
4d07036
feat: add base `SoundboardSound` model
shiftinv Jul 1, 2023
f8e5dd5
feat: split `SoundboardSound` into base model
shiftinv Jul 1, 2023
e3c380f
feat: add `VoiceChannelEffect.sound`
shiftinv Jul 1, 2023
f1b2fcd
feat: add `Client.fetch_default_soundboard_sounds`
shiftinv Jul 1, 2023
cc1df4f
feat: add sound create/edit/delete
shiftinv Jul 2, 2023
1c787f8
feat: implement requesting sounds over gateway
shiftinv Jul 2, 2023
1bb3221
feat: implement create/update/delete gateway events
shiftinv Jul 2, 2023
8a23547
feat: add `Guild.sound_limit`
shiftinv Jul 2, 2023
3ccf72c
fix: add import in main module
shiftinv Jul 2, 2023
13e2891
docs: all the docs yay
shiftinv Jul 2, 2023
422f28e
feat: add event enum members
shiftinv Jul 2, 2023
6020056
feat: add `SoundboardSound.user_id`, fall back to user cache
shiftinv Jul 2, 2023
72b090f
test: add test for mime_type parameter
shiftinv Jul 2, 2023
4edf33d
refactor: split into `GuildSoundboardSound` to avoid `guild_id` nulla…
shiftinv Jul 3, 2023
d7c6340
docs: fix raw_delete event parameter name
shiftinv Jul 3, 2023
806df15
docs: add changelog entry
shiftinv Jul 3, 2023
0c597e5
feat: support `MORE_SOUNDBOARD` guild feature
shiftinv Jun 7, 2024
d7e8e3b
fix: remove `override_path`
shiftinv Aug 9, 2024
dc03160
fix: (re)move some fields
shiftinv Aug 9, 2024
dcaf8f4
fix: remove edit workaround, appears to no longer be necessary
shiftinv Aug 9, 2024
d371b6b
feat: implement audit log stuff
shiftinv Aug 9, 2024
5d3751e
docs: mention permission requirement for `user` attr
shiftinv Aug 9, 2024
41806b1
refactor: remove `REQUEST_SOUNDBOARD` gateway stuff
shiftinv Aug 9, 2024
d100d87
feat: add `soundboard_sounds` caching from guild_create
shiftinv Aug 9, 2024
b4da2d7
fix: remove `soundboard_sound` audit log target type
shiftinv Aug 9, 2024
f43b837
docs: update changelog
shiftinv Aug 9, 2024
ef06439
docs: use new collapse element
shiftinv Aug 19, 2024
4394ad6
chore: rename http method to match docs change
shiftinv Aug 19, 2024
259f284
feat: implement sending sounds
shiftinv Aug 19, 2024
bba6852
fix: `Client.get_soundboard_sound` typo'd
shiftinv Aug 19, 2024
f907dac
feat: add `Guild.fetch_soundboard_sound[s]`
shiftinv Aug 19, 2024
440048b
fix(docs): update required permissions
shiftinv Aug 19, 2024
3e21298
feat: remove raw events, now that sounds can be cached
shiftinv Aug 19, 2024
67c77e0
chore: changelog nits
shiftinv Aug 19, 2024
505b15b
feat(intents): add new `guild_expressions` intent
shiftinv Aug 19, 2024
a31f391
docs: add changelog for intent
shiftinv Aug 19, 2024
c6887ef
fix: update sound cache on create/delete
shiftinv Aug 19, 2024
1b6f9a9
chore: internal variable name fixups
shiftinv Aug 19, 2024
485b3ef
refactor: add `guild_soundboard_sounds_update` event, turn other even…
shiftinv Aug 19, 2024
c472d3b
feat: use soundboard cache for voice channel effects
shiftinv Aug 19, 2024
d365768
fix: remove override_path
shiftinv Aug 19, 2024
3d4b02e
fix(docs): update event name
shiftinv Aug 19, 2024
f570110
refactor: rename intent to `expressions`
shiftinv Aug 19, 2024
79b7a8b
feat: add `GuildSoundboardSound` converter
shiftinv Aug 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog/1068.feature.0.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Implement soundboard features.
- Sound models: :class:`PartialSoundboardSound`, :class:`SoundboardSound`, :class:`GuildSoundboardSound`
- Managing sounds:
- Get soundboard sounds using :attr:`Guild.soundboard_sounds`, :attr:`Client.get_soundboard_sound`, or fetch them using :meth:`Guild.fetch_soundboard_sound`, :meth:`Guild.fetch_soundboard_sounds`, or :meth:`Client.fetch_default_soundboard_sounds`
- New sounds can be created with :meth:`Guild.create_soundboard_sound`
- Handle guild soundboard sound updates using the :attr:`~Event.guild_soundboard_sounds_update` event
- Send sounds using :meth:`VoiceChannel.send_soundboard_sound`
- New attributes: :attr:`Guild.soundboard_limit`, :attr:`VoiceChannelEffect.sound`, :attr:`Client.soundboard_sounds`
- New audit log actions: :attr:`AuditLogAction.soundboard_sound_create`, :attr:`~AuditLogAction.soundboard_sound_update`, :attr:`~AuditLogAction.soundboard_sound_delete`
1 change: 1 addition & 0 deletions changelog/1068.feature.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Rename :attr:`Intents.emojis_and_stickers` to :attr:`Intents.expressions`. An alias is provided for backwards compatibility.
1 change: 1 addition & 0 deletions disnake/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
from .role import *
from .shard import *
from .sku import *
from .soundboard import *
from .stage_instance import *
from .sticker import *
from .team import *
Expand Down
20 changes: 20 additions & 0 deletions disnake/audit_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,13 +365,16 @@ class AuditLogChanges:
"available_tags": (None, _list_transformer(_transform_tag)),
"default_reaction_emoji": ("default_reaction", _transform_default_reaction),
"default_sort_order": (None, _enum_transformer(enums.ThreadSortOrder)),
"sound_id": ("id", _transform_snowflake),
}
# fmt: on

def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload]) -> None:
self.before = AuditLogDiff()
self.after = AuditLogDiff()

has_emoji_fields = False

for elem in data:
attr = elem["key"]

Expand All @@ -390,6 +393,10 @@ def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload]) -> N
)
continue

# special case for flat emoji fields (discord, why), these will be merged later
if attr == "emoji_id" or attr == "emoji_name":
has_emoji_fields = True

transformer: Optional[Transformer]

try:
Expand Down Expand Up @@ -420,6 +427,9 @@ def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload]) -> N

setattr(self.after, attr, after)

if has_emoji_fields:
self._merge_emoji(entry)

# add an alias
if hasattr(self.after, "colour"):
self.after.color = self.after.colour
Expand Down Expand Up @@ -478,6 +488,16 @@ def _handle_command_permissions(
data=new, guild_id=guild_id
)

def _merge_emoji(self, entry: AuditLogEntry) -> None:
for diff in (self.before, self.after):
emoji_id: Optional[str] = diff.__dict__.pop("emoji_id", None)
emoji_name: Optional[str] = diff.__dict__.pop("emoji_name", None)

diff.emoji = entry._state._get_emoji_from_fields(
name=emoji_name,
id=int(emoji_id) if emoji_id else None,
)


class _AuditLogProxyMemberPrune:
delete_member_days: int
Expand Down
57 changes: 53 additions & 4 deletions disnake/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from .mixins import Hashable
from .partial_emoji import PartialEmoji
from .permissions import PermissionOverwrite, Permissions
from .soundboard import GuildSoundboardSound, PartialSoundboardSound, SoundboardSound
from .stage_instance import StageInstance
from .threads import ForumTag, Thread
from .utils import MISSING
Expand Down Expand Up @@ -91,6 +92,7 @@
VoiceChannel as VoiceChannelPayload,
)
from .types.snowflake import SnowflakeList
from .types.soundboard import PartialSoundboardSound as PartialSoundboardSoundPayload
from .types.threads import ThreadArchiveDurationLiteral
from .types.voice import VoiceChannelEffect as VoiceChannelEffectPayload
from .ui.action_row import Components, MessageUIComponent
Expand All @@ -110,17 +112,22 @@ class VoiceChannelEffect:
Attributes
----------
emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`]]
The emoji, for emoji reaction effects.
The emoji, for emoji reaction effects and soundboard effects.
animation_type: Optional[:class:`VoiceChannelEffectAnimationType`]
The emoji animation type, for emoji reaction effects.
The emoji animation type, for emoji reaction and soundboard effects.
animation_id: Optional[:class:`int`]
The emoji animation ID, for emoji reaction effects.
The emoji animation ID, for emoji reaction and soundboard effects.
sound: Optional[Union[:class:`GuildSoundboardSound`, :class:`PartialSoundboardSound`]]
The sound data, for soundboard effects.
This will be a :class:`PartialSoundboardSound` if it's a default sound
or from an external guild.
"""

__slots__ = (
"emoji",
"animation_type",
"animation_id",
"sound",
)

def __init__(self, *, data: VoiceChannelEffectPayload, state: ConnectionState) -> None:
Expand All @@ -138,10 +145,21 @@ def __init__(self, *, data: VoiceChannelEffectPayload, state: ConnectionState) -
)
self.animation_id: Optional[int] = utils._get_as_snowflake(data, "animation_id")

self.sound: Optional[Union[GuildSoundboardSound, PartialSoundboardSound]] = None
if sound_id := utils._get_as_snowflake(data, "sound_id"):
if sound := state.get_soundboard_sound(sound_id):
self.sound = sound
else:
sound_data: PartialSoundboardSoundPayload = {
"sound_id": sound_id,
"volume": data.get("sound_volume"), # type: ignore # assume this exists if sound_id is set
}
self.sound = PartialSoundboardSound(data=sound_data, state=state)

def __repr__(self) -> str:
return (
f"<VoiceChannelEffect emoji={self.emoji!r} animation_type={self.animation_type!r}"
f" animation_id={self.animation_id!r}>"
f" animation_id={self.animation_id!r} sound={self.sound!r}>"
)


Expand Down Expand Up @@ -1916,6 +1934,37 @@ async def create_webhook(
)
return Webhook.from_state(data, state=self._state)

async def send_soundboard_sound(self, sound: SoundboardSound, /) -> None:
"""|coro|

Sends a soundboard sound in this channel.

You must have :attr:`~Permissions.speak` and :attr:`~Permissions.use_soundboard`
permissions to do this. For sounds from different guilds, you must also have
:attr:`~Permissions.use_external_sounds` permission.
Additionally, you may not be muted or deafened.

Parameters
----------
sound: Union[:class:`SoundboardSound`, :class:`GuildSoundboardSound`]
The sound to send in the channel.

Raises
------
Forbidden
You are not allowed to send soundboard sounds.
HTTPException
An error occurred sending the soundboard sound.
"""
if isinstance(sound, GuildSoundboardSound):
source_guild_id = sound.guild_id
else:
source_guild_id = None

await self._state.http.send_soundboard_sound(
self.id, sound.id, source_guild_id=source_guild_id
)


class StageChannel(disnake.abc.Messageable, VocalGuildChannel):
"""Represents a Discord guild stage channel.
Expand Down
47 changes: 46 additions & 1 deletion disnake/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
from .mentions import AllowedMentions
from .object import Object
from .sku import SKU
from .soundboard import GuildSoundboardSound, SoundboardSound
from .stage_instance import StageInstance
from .state import ConnectionState
from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory
Expand Down Expand Up @@ -571,6 +572,14 @@ def stickers(self) -> List[GuildSticker]:
"""
return self._connection.stickers

@property
def soundboard_sounds(self) -> List[GuildSoundboardSound]:
"""List[:class:`.GuildSoundboardSound`]: The soundboard sounds that the connected client has.

.. versionadded:: 2.10
"""
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.
Expand Down Expand Up @@ -1496,7 +1505,7 @@ def get_sticker(self, id: int, /) -> Optional[GuildSticker]:

.. note::

To retrieve standard stickers, use :meth:`.fetch_sticker`.
To retrieve standard stickers, use :meth:`.fetch_sticker`
or :meth:`.fetch_sticker_packs`.

Returns
Expand All @@ -1506,6 +1515,22 @@ def get_sticker(self, id: int, /) -> Optional[GuildSticker]:
"""
return self._connection.get_sticker(id)

def get_soundboard_sound(self, id: int, /) -> Optional[GuildSoundboardSound]:
"""Returns a guild soundboard sound with the given ID.

.. versionadded:: 2.10

.. note::

To retrieve standard soundboard sounds, use :meth:`.fetch_default_soundboard_sounds`.

Returns
-------
Optional[:class:`.GuildSoundboardSound`]
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'.

Expand Down Expand Up @@ -2352,6 +2377,26 @@ async def fetch_widget(self, guild_id: int, /) -> Widget:
data = await self.http.get_widget(guild_id)
return Widget(state=self._connection, data=data)

async def fetch_default_soundboard_sounds(self) -> List[SoundboardSound]:
"""|coro|

Retrieves the list of default :class:`.SoundboardSound`\\s provided by Discord.

.. versionadded:: 2.10

Raises
------
HTTPException
Retrieving the soundboard sounds failed.

Returns
-------
List[:class:`.SoundboardSound`]
The default soundboard sounds.
"""
data = await self.http.get_default_soundboard_sounds()
return [SoundboardSound(data=d, state=self._connection) for d in data]

async def application_info(self) -> AppInfo:
"""|coro|

Expand Down
30 changes: 26 additions & 4 deletions disnake/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,9 @@ class AuditLogAction(Enum):
thread_update = 111
thread_delete = 112
application_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
Expand Down Expand Up @@ -461,6 +464,9 @@ def category(self) -> Optional[AuditLogActionCategory]:
AuditLogAction.guild_scheduled_event_update: AuditLogActionCategory.update,
AuditLogAction.guild_scheduled_event_delete: AuditLogActionCategory.delete,
AuditLogAction.application_command_permission_update: AuditLogActionCategory.update,
AuditLogAction.soundboard_sound_create: AuditLogActionCategory.create,
AuditLogAction.soundboard_sound_update: AuditLogActionCategory.update,
AuditLogAction.soundboard_sound_delete: AuditLogActionCategory.delete,
AuditLogAction.automod_rule_create: AuditLogActionCategory.create,
AuditLogAction.automod_rule_update: AuditLogActionCategory.update,
AuditLogAction.automod_rule_delete: AuditLogActionCategory.delete,
Expand Down Expand Up @@ -1052,6 +1058,12 @@ class Event(Enum):
"""Called when a `Guild` updates its stickers.
Represents the :func:`on_guild_stickers_update` event.
"""
guild_soundboard_sounds_update = "guild_soundboard_sounds_update"
"""Called when a `Guild` updates its soundboard sounds.
Represents the :func:`on_guild_soundboard_sounds_update` event.

.. versionadded:: 2.10
"""
guild_integrations_update = "guild_integrations_update"
"""Called whenever an integration is created, modified, or removed from a guild.
Represents the :func:`on_guild_integrations_update` event.
Expand Down Expand Up @@ -1259,7 +1271,8 @@ class Event(Enum):
"""
raw_presence_update = "raw_presence_update"
"""Called when a user's presence changes regardless of the state of the internal member cache.
Represents the :func:`on_raw_presence_update` event."""
Represents the :func:`on_raw_presence_update` event.
"""
raw_reaction_add = "raw_reaction_add"
"""Called when a message has a reaction added regardless of the state of the internal message cache.
Represents the :func:`on_raw_reaction_add` event.
Expand All @@ -1286,13 +1299,22 @@ class Event(Enum):
"""
entitlement_create = "entitlement_create"
"""Called when a user subscribes to an SKU, creating a new :class:`Entitlement`.
Represents the :func:`on_entitlement_create` event."""
Represents the :func:`on_entitlement_create` event.

.. versionadded:: 2.10
"""
entitlement_update = "entitlement_update"
"""Called when a user's subscription renews.
Represents the :func:`on_entitlement_update` event."""
Represents the :func:`on_entitlement_update` event.

.. versionadded:: 2.10
"""
entitlement_delete = "entitlement_delete"
"""Called when a user's entitlement is deleted.
Represents the :func:`on_entitlement_delete` event."""
Represents the :func:`on_entitlement_delete` event.

.. versionadded:: 2.10
"""
# ext.commands events
command = "command"
"""Called when a command is found and is about to be invoked.
Expand Down
40 changes: 40 additions & 0 deletions disnake/ext/commands/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
EmojiNotFound,
GuildNotFound,
GuildScheduledEventNotFound,
GuildSoundboardSoundNotFound,
GuildStickerNotFound,
MemberNotFound,
MessageNotFound,
Expand Down Expand Up @@ -82,6 +83,7 @@
"EmojiConverter",
"PartialEmojiConverter",
"GuildStickerConverter",
"GuildSoundboardSoundConverter",
"PermissionsConverter",
"GuildScheduledEventConverter",
"clean_content",
Expand Down Expand Up @@ -944,6 +946,43 @@ async def convert(self, ctx: AnyContext, argument: str) -> disnake.GuildSticker:
return result


class GuildSoundboardSoundConverter(IDConverter[disnake.GuildSoundboardSound]):
"""Converts to a :class:`~disnake.GuildSoundboardSound`.

All lookups are done for the local guild first, if available. If that lookup
fails, then it checks the client's global cache.

The lookup strategy is as follows (in order):

1. Lookup by ID
2. Lookup by name

.. versionadded:: 2.10
"""

async def convert(self, ctx: AnyContext, argument: str) -> disnake.GuildSoundboardSound:
match = self._get_id_match(argument)
result = None
bot: disnake.Client = ctx.bot
guild = ctx.guild

if match is None:
# Try to get the sound by name. Try local guild first.
if guild:
result = _utils_get(guild.soundboard_sounds, name=argument)

if result is None:
result = _utils_get(bot.soundboard_sounds, name=argument)
else:
# Try to look up sound by id.
result = bot.get_soundboard_sound(int(match.group(1)))

if result is None:
raise GuildSoundboardSoundNotFound(argument)

return result


class PermissionsConverter(Converter[disnake.Permissions]):
"""Converts to a :class:`~disnake.Permissions`.

Expand Down Expand Up @@ -1212,6 +1251,7 @@ def is_generic_type(tp: Any, *, _GenericAlias: Type = _GenericAlias) -> bool:
disnake.Thread: ThreadConverter,
disnake.abc.GuildChannel: GuildChannelConverter,
disnake.GuildSticker: GuildStickerConverter,
disnake.GuildSoundboardSound: GuildSoundboardSoundConverter,
disnake.Permissions: PermissionsConverter,
disnake.GuildScheduledEvent: GuildScheduledEventConverter,
}
Expand Down
Loading
Loading