Skip to content

Commit d3154cf

Browse files
authored
feat: add soundboard in v14 (#10843)
1 parent 45552fa commit d3154cf

29 files changed

+788
-10
lines changed

packages/discord.js/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"discord-api-types": "^0.37.119",
7676
"fast-deep-equal": "3.1.3",
7777
"lodash.snakecase": "4.1.1",
78+
"magic-bytes.js": "^1.10.0",
7879
"tslib": "^2.6.3",
7980
"undici": "6.21.1"
8081
},

packages/discord.js/src/client/Client.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const ClientPresence = require('../structures/ClientPresence');
1818
const GuildPreview = require('../structures/GuildPreview');
1919
const GuildTemplate = require('../structures/GuildTemplate');
2020
const Invite = require('../structures/Invite');
21+
const { SoundboardSound } = require('../structures/SoundboardSound');
2122
const { Sticker } = require('../structures/Sticker');
2223
const StickerPack = require('../structures/StickerPack');
2324
const VoiceRegion = require('../structures/VoiceRegion');
@@ -390,6 +391,19 @@ class Client extends BaseClient {
390391
return this.fetchStickerPacks();
391392
}
392393

394+
/**
395+
* Obtains the list of default soundboard sounds.
396+
* @returns {Promise<Collection<string, SoundboardSound>>}
397+
* @example
398+
* client.fetchDefaultSoundboardSounds()
399+
* .then(sounds => console.log(`Available soundboard sounds are: ${sounds.map(sound => sound.name).join(', ')}`))
400+
* .catch(console.error);
401+
*/
402+
async fetchDefaultSoundboardSounds() {
403+
const data = await this.rest.get(Routes.soundboardDefaultSounds());
404+
return new Collection(data.map(sound => [sound.sound_id, new SoundboardSound(this, sound)]));
405+
}
406+
393407
/**
394408
* Obtains a guild preview from Discord, available for all guilds the bot is in and all Discoverable guilds.
395409
* @param {GuildResolvable} guild The guild to fetch the preview for

packages/discord.js/src/client/actions/Action.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ class GenericAction {
112112
return this.getPayload({ user_id: id }, manager, id, Partials.ThreadMember, false);
113113
}
114114

115+
getSoundboardSound(data, guild) {
116+
return this.getPayload(data, guild.soundboardSounds, data.sound_id, Partials.SoundboardSound);
117+
}
118+
115119
spreadInjectedData(data) {
116120
return Object.fromEntries(Object.getOwnPropertySymbols(data).map(symbol => [symbol, data[symbol]]));
117121
}

packages/discord.js/src/client/actions/ActionsManager.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class ActionsManager {
4343
this.register(require('./GuildScheduledEventUpdate'));
4444
this.register(require('./GuildScheduledEventUserAdd'));
4545
this.register(require('./GuildScheduledEventUserRemove'));
46+
this.register(require('./GuildSoundboardSoundDelete.js'));
4647
this.register(require('./GuildStickerCreate'));
4748
this.register(require('./GuildStickerDelete'));
4849
this.register(require('./GuildStickerUpdate'));
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use strict';
2+
3+
const Action = require('./Action.js');
4+
const Events = require('../../util/Events.js');
5+
6+
class GuildSoundboardSoundDeleteAction extends Action {
7+
handle(data) {
8+
const guild = this.client.guilds.cache.get(data.guild_id);
9+
10+
if (!guild) return {};
11+
12+
const soundboardSound = this.getSoundboardSound(data, guild);
13+
14+
if (soundboardSound) {
15+
guild.soundboardSounds.cache.delete(soundboardSound.soundId);
16+
17+
/**
18+
* Emitted whenever a soundboard sound is deleted in a guild.
19+
* @event Client#guildSoundboardSoundDelete
20+
* @param {SoundboardSound} soundboardSound The soundboard sound that was deleted
21+
*/
22+
this.client.emit(Events.GuildSoundboardSoundDelete, soundboardSound);
23+
}
24+
25+
return { soundboardSound };
26+
}
27+
}
28+
29+
module.exports = GuildSoundboardSoundDeleteAction;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict';
2+
3+
const { Collection } = require('@discordjs/collection');
4+
const Events = require('../../../util/Events.js');
5+
6+
module.exports = (client, { d: data }) => {
7+
const guild = client.guilds.cache.get(data.guild_id);
8+
9+
if (!guild) return;
10+
11+
const soundboardSounds = new Collection();
12+
13+
for (const soundboardSound of data.soundboard_sounds) {
14+
soundboardSounds.set(soundboardSound.sound_id, guild.soundboardSounds._add(soundboardSound));
15+
}
16+
17+
/**
18+
* Emitted whenever multiple guild soundboard sounds are updated.
19+
* @event Client#guildSoundboardSoundsUpdate
20+
* @param {Collection<Snowflake, SoundboardSound>} soundboardSounds The updated soundboard sounds
21+
* @param {Guild} guild The guild that the soundboard sounds are from
22+
*/
23+
client.emit(Events.GuildSoundboardSoundsUpdate, soundboardSounds, guild);
24+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
'use strict';
2+
3+
const Events = require('../../../util/Events.js');
4+
5+
module.exports = (client, { d: data }) => {
6+
const guild = client.guilds.cache.get(data.guild_id);
7+
8+
if (!guild) return;
9+
10+
const soundboardSound = guild.soundboardSounds._add(data);
11+
12+
/**
13+
* Emitted whenever a guild soundboard sound is created.
14+
* @event Client#guildSoundboardSoundCreate
15+
* @param {SoundboardSound} soundboardSound The created guild soundboard sound
16+
*/
17+
client.emit(Events.GuildSoundboardSoundCreate, soundboardSound);
18+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use strict';
2+
3+
module.exports = (client, { d: data }) => {
4+
client.actions.GuildSoundboardSoundDelete.handle(data);
5+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
'use strict';
2+
3+
const Events = require('../../../util/Events.js');
4+
5+
module.exports = (client, { d: data }) => {
6+
const guild = client.guilds.cache.get(data.guild_id);
7+
8+
if (!guild) return;
9+
10+
const oldGuildSoundboardSound = guild.soundboardSounds.cache.get(data.sound_id)?._clone() ?? null;
11+
const newGuildSoundboardSound = guild.soundboardSounds._add(data);
12+
13+
/**
14+
* Emitted whenever a guild soundboard sound is updated.
15+
* @event Client#guildSoundboardSoundUpdate
16+
* @param {?SoundboardSound} oldGuildSoundboardSound The guild soundboard sound before the update
17+
* @param {SoundboardSound} newGuildSoundboardSound The guild soundboard sound after the update
18+
*/
19+
client.emit(Events.GuildSoundboardSoundUpdate, oldGuildSoundboardSound, newGuildSoundboardSound);
20+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict';
2+
3+
const { Collection } = require('@discordjs/collection');
4+
const Events = require('../../../util/Events.js');
5+
6+
module.exports = (client, { d: data }) => {
7+
const guild = client.guilds.cache.get(data.guild_id);
8+
9+
if (!guild) return;
10+
11+
const soundboardSounds = new Collection();
12+
13+
for (const soundboardSound of data.soundboard_sounds) {
14+
soundboardSounds.set(soundboardSound.sound_id, guild.soundboardSounds._add(soundboardSound));
15+
}
16+
17+
/**
18+
* Emitted whenever soundboard sounds are received (all soundboard sounds come from the same guild).
19+
* @event Client#soundboardSounds
20+
* @param {Collection<Snowflake, SoundboardSound>} soundboardSounds The sounds received
21+
* @param {Guild} guild The guild that the soundboard sounds are from
22+
*/
23+
client.emit(Events.SoundboardSounds, soundboardSounds, guild);
24+
};

packages/discord.js/src/client/websocket/handlers/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const handlers = Object.fromEntries([
3232
['GUILD_SCHEDULED_EVENT_UPDATE', require('./GUILD_SCHEDULED_EVENT_UPDATE')],
3333
['GUILD_SCHEDULED_EVENT_USER_ADD', require('./GUILD_SCHEDULED_EVENT_USER_ADD')],
3434
['GUILD_SCHEDULED_EVENT_USER_REMOVE', require('./GUILD_SCHEDULED_EVENT_USER_REMOVE')],
35+
['GUILD_SOUNDBOARD_SOUNDS_UPDATE', require('./GUILD_SOUNDBOARD_SOUNDS_UPDATE.js')],
36+
['GUILD_SOUNDBOARD_SOUND_CREATE', require('./GUILD_SOUNDBOARD_SOUND_CREATE.js')],
37+
['GUILD_SOUNDBOARD_SOUND_DELETE', require('./GUILD_SOUNDBOARD_SOUND_DELETE.js')],
38+
['GUILD_SOUNDBOARD_SOUND_UPDATE', require('./GUILD_SOUNDBOARD_SOUND_UPDATE.js')],
3539
['GUILD_STICKERS_UPDATE', require('./GUILD_STICKERS_UPDATE')],
3640
['GUILD_UPDATE', require('./GUILD_UPDATE')],
3741
['INTERACTION_CREATE', require('./INTERACTION_CREATE')],
@@ -50,6 +54,7 @@ const handlers = Object.fromEntries([
5054
['PRESENCE_UPDATE', require('./PRESENCE_UPDATE')],
5155
['READY', require('./READY')],
5256
['RESUMED', require('./RESUMED')],
57+
['SOUNDBOARD_SOUNDS', require('./SOUNDBOARD_SOUNDS.js')],
5358
['STAGE_INSTANCE_CREATE', require('./STAGE_INSTANCE_CREATE')],
5459
['STAGE_INSTANCE_DELETE', require('./STAGE_INSTANCE_DELETE')],
5560
['STAGE_INSTANCE_UPDATE', require('./STAGE_INSTANCE_UPDATE')],

packages/discord.js/src/errors/ErrorCodes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
* @property {'GuildChannelUnowned'} GuildChannelUnowned
107107
* @property {'GuildOwned'} GuildOwned
108108
* @property {'GuildMembersTimeout'} GuildMembersTimeout
109+
* @property {'GuildSoundboardSoundsTimeout'} GuildSoundboardSoundsTimeout
109110
* @property {'GuildUncachedMe'} GuildUncachedMe
110111
* @property {'ChannelNotCached'} ChannelNotCached
111112
* @property {'StageChannelResolve'} StageChannelResolve
@@ -131,6 +132,8 @@
131132
* @property {'MissingManageEmojisAndStickersPermission'} MissingManageEmojisAndStickersPermission
132133
* <warn>This property is deprecated. Use `MissingManageGuildExpressionsPermission` instead.</warn>
133134
*
135+
136+
* @property {'NotGuildSoundboardSound'} NotGuildSoundboardSound
134137
* @property {'NotGuildSticker'} NotGuildSticker
135138
136139
* @property {'ReactionResolveUser'} ReactionResolveUser
@@ -266,6 +269,7 @@ const keys = [
266269
'GuildChannelUnowned',
267270
'GuildOwned',
268271
'GuildMembersTimeout',
272+
'GuildSoundboardSoundsTimeout',
269273
'GuildUncachedMe',
270274
'ChannelNotCached',
271275
'StageChannelResolve',
@@ -290,6 +294,7 @@ const keys = [
290294
'MissingManageGuildExpressionsPermission',
291295
'MissingManageEmojisAndStickersPermission',
292296

297+
'NotGuildSoundboardSound',
293298
'NotGuildSticker',
294299

295300
'ReactionResolveUser',

packages/discord.js/src/errors/Messages.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ const Messages = {
9191
[DjsErrorCodes.GuildChannelUnowned]: "The fetched channel does not belong to this manager's guild.",
9292
[DjsErrorCodes.GuildOwned]: 'Guild is owned by the client.',
9393
[DjsErrorCodes.GuildMembersTimeout]: "Members didn't arrive in time.",
94+
[DjsErrorCodes.GuildSoundboardSoundsTimeout]: "Soundboard sounds didn't arrive in time.",
9495
[DjsErrorCodes.GuildUncachedMe]: 'The client user as a member of this guild is uncached.',
9596
[DjsErrorCodes.ChannelNotCached]: 'Could not find the channel where this message came from in the cache!',
9697
[DjsErrorCodes.StageChannelResolve]: 'Could not resolve channel to a stage channel.',
@@ -118,6 +119,8 @@ const Messages = {
118119
[DjsErrorCodes.MissingManageEmojisAndStickersPermission]: guild =>
119120
`Client must have Manage Emojis and Stickers permission in guild ${guild} to see emoji authors.`,
120121

122+
[DjsErrorCodes.NotGuildSoundboardSound]: action =>
123+
`Soundboard sound is a default (non-guild) soundboard sound and can't be ${action}.`,
121124
[DjsErrorCodes.NotGuildSticker]: 'Sticker is a standard (non-guild) sticker and has no author.',
122125

123126
[DjsErrorCodes.ReactionResolveUser]: "Couldn't resolve the user id to remove from the reaction.",

packages/discord.js/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ exports.GuildMemberManager = require('./managers/GuildMemberManager');
7575
exports.GuildMemberRoleManager = require('./managers/GuildMemberRoleManager');
7676
exports.GuildMessageManager = require('./managers/GuildMessageManager');
7777
exports.GuildScheduledEventManager = require('./managers/GuildScheduledEventManager');
78+
exports.GuildSoundboardSoundManager = require('./managers/GuildSoundboardSoundManager.js').GuildSoundboardSoundManager;
7879
exports.GuildStickerManager = require('./managers/GuildStickerManager');
7980
exports.GuildTextThreadManager = require('./managers/GuildTextThreadManager');
8081
exports.MessageManager = require('./managers/MessageManager');
@@ -202,6 +203,7 @@ exports.StringSelectMenuInteraction = require('./structures/StringSelectMenuInte
202203
exports.UserSelectMenuInteraction = require('./structures/UserSelectMenuInteraction');
203204
exports.SelectMenuOptionBuilder = require('./structures/SelectMenuOptionBuilder');
204205
exports.SKU = require('./structures/SKU').SKU;
206+
exports.SoundboardSound = require('./structures/SoundboardSound.js').SoundboardSound;
205207
exports.StringSelectMenuOptionBuilder = require('./structures/StringSelectMenuOptionBuilder');
206208
exports.StageChannel = require('./structures/StageChannel');
207209
exports.StageInstance = require('./structures/StageInstance').StageInstance;

packages/discord.js/src/managers/GuildManager.js

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ const process = require('node:process');
44
const { setTimeout, clearTimeout } = require('node:timers');
55
const { Collection } = require('@discordjs/collection');
66
const { makeURLSearchParams } = require('@discordjs/rest');
7-
const { Routes, RouteBases } = require('discord-api-types/v10');
7+
const { GatewayOpcodes, Routes, RouteBases } = require('discord-api-types/v10');
88
const CachedManager = require('./CachedManager');
9+
const { ErrorCodes, DiscordjsError } = require('../errors/index.js');
910
const ShardClientUtil = require('../sharding/ShardClientUtil');
1011
const { Guild } = require('../structures/Guild');
1112
const GuildChannel = require('../structures/GuildChannel');
@@ -282,6 +283,79 @@ class GuildManager extends CachedManager {
282283
return data.reduce((coll, guild) => coll.set(guild.id, new OAuth2Guild(this.client, guild)), new Collection());
283284
}
284285

286+
/**
287+
* @typedef {Object} FetchSoundboardSoundsOptions
288+
* @param {Snowflake[]} guildIds The ids of the guilds to fetch soundboard sounds for
289+
* @param {number} [time=10_000] The timeout for receipt of the soundboard sounds
290+
*/
291+
292+
/**
293+
* Fetches soundboard sounds for the specified guilds.
294+
* @param {FetchSoundboardSoundsOptions} options The options for fetching soundboard sounds
295+
* @returns {Promise<Collection<Snowflake, Collection<Snowflake, SoundboardSound>>>}
296+
* @example
297+
* // Fetch soundboard sounds for multiple guilds
298+
* const soundboardSounds = await client.guilds.fetchSoundboardSounds({
299+
* guildIds: ['123456789012345678', '987654321098765432'],
300+
* })
301+
*
302+
* console.log(soundboardSounds.get('123456789012345678'));
303+
*/
304+
async fetchSoundboardSounds({ guildIds, time = 10_000 }) {
305+
const shardCount = this.client.options.shardCount;
306+
const shardIds = new Map();
307+
308+
for (const guildId of guildIds) {
309+
const shardId = ShardClientUtil.shardIdForGuildId(guildId, shardCount);
310+
const group = shardIds.get(shardId);
311+
312+
if (group) group.push(guildId);
313+
else shardIds.set(shardId, [guildId]);
314+
}
315+
316+
for (const [shardId, shardGuildIds] of shardIds) {
317+
this.client.ws.shards.get(shardId).send({
318+
op: GatewayOpcodes.RequestSoundboardSounds,
319+
d: {
320+
guild_ids: shardGuildIds,
321+
},
322+
});
323+
}
324+
325+
return new Promise((resolve, reject) => {
326+
const remainingGuildIds = new Set(guildIds);
327+
328+
const fetchedSoundboardSounds = new Collection();
329+
330+
const handler = (soundboardSounds, guild) => {
331+
timeout.refresh();
332+
333+
if (!remainingGuildIds.has(guild.id)) return;
334+
335+
fetchedSoundboardSounds.set(guild.id, soundboardSounds);
336+
337+
remainingGuildIds.delete(guild.id);
338+
339+
if (remainingGuildIds.size === 0) {
340+
clearTimeout(timeout);
341+
this.client.removeListener(Events.SoundboardSounds, handler);
342+
this.client.decrementMaxListeners();
343+
344+
resolve(fetchedSoundboardSounds);
345+
}
346+
};
347+
348+
const timeout = setTimeout(() => {
349+
this.client.removeListener(Events.SoundboardSounds, handler);
350+
this.client.decrementMaxListeners();
351+
reject(new DiscordjsError(ErrorCodes.GuildSoundboardSoundsTimeout));
352+
}, time).unref();
353+
354+
this.client.incrementMaxListeners();
355+
this.client.on(Events.SoundboardSounds, handler);
356+
});
357+
}
358+
285359
/**
286360
* Options used to set incident actions. Supplying `null` to any option will disable the action.
287361
* @typedef {Object} IncidentActionsEditOptions

0 commit comments

Comments
 (0)