diff --git a/lib/src/builders/channel/forum_tag.dart b/lib/src/builders/channel/forum_tag.dart index 3542fca88..a702e4ade 100644 --- a/lib/src/builders/channel/forum_tag.dart +++ b/lib/src/builders/channel/forum_tag.dart @@ -1,6 +1,7 @@ import 'package:nyxx/src/builders/builder.dart'; import 'package:nyxx/src/models/channel/types/forum.dart'; import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/building_helpers.dart'; class ForumTagBuilder extends CreateBuilder { String name; @@ -17,7 +18,6 @@ class ForumTagBuilder extends CreateBuilder { Map build() => { 'name': name, if (isModerated != null) 'moderated': isModerated, - if (emojiId != null) 'emoji_id': emojiId!.toString(), - if (emojiName != null) 'emoji_name': emojiName, + ...makeEmojiMap(emojiId: emojiId, emojiName: emojiName), }; } diff --git a/lib/src/builders/channel/guild_channel.dart b/lib/src/builders/channel/guild_channel.dart index 6016f440c..442f4fd6c 100644 --- a/lib/src/builders/channel/guild_channel.dart +++ b/lib/src/builders/channel/guild_channel.dart @@ -11,6 +11,7 @@ import 'package:nyxx/src/models/channel/types/guild_voice.dart'; import 'package:nyxx/src/models/channel/voice_channel.dart'; import 'package:nyxx/src/models/permission_overwrite.dart'; import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/utils/building_helpers.dart'; import 'package:nyxx/src/utils/flags.dart'; class GuildChannelBuilder extends CreateBuilder { @@ -231,12 +232,7 @@ class ForumChannelBuilder extends GuildChannelBuilder { if (isNsfw != null) 'nsfw': isNsfw, if (defaultAutoArchiveDuration != null) 'default_auto_archive_duration': defaultAutoArchiveDuration!.inMinutes, if (!identical(defaultReaction, sentinelDefaultReaction)) - 'default_reaction_emoji': defaultReaction == null - ? null - : { - if (defaultReaction!.emojiId != null) 'emoji_id': defaultReaction!.emojiId!.toString(), - if (defaultReaction!.emojiName != null) 'emoji_name': defaultReaction!.emojiName, - }, + 'default_reaction_emoji': defaultReaction == null ? null : makeEmojiMap(emojiId: defaultReaction!.emojiId, emojiName: defaultReaction!.emojiName), if (tags != null) 'available_tags': tags!.map((e) => e.build()).toList(), if (defaultSortOrder != null) 'default_sort_order': defaultSortOrder!.value, }; @@ -293,12 +289,7 @@ class ForumChannelUpdateBuilder extends GuildChannelUpdateBuilder if (flags != null) 'flags': flags!.value, if (tags != null) 'available_tags': tags!.map((e) => e.build()).toList(), if (!identical(defaultReaction, sentinelDefaultReaction)) - 'default_reaction_emoji': defaultReaction == null - ? null - : { - if (defaultReaction!.emojiId != null) 'emoji_id': defaultReaction!.emojiId!.toString(), - if (defaultReaction!.emojiName != null) 'emoji_name': defaultReaction!.emojiName, - }, + 'default_reaction_emoji': defaultReaction == null ? null : makeEmojiMap(emojiId: defaultReaction!.emojiId, emojiName: defaultReaction!.emojiName), if (defaultThreadRateLimitPerUser != null) 'default_thread_rate_limit_per_user': defaultThreadRateLimitPerUser!.inSeconds, if (defaultSortOrder != null) 'default_sort_order': defaultSortOrder!.value, if (defaultLayout != null) 'default_forum_layout': defaultLayout!.value, diff --git a/lib/src/builders/guild/welcome_screen.dart b/lib/src/builders/guild/welcome_screen.dart index 8ea232fb0..f852d3feb 100644 --- a/lib/src/builders/guild/welcome_screen.dart +++ b/lib/src/builders/guild/welcome_screen.dart @@ -1,6 +1,7 @@ import 'package:nyxx/src/builders/builder.dart'; import 'package:nyxx/src/builders/sentinels.dart'; import 'package:nyxx/src/models/guild/welcome_screen.dart'; +import 'package:nyxx/src/utils/building_helpers.dart'; class WelcomeScreenUpdateBuilder extends UpdateBuilder { bool? isEnabled; @@ -20,8 +21,7 @@ class WelcomeScreenUpdateBuilder extends UpdateBuilder { { 'channel_id': channel.channelId.toString(), 'description': channel.description, - 'emoji_id': channel.emojiId?.toString(), - 'emoji_name': channel.emojiName, + ...makeEmojiMap(emojiId: channel.emojiId, emojiName: channel.emojiName), }, ], if (!identical(description, sentinelString)) 'description': description, diff --git a/lib/src/builders/image.dart b/lib/src/builders/image.dart index 4759df255..6e414a3e8 100644 --- a/lib/src/builders/image.dart +++ b/lib/src/builders/image.dart @@ -16,7 +16,7 @@ class ImageBuilder { ImageBuilder.gif(this.data) : format = 'gif'; static Future fromFile(File file, {String? format}) async { - format ??= p.extension(file.path); + format ??= p.extension(file.path).substring(1); const formats = { 'png': 'png', diff --git a/lib/src/builders/sentinels.dart b/lib/src/builders/sentinels.dart index c8462e6c6..58e42d5c2 100644 --- a/lib/src/builders/sentinels.dart +++ b/lib/src/builders/sentinels.dart @@ -8,6 +8,8 @@ import 'package:nyxx/src/utils/flags.dart'; // ASCII encoded "nyxx" const sentinelInteger = 0x6E797878; +const sentinelDouble = sentinelInteger * 1.0; + // ESC-"nyxx" const sentinelString = '\u{1B}nyxx'; diff --git a/lib/src/builders/sound.dart b/lib/src/builders/sound.dart new file mode 100644 index 000000000..d3b26b34b --- /dev/null +++ b/lib/src/builders/sound.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:path/path.dart' as p; + +class SoundBuilder { + List data; + String format; + + SoundBuilder({required this.data, required this.format}); + + SoundBuilder.mp3(this.data) : format = 'mpeg'; + + SoundBuilder.ogg(this.data) : format = 'ogg'; + + static Future fromFile(File file, {String? format}) async { + format ??= p.extension(file.path).substring(1); + + const formats = { + 'mp3': 'mpeg', + 'mpeg': 'mpeg', + 'ogg': 'ogg', + }; + + final actualFormat = formats[format]; + + if (actualFormat == null) { + throw ArgumentError.value(format, 'format', 'Unsupported format'); + } + + final data = await file.readAsBytes(); + + return SoundBuilder(data: data, format: actualFormat); + } + + List buildRawData() => data; + + String buildDataString() => 'data:audio/$format;base64,${base64Encode(data)}'; +} diff --git a/lib/src/builders/soundboard.dart b/lib/src/builders/soundboard.dart new file mode 100644 index 000000000..68fce32e2 --- /dev/null +++ b/lib/src/builders/soundboard.dart @@ -0,0 +1,47 @@ +import 'package:nyxx/src/builders/builder.dart'; +import 'package:nyxx/src/builders/sentinels.dart'; +import 'package:nyxx/src/builders/sound.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/soundboard/soundboard.dart'; +import 'package:nyxx/src/utils/building_helpers.dart'; + +class SoundboardSoundBuilder extends CreateBuilder { + String name; + + SoundBuilder sound; + + double? volume; + + String? emojiName; + + Snowflake? emojiId; + + SoundboardSoundBuilder({required this.name, required this.sound, this.volume, this.emojiName, this.emojiId}); + + @override + Map build() => { + 'name': name, + 'sound': sound.buildDataString(), + if (volume != null) 'volume': volume, + ...makeEmojiMap(emojiId: emojiId, emojiName: emojiName), + }; +} + +class SoundboardSoundUpdateBuilder extends UpdateBuilder { + String name; + + double? volume; + + String? emojiName; + + Snowflake? emojiId; + + SoundboardSoundUpdateBuilder({required this.name, this.volume = sentinelDouble, this.emojiName = sentinelString, this.emojiId = sentinelSnowflake}); + + @override + Map build() => { + 'name': name, + if (volume != sentinelDouble) 'volume': volume, + if (!(identical(emojiName, sentinelString) || identical(emojiId, sentinelSnowflake))) ...makeEmojiMap(emojiId: emojiId, emojiName: emojiName), + }; +} diff --git a/lib/src/client_options.dart b/lib/src/client_options.dart index 6c601f9c3..399576c45 100644 --- a/lib/src/client_options.dart +++ b/lib/src/client_options.dart @@ -16,6 +16,7 @@ import 'package:nyxx/src/models/guild/scheduled_event.dart'; import 'package:nyxx/src/models/message/message.dart'; import 'package:nyxx/src/models/role.dart'; import 'package:nyxx/src/models/sku.dart'; +import 'package:nyxx/src/models/soundboard/soundboard.dart'; import 'package:nyxx/src/models/sticker/global_sticker.dart'; import 'package:nyxx/src/models/sticker/guild_sticker.dart'; import 'package:nyxx/src/models/subscription.dart'; @@ -143,9 +144,15 @@ class RestClientOptions extends ClientOptions { /// The [CacheConfig] to use for the [Application.skus] manager. final CacheConfig skuConfig; - /// Tje [CacheConfig] to use for the [Sku.subscriptions] manager. + /// The [CacheConfig] to use for the [Sku.subscriptions] manager. final CacheConfig subscriptionConfig; + /// The [CacheConfig] to use for the [NyxxRest.soundboard] manager. + final CacheConfig globalSoundboardCacheConfig; + + /// The [CacheConfig] to use for the [Guild.soundboard] manager. + final CacheConfig soundboardCacheConfig; + /// Create a new [RestClientOptions]. const RestClientOptions({ super.plugins, @@ -187,6 +194,8 @@ class RestClientOptions extends ClientOptions { this.entitlementConfig = _defaultCacheConfig, this.skuConfig = _defaultCacheConfig, this.subscriptionConfig = _defaultCacheConfig, + this.globalSoundboardCacheConfig = _smallCacheConfig, + this.soundboardCacheConfig = _smallCacheConfig, }); } diff --git a/lib/src/errors.dart b/lib/src/errors.dart index 62d01fb8c..2796b425d 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -157,3 +157,10 @@ class ClientClosedError extends Error { @override String toString() => 'Client is closed'; } + +class SoundboardSoundNotFoundException extends NyxxException { + /// The ID of the sound. + final Snowflake soundId; + + SoundboardSoundNotFoundException(this.soundId) : super('Soundboard sound $soundId not found'); +} diff --git a/lib/src/event_mixin.dart b/lib/src/event_mixin.dart index 5912958e5..742f7d7ff 100644 --- a/lib/src/event_mixin.dart +++ b/lib/src/event_mixin.dart @@ -13,6 +13,7 @@ import 'package:nyxx/src/models/gateway/events/invite.dart'; import 'package:nyxx/src/models/gateway/events/message.dart'; import 'package:nyxx/src/models/gateway/events/presence.dart'; import 'package:nyxx/src/models/gateway/events/ready.dart'; +import 'package:nyxx/src/models/gateway/events/soundboard.dart'; import 'package:nyxx/src/models/gateway/events/stage_instance.dart'; import 'package:nyxx/src/models/gateway/events/voice.dart'; import 'package:nyxx/src/models/gateway/events/webhook.dart'; @@ -261,4 +262,19 @@ mixin EventMixin implements Nyxx { /// A [Stream] of [MessagePollVoteRemoveEvent]s received by this client. Stream get onMessagePollVoteRemove => onEvent.whereType(); + + /// A [Stream] of [SoundboardSoundDeleteEvent]s received by this client. + Stream get onSoundboardSoundDelete => onEvent.whereType(); + + /// A [Stream] of [SoundboardSoundUpdateEvent]s received by this client. + Stream get onSoundboardSoundUpdate => onEvent.whereType(); + + /// A [Stream] of [SoundboardSoundCreateEvent]s received by this client. + Stream get onSoundboardSoundCreate => onEvent.whereType(); + + /// A [Stream] of [SoundboardSoundsUpdateEvent]s received by this client. + Stream get onSoundboardSoundsUpdate => onEvent.whereType(); + + /// A [Stream] of [VoiceChannelEffectSendEvent]s received by this client. + Stream get onVoiceChannelEffectSend => onEvent.whereType(); } diff --git a/lib/src/gateway/gateway.dart b/lib/src/gateway/gateway.dart index 226a8ca0c..9f03f9b3a 100644 --- a/lib/src/gateway/gateway.dart +++ b/lib/src/gateway/gateway.dart @@ -18,6 +18,7 @@ import 'package:nyxx/src/models/channel/guild_channel.dart'; import 'package:nyxx/src/models/channel/text_channel.dart'; import 'package:nyxx/src/models/channel/thread.dart'; import 'package:nyxx/src/models/gateway/events/entitlement.dart'; +import 'package:nyxx/src/models/gateway/events/soundboard.dart'; import 'package:nyxx/src/models/gateway/gateway.dart'; import 'package:nyxx/src/models/gateway/event.dart'; import 'package:nyxx/src/models/gateway/events/application_command.dart'; @@ -284,6 +285,7 @@ class Gateway extends GatewayManager with EventParser { 'PRESENCE_UPDATE': parsePresenceUpdate, 'TYPING_START': parseTypingStart, 'USER_UPDATE': parseUserUpdate, + 'VOICE_CHANNEL_EFFECT_SEND': parseVoiceChannelEffectSend, 'VOICE_STATE_UPDATE': parseVoiceStateUpdate, 'VOICE_SERVER_UPDATE': parseVoiceServerUpdate, 'WEBHOOKS_UPDATE': parseWebhooksUpdate, @@ -296,6 +298,10 @@ class Gateway extends GatewayManager with EventParser { 'ENTITLEMENT_DELETE': parseEntitlementDelete, 'MESSAGE_POLL_VOTE_ADD': parseMessagePollVoteAdd, 'MESSAGE_POLL_VOTE_REMOVE': parseMessagePollVoteRemove, + 'GUILD_SOUNDBOARD_SOUND_CREATE': parseSoundboardSoundCreate, + 'GUILD_SOUNDBOARD_SOUND_UPDATE': parseSoundboardSoundUpdate, + 'GUILD_SOUNDBOARD_SOUND_DELETE': parseSoundboardSoundDelete, + 'GUILD_SOUNDBOARD_SOUNDS_UPDATE': parseSoundboardSoundsUpdate, }; return mapping[raw.name]?.call(raw.payload) ?? UnknownDispatchEvent(gateway: this, raw: raw); @@ -984,6 +990,23 @@ class Gateway extends GatewayManager with EventParser { ); } + /// Parse a [VoiceChannelEffectSendEvent] from [raw]. + VoiceChannelEffectSendEvent parseVoiceChannelEffectSend(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + return VoiceChannelEffectSendEvent( + gateway: this, + channelId: Snowflake.parse(raw['channel_id']!), + guildId: guildId, + userId: Snowflake.parse(raw['user_id']!), + emoji: maybeParse(raw['emoji'], client.guilds[guildId].emojis.parse), + animationType: maybeParse(raw['animation_type'], AnimationType.new), + animationId: raw['animation_id'] as int?, + soundId: maybeParse(raw['sound_id'], Snowflake.parse), + soundVolume: raw['sound_volume'] as double?, + ); + } + /// Parse a [VoiceStateUpdateEvent] from [raw]. VoiceStateUpdateEvent parseVoiceStateUpdate(Map raw) { final voiceState = client.voice.parseVoiceState(raw); @@ -1162,6 +1185,52 @@ class Gateway extends GatewayManager with EventParser { } } + SoundboardSoundCreateEvent parseSoundboardSoundCreate(Map raw) { + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + + return SoundboardSoundCreateEvent( + gateway: this, + sound: client.guilds[guildId ?? Snowflake.zero].soundboard.parse(raw), + ); + } + + SoundboardSoundUpdateEvent parseSoundboardSoundUpdate(Map raw) { + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + + return SoundboardSoundUpdateEvent( + gateway: this, + oldSound: client.guilds[guildId ?? Snowflake.zero].soundboard.cache[Snowflake.parse(raw['sound_id']!)], + sound: client.guilds[guildId ?? Snowflake.zero].soundboard.parse(raw), + ); + } + + SoundboardSoundDeleteEvent parseSoundboardSoundDelete(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + final soundId = Snowflake.parse(raw['sound_id']!); + + return SoundboardSoundDeleteEvent( + gateway: this, + sound: client.guilds[guildId].soundboard.cache[soundId], + guildId: guildId, + soundId: soundId, + ); + } + + SoundboardSoundsUpdateEvent parseSoundboardSoundsUpdate(Map raw) { + final guildId = Snowflake.parse(raw['guild_id']!); + + final sounds = parseMany(raw['sounds'] as List, client.guilds[guildId].soundboard.parse); + + final oldSounds = sounds.map((sound) => client.guilds[guildId].soundboard.cache[sound.id]).toList(); + + return SoundboardSoundsUpdateEvent( + gateway: this, + guildId: guildId, + sounds: sounds, + oldSounds: oldSounds, + ); + } + /// Update the client's voice state in the guild with ID [guildId]. void updateVoiceState(Snowflake guildId, GatewayVoiceStateBuilder builder) => shardFor(guildId).updateVoiceState(guildId, builder); diff --git a/lib/src/http/managers/soundboard_manager.dart b/lib/src/http/managers/soundboard_manager.dart new file mode 100644 index 000000000..d4cc96877 --- /dev/null +++ b/lib/src/http/managers/soundboard_manager.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; + +import 'package:nyxx/src/builders/soundboard.dart'; +import 'package:nyxx/src/errors.dart'; +import 'package:nyxx/src/http/managers/manager.dart'; +import 'package:nyxx/src/http/request.dart'; +import 'package:nyxx/src/http/route.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/soundboard/soundboard.dart'; +import 'package:nyxx/src/utils/cache_helpers.dart'; +import 'package:nyxx/src/utils/parsing_helpers.dart'; + +abstract class SoundboardManager extends Manager { + SoundboardManager(super.config, super.client, {required super.identifier}); + + @override + SoundboardSound parse(Map raw) { + final guildId = maybeParse(raw['guild_id'], Snowflake.parse); + + Emoji? emoji = maybeParse(raw['emoji_name'], (emoji) => client.guilds[guildId ?? Snowflake.zero].emojis.parse({'id': null, 'name': emoji})); + + final emojiId = maybeParse(raw['emoji_id'], Snowflake.parse); + + emoji ??= client.guilds[guildId ?? Snowflake.zero].emojis.cache[emojiId]; + + return SoundboardSound( + id: Snowflake.parse(raw['sound_id']!), + manager: this, + name: raw['name'] as String, + volume: (raw['volume'] as num).toDouble(), + emoji: emoji, + emojiName: raw['emoji_name'] as String?, + emojiId: emojiId, + guildId: guildId, + isAvailable: raw['available'] as bool, + user: maybeParse(raw['user'], client.users.parse), + ); + } + + Future> list(); +} + +/// A [Manager] for guild [SoundboardSound]s +class GuildSoundboardManager extends SoundboardManager { + /// The guild this manager is for. + final Snowflake guildId; + + GuildSoundboardManager(super.config, super.client, {required this.guildId}) : super(identifier: '$guildId.soundboard'); + + @override + PartialSoundboardSound operator [](Snowflake id) => PartialSoundboardSound(id: id, manager: this); + + @override + Future fetch(Snowflake id) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..soundboardSounds(id: id.toString()); + + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + + final sound = parse(response.jsonBody as Map); + client.updateCacheWith(sound); + + return sound; + } + + @override + Future> list() async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..soundboardSounds(); + + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + + final raw = (response.jsonBody as Map)['items'] as List; + + final sounds = parseMany(raw, parse); + + sounds.forEach(client.updateCacheWith); + + return sounds; + } + + @override + Future create(SoundboardSoundBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..soundboardSounds(); + + final request = BasicRequest(route, method: 'POST', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + + final soundboard = parse(response.jsonBody as Map); + + client.updateCacheWith(soundboard); + + return soundboard; + } + + @override + Future update(Snowflake id, SoundboardSoundUpdateBuilder builder, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..soundboardSounds(id: id.toString()); + + final request = BasicRequest(route, method: 'PATCH', auditLogReason: auditLogReason, body: jsonEncode(builder.build())); + + final response = await client.httpHandler.executeSafe(request); + + final soundboard = parse(response.jsonBody as Map); + + client.updateCacheWith(soundboard); + + return soundboard; + } + + @override + Future delete(Snowflake id, {String? auditLogReason}) async { + final route = HttpRoute() + ..guilds(id: guildId.toString()) + ..soundboardSounds(id: id.toString()); + + final request = BasicRequest(route, method: 'DELETE', auditLogReason: auditLogReason); + + await client.httpHandler.executeSafe(request); + cache.remove(id); + } + + /// Send a soundboard sound to a voice channel the user is connected to. [soundId] is the id of the soundboard sound to play, + /// while [sourceGuildId] is the id of the guild the soundboard sound is from. (Required to play sounds from different servers.) + Future sendSoundboardSound(Snowflake channelId, {required Snowflake soundId, Snowflake? sourceGuildId}) async { + final route = HttpRoute() + ..channels(id: channelId.toString()) + ..sendSoundboardSound(); + + final request = + BasicRequest(route, method: 'POST', body: jsonEncode({'sound_id': soundId.toString(), 'source_guild_id': (sourceGuildId ?? guildId).toString()})); + + await client.httpHandler.executeSafe(request); + } +} + +class GlobalSoundboardManager extends SoundboardManager implements ReadOnlyManager { + GlobalSoundboardManager(super.config, super.client) : super(identifier: 'soundboard'); + + @override + PartialSoundboardSound operator [](Snowflake id) => PartialSoundboardSound(id: id, manager: this); + + @override + Future fetch(Snowflake id) async { + final sounds = await list(); + + final sound = sounds.firstWhere( + (sound) => sound.id == id, + orElse: () => throw SoundboardSoundNotFoundException(id), + ); + + client.updateCacheWith(sound); + return sound; + } + + @override + Future> list() async { + final route = HttpRoute()..soundboardDefaultSounds(); + + final request = BasicRequest(route); + + final response = await client.httpHandler.executeSafe(request); + + final raw = response.jsonBody as List; + + final sounds = parseMany(raw, parse); + + sounds.forEach(client.updateCacheWith); + + return sounds; + } + + @override + Future delete(Snowflake id, {String? auditLogReason}) => throw UnsupportedError('Cannot delete a global soundboard sound'); + + @override + Future create(SoundboardSoundBuilder builder, {String? auditLogReason}) => throw UnsupportedError('Cannot create a global soundboard sound'); + + @override + Future update(Snowflake id, SoundboardSoundUpdateBuilder builder, {String? auditLogReason}) => + throw UnsupportedError('Cannot update a global soundboard sound'); +} diff --git a/lib/src/http/route.dart b/lib/src/http/route.dart index b7cebaf49..cb8686b5d 100644 --- a/lib/src/http/route.dart +++ b/lib/src/http/route.dart @@ -332,4 +332,13 @@ extension RouteHelpers on HttpRoute { /// Adds the [`subscriptions`](https://discord.com/developers/docs/resources/subscription#list-sku-subscriptions) part to this [HttpRoute]. void subscriptions({String? id}) => add(HttpRoutePart('subscriptions', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`sounboard-default-sounds`](https://discord.com/developers/docs/resources/soundboard#list-default-soundboard-sounds) part to this [HttpRoute]. + void soundboardDefaultSounds() => add(HttpRoutePart('soundboard-default-sounds')); + + /// Adds the [`soundboard-sounds`](https://discord.com/developers/docs/resources/soundboard#list-guild-soundboard-sounds) part to this [HttpRoute]. + void soundboardSounds({String? id}) => add(HttpRoutePart('soundboard-sounds', [if (id != null) HttpRouteParam(id)])); + + /// Adds the [`send-soundboard-sound`](https://discord.com/developers/docs/resources/soundboard#send-soundboard-sound) part to this [HttpRoute]. + void sendSoundboardSound() => add(HttpRoutePart('send-soundboard-sound')); } diff --git a/lib/src/manager_mixin.dart b/lib/src/manager_mixin.dart index 7ccec1de0..3aaf0d0fc 100644 --- a/lib/src/manager_mixin.dart +++ b/lib/src/manager_mixin.dart @@ -6,6 +6,7 @@ import 'package:nyxx/src/http/managers/interaction_manager.dart'; import 'package:nyxx/src/http/managers/invite_manager.dart'; import 'package:nyxx/src/http/managers/gateway_manager.dart'; import 'package:nyxx/src/http/managers/guild_manager.dart'; +import 'package:nyxx/src/http/managers/soundboard_manager.dart'; import 'package:nyxx/src/http/managers/sticker_manager.dart'; import 'package:nyxx/src/http/managers/user_manager.dart'; import 'package:nyxx/src/http/managers/webhook_manager.dart'; @@ -49,4 +50,7 @@ mixin ManagerMixin implements Nyxx { GlobalApplicationCommandManager(options.applicationCommandConfig, this as NyxxRest, applicationId: (this as NyxxRest).application.id); InteractionManager get interactions => InteractionManager(this as NyxxRest, applicationId: (this as NyxxRest).application.id); + + /// A [GlobalSoundboardManager] that manages global soundboard sounds. + GlobalSoundboardManager get soundboard => GlobalSoundboardManager(options.globalSoundboardCacheConfig, this as NyxxRest); } diff --git a/lib/src/models/gateway/events/soundboard.dart b/lib/src/models/gateway/events/soundboard.dart new file mode 100644 index 000000000..f6df2f1c6 --- /dev/null +++ b/lib/src/models/gateway/events/soundboard.dart @@ -0,0 +1,69 @@ +import 'package:nyxx/src/models/gateway/event.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/soundboard/soundboard.dart'; + +/// {@template soundboard_sound_create_event} +/// Emitted when a guild soundboard sound is created. +/// {@endtemplate} +class SoundboardSoundCreateEvent extends DispatchEvent { + /// The sound that was created. + final SoundboardSound sound; + + /// {@macro soundboard_sound_create_event} + /// @nodoc + SoundboardSoundCreateEvent({required super.gateway, required this.sound}); +} + +/// {@template soundboard_sound_update_event} +/// Emitted when a guild soundboard sound is updated. +/// {@endtemplate} +class SoundboardSoundUpdateEvent extends DispatchEvent { + /// The sound that was updated. + final SoundboardSound sound; + + /// The old sound. + final SoundboardSound? oldSound; + + /// {@macro soundboard_sound_update_event} + /// @nodoc + SoundboardSoundUpdateEvent({required super.gateway, required this.sound, required this.oldSound}); +} + +/// {@template soundboard_sound_delete_event} +/// Emitted when a guild soundboard sound is deleted. +/// {@endtemplate} +class SoundboardSoundDeleteEvent extends DispatchEvent { + /// The sound that was deleted. + final SoundboardSound? sound; + + /// The guild ID where the sound was deleted. + final Snowflake guildId; + + /// The sound ID that was deleted. + final Snowflake soundId; + + /// {@macro soundboard_sound_delete_event} + /// @nodoc + SoundboardSoundDeleteEvent({required super.gateway, required this.sound, required this.guildId, required this.soundId}); + + PartialGuild get guild => gateway.client.guilds[guildId]; +} + +/// {@template soundboard_sounds_update_event} +/// Emitted when multiple guild soundboard sounds are updated. +/// {@endtemplate} +class SoundboardSoundsUpdateEvent extends DispatchEvent { + /// The ID of the guild where the sounds were updated. + final Snowflake guildId; + + /// The sounds that were updated. + final List sounds; + + /// The old sounds. + final List oldSounds; + + /// {@macro soundboard_sounds_update_event} + /// @nodoc + SoundboardSoundsUpdateEvent({required super.gateway, required this.guildId, required this.sounds, required this.oldSounds}); +} diff --git a/lib/src/models/gateway/events/voice.dart b/lib/src/models/gateway/events/voice.dart index b470dcd66..538da93e6 100644 --- a/lib/src/models/gateway/events/voice.dart +++ b/lib/src/models/gateway/events/voice.dart @@ -1,7 +1,11 @@ +import 'package:nyxx/src/models/channel/channel.dart'; +import 'package:nyxx/src/models/emoji.dart'; import 'package:nyxx/src/models/gateway/event.dart'; import 'package:nyxx/src/models/guild/guild.dart'; import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/user/user.dart'; import 'package:nyxx/src/models/voice/voice_state.dart'; +import 'package:nyxx/src/utils/enum_like.dart'; /// {@template voice_state_update_event} /// Emitted when a user's voice state is updated. @@ -38,3 +42,63 @@ class VoiceServerUpdateEvent extends DispatchEvent { /// The guild. PartialGuild get guild => gateway.client.guilds[guildId]; } + +/// {@template voice_channel_effect_send_event} +/// Emitted when someone sends an effect, such as an emoji reaction or a soundboard sound, in a voice channel the current user is connected to. +/// {@endtemplate} +class VoiceChannelEffectSendEvent extends DispatchEvent { + /// The ID of the channel this effect was sent in. + final Snowflake channelId; + + /// The ID of the guild this effect was sent in. + final Snowflake guildId; + + /// The ID of the user who sent this effect. + final Snowflake userId; + + /// The emoji sent, for emoji reaction and soundboard effects. + final Emoji? emoji; + + /// The type of emoji animation, for emoji reaction and soundboard effects. + final AnimationType? animationType; + + /// The ID of the emoji animation, for emoji reaction and soundboard effects. + final int? animationId; + + /// The ID of the soundboard sound, for soundboard effects. + final Snowflake? soundId; + + /// The volume of the soundboard sound, from 0 to 1, for soundboard effects. + final double? soundVolume; + + /// {@macro voice_channel_effect_send_event} + /// @nodoc + VoiceChannelEffectSendEvent({ + required super.gateway, + required this.channelId, + required this.guildId, + required this.userId, + required this.emoji, + required this.animationType, + required this.animationId, + required this.soundId, + required this.soundVolume, + }); + + /// The channel this effect was sent in. + PartialChannel get channel => gateway.client.channels[channelId]; + + /// The guild this effect was sent in. + PartialGuild get guild => gateway.client.guilds[guildId]; + + /// The user who sent this effect. + PartialUser get user => gateway.client.users[userId]; +} + +final class AnimationType extends EnumLike { + static const premium = AnimationType(1); + static const basic = AnimationType(2); + + /// @nodoc + const AnimationType(super.value); +} diff --git a/lib/src/models/guild/guild.dart b/lib/src/models/guild/guild.dart index 28d3e2a62..22b5b10b7 100644 --- a/lib/src/models/guild/guild.dart +++ b/lib/src/models/guild/guild.dart @@ -18,6 +18,7 @@ import 'package:nyxx/src/http/managers/integration_manager.dart'; import 'package:nyxx/src/http/managers/member_manager.dart'; import 'package:nyxx/src/http/managers/role_manager.dart'; import 'package:nyxx/src/http/managers/scheduled_event_manager.dart'; +import 'package:nyxx/src/http/managers/soundboard_manager.dart'; import 'package:nyxx/src/http/route.dart'; import 'package:nyxx/src/models/application.dart'; import 'package:nyxx/src/models/channel/channel.dart'; @@ -75,6 +76,9 @@ class PartialGuild extends WritableSnowflakeEntity { /// An [AuditLogManager] for the audit log of this guild. AuditLogManager get auditLogs => AuditLogManager(manager.client.options.auditLogEntryConfig, manager.client, guildId: id); + /// A [GuildSoundboardManager] for the soundboard sounds of this guild. + GuildSoundboardManager get soundboard => GuildSoundboardManager(manager.client.options.soundboardCacheConfig, manager.client, guildId: id); + /// A [Cache] for [VoiceState]s in this guild. Cache get voiceStates => manager.client.cache.getCache('$id.voiceStates', manager.client.options.voiceStateConfig); diff --git a/lib/src/models/soundboard/soundboard.dart b/lib/src/models/soundboard/soundboard.dart new file mode 100644 index 000000000..9ade9e6ab --- /dev/null +++ b/lib/src/models/soundboard/soundboard.dart @@ -0,0 +1,54 @@ +import 'package:nyxx/src/http/managers/soundboard_manager.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/guild/guild.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/snowflake_entity/snowflake_entity.dart'; +import 'package:nyxx/src/models/user/user.dart'; + +class PartialSoundboardSound extends WritableSnowflakeEntity { + @override + final SoundboardManager manager; + + PartialSoundboardSound({required super.id, required this.manager}); +} + +class SoundboardSound extends PartialSoundboardSound { + /// The name of this sound. + final String name; + + /// The volume of this sound, from 0 to 1. + final double volume; + + /// The emoji this sound is associated with. + final Emoji? emoji; + + /// The emoji name this sound is associated with. + final String? emojiName; + + /// The emoji ID this sound is associated with. + final Snowflake? emojiId; + + /// The ID of the guild this sound is in. + final Snowflake? guildId; + + /// Whether this sound can be used, may be `false` due to loss of Server Boosts. + final bool isAvailable; + + /// The user who created this sound. + final User? user; + + SoundboardSound({ + required super.id, + required super.manager, + required this.name, + required this.volume, + required this.emoji, + required this.emojiName, + required this.emojiId, + required this.guildId, + required this.isAvailable, + required this.user, + }); + + PartialGuild get guild => manager.client.guilds[guildId!]; +} diff --git a/lib/src/utils/building_helpers.dart b/lib/src/utils/building_helpers.dart new file mode 100644 index 000000000..f7db5894d --- /dev/null +++ b/lib/src/utils/building_helpers.dart @@ -0,0 +1,6 @@ +import 'package:nyxx/nyxx.dart'; + +Map makeEmojiMap({Snowflake? emojiId, String? emojiName}) => { + if (emojiName case final String emojiName?) 'emoji_name': emojiName, + if (emojiId case final Snowflake emojiId?) 'emoji_id': emojiId.toString(), + }; diff --git a/lib/src/utils/cache_helpers.dart b/lib/src/utils/cache_helpers.dart index 3b343ce61..be32476ef 100644 --- a/lib/src/utils/cache_helpers.dart +++ b/lib/src/utils/cache_helpers.dart @@ -21,6 +21,7 @@ import 'package:nyxx/src/models/gateway/events/invite.dart'; import 'package:nyxx/src/models/gateway/events/message.dart'; import 'package:nyxx/src/models/gateway/events/presence.dart'; import 'package:nyxx/src/models/gateway/events/ready.dart'; +import 'package:nyxx/src/models/gateway/events/soundboard.dart'; import 'package:nyxx/src/models/gateway/events/stage_instance.dart'; import 'package:nyxx/src/models/gateway/events/voice.dart'; import 'package:nyxx/src/models/gateway/events/webhook.dart'; @@ -38,6 +39,7 @@ import 'package:nyxx/src/models/invite/invite.dart'; import 'package:nyxx/src/models/message/message.dart'; import 'package:nyxx/src/models/presence.dart'; import 'package:nyxx/src/models/role.dart'; +import 'package:nyxx/src/models/soundboard/soundboard.dart'; import 'package:nyxx/src/models/sticker/global_sticker.dart'; import 'package:nyxx/src/models/sticker/guild_sticker.dart'; import 'package:nyxx/src/models/sticker/sticker_pack.dart'; @@ -288,6 +290,15 @@ extension CacheUpdates on NyxxRest { EntitlementCreateEvent(:final entitlement) => updateCacheWith(entitlement), EntitlementUpdateEvent(:final entitlement) => updateCacheWith(entitlement), EntitlementDeleteEvent(:final entitlement) => entitlement.manager.cache.remove(entitlement.id), + SoundboardSound() => () { + updateCacheWith(entity.user); + entity.manager.cache[entity.id] = entity; + }(), + VoiceChannelEffectSendEvent() => null, + SoundboardSoundCreateEvent(:final sound) => updateCacheWith(sound), + SoundboardSoundUpdateEvent(:final sound) => updateCacheWith(sound), + SoundboardSoundDeleteEvent(:final sound?) => sound.manager.cache.remove(sound.id), + SoundboardSoundsUpdateEvent(:final sounds) => sounds.forEach(updateCacheWith), MessagePollVoteAddEvent() => null, MessagePollVoteRemoveEvent() => null, diff --git a/test/files/sound.ogg b/test/files/sound.ogg new file mode 100644 index 000000000..0321a702d Binary files /dev/null and b/test/files/sound.ogg differ diff --git a/test/integration/rest_integration_test.dart b/test/integration/rest_integration_test.dart index cce5f9b58..5c462566f 100644 --- a/test/integration/rest_integration_test.dart +++ b/test/integration/rest_integration_test.dart @@ -2,6 +2,9 @@ import 'dart:io'; import 'package:mocktail/mocktail.dart'; import 'package:nyxx/nyxx.dart'; +import 'package:nyxx/src/builders/sound.dart'; +import 'package:nyxx/src/builders/soundboard.dart'; +import 'package:nyxx/src/models/soundboard/soundboard.dart'; import 'package:test/test.dart' hide completes; import '../function_completes.dart'; @@ -432,5 +435,27 @@ void main() { await expectLater(command.delete(), completes); }); + + test('Soundboard', skip: testGuild != null ? false : 'No test guild provided', () async { + final guildId = Snowflake.parse(testGuild!); + final guild = client.guilds[guildId]; + + await expectLater(guild.soundboard.list(), completion(isEmpty)); + + late SoundboardSound sound; + await expectLater( + () async => sound = await guild.soundboard.create( + SoundboardSoundBuilder( + name: 'Test sound', + volume: 0.5, + sound: await SoundBuilder.fromFile(File('test/files/sound.ogg')), + ), + ), + completes, + ); + + await expectLater(sound.update(SoundboardSoundUpdateBuilder(name: 'New name')), completes); + await expectLater(sound.delete(), completes); + }); }); } diff --git a/test/unit/builders/sound_test.dart b/test/unit/builders/sound_test.dart new file mode 100644 index 000000000..3d82027df --- /dev/null +++ b/test/unit/builders/sound_test.dart @@ -0,0 +1,15 @@ +import 'package:nyxx/src/builders/sound.dart'; +import 'package:test/test.dart'; + +void main() { + group('SoundBuilder', () { + test('build', () { + final builder = SoundBuilder( + format: 'mp3', + data: [0, 0, 0, 255, 255, 255], + ); + + expect(builder.buildDataString(), equals('data:audio/mp3;base64,AAAA////')); + }); + }); +} diff --git a/test/unit/http/managers/soundboard_manager_test.dart b/test/unit/http/managers/soundboard_manager_test.dart new file mode 100644 index 000000000..bbee65a8b --- /dev/null +++ b/test/unit/http/managers/soundboard_manager_test.dart @@ -0,0 +1,98 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:nyxx/src/api_options.dart'; +import 'package:nyxx/src/builders/sound.dart'; +import 'package:nyxx/src/builders/soundboard.dart'; +import 'package:nyxx/src/client.dart'; +import 'package:nyxx/src/client_options.dart'; +import 'package:nyxx/src/http/managers/soundboard_manager.dart'; +import 'package:nyxx/src/models/emoji.dart'; +import 'package:nyxx/src/models/snowflake.dart'; +import 'package:nyxx/src/models/soundboard/soundboard.dart'; +import 'package:test/test.dart'; +import '../../../mocks/client.dart'; +import '../../../test_manager.dart'; + +const sampleGlobalSoundboardSound = { + "name": "quack", + "sound_id": "1", + "volume": 1.0, + "emoji_id": null, + "emoji_name": "🦆", + "available": true, +}; + +const sampleGuildSoundboardSound = { + "name": "Yay", + "sound_id": "1106714396018884649", + "volume": 1, + "emoji_id": "989193655938064464", + "emoji_name": null, + "guild_id": "613425648685547541", + "available": true +}; + +void checkGlobalSoundboardSound(SoundboardSound sound, NyxxRest client) { + expect(sound.id, equals(Snowflake(1))); + expect(sound.name, equals('quack')); + expect(sound.volume, equals(1.0)); + expect(sound.emoji, equals(TextEmoji(id: Snowflake.zero, name: '🦆', manager: client.application.emojis))); + expect(sound.emojiName, equals('🦆')); + expect(sound.emojiId, isNull); + expect(sound.guildId, isNull); + expect(sound.isAvailable, isTrue); + expect(sound.user, isNull); +} + +void checkGuildSoundboardSound(SoundboardSound sound, NyxxRest client) { + expect(sound.id, equals(Snowflake(1106714396018884649))); + expect(sound.name, equals('Yay')); + expect(sound.volume, equals(1.0)); + expect(sound.emoji, equals(client.guilds[Snowflake(613425648685547541)].emojis.cache[Snowflake(989193655938064464)])); + expect(sound.emojiName, isNull); + expect(sound.emojiId, equals(Snowflake(989193655938064464))); + expect(sound.guildId, equals(Snowflake(613425648685547541))); + expect(sound.isAvailable, isTrue); + expect(sound.user, isNull); +} + +void main() { + final client = MockNyxx(); + when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN')); + when(() => client.options).thenReturn(RestClientOptions()); + testReadOnlyManager( + 'GlobalSoundboardManager', + GlobalSoundboardManager.new, + '/soundboard-default-sounds', + sampleObject: sampleGlobalSoundboardSound, + fetchObjectOverride: [sampleGlobalSoundboardSound], + sampleMatches: (source) => checkGlobalSoundboardSound(source, client), + additionalParsingTests: [], + additionalEndpointTests: [], + ); + + testManager( + 'GuildSoundboardManager', + (config, client) => GuildSoundboardManager(config, client, guildId: Snowflake.zero), + RegExp(r'/guilds/0/soundboard-sounds/\d+'), + '/guilds/0/soundboard-sounds', + sampleObject: sampleGuildSoundboardSound, + sampleMatches: (sound) => checkGuildSoundboardSound(sound, client), + additionalParsingTests: [], + additionalEndpointTests: [ + EndpointTest( + name: 'send-soundboard-sound', + source: null, + urlMatcher: '/channels/0/send-soundboard-sound', + execute: (manager) => manager.sendSoundboardSound(Snowflake.zero, soundId: Snowflake.zero), + check: (_) {}, + method: 'post'), + ], + createBuilder: SoundboardSoundBuilder( + name: 'cool', + sound: SoundBuilder.ogg([0, 1, 2, 3]), + volume: .5, + emojiName: '😎', + ), + updateBuilder: SoundboardSoundUpdateBuilder(name: 'cooler', volume: .7), + ); +}