From 09bed5f48b9ae5a2b98550430eb80739fde25623 Mon Sep 17 00:00:00 2001 From: Danial Raza Date: Wed, 23 Apr 2025 23:51:41 +0200 Subject: [PATCH 1/2] feat: add soundboard v14 --- packages/discord.js/package.json | 1 + packages/discord.js/src/client/Client.js | 14 ++ .../discord.js/src/client/actions/Action.js | 4 + .../src/client/actions/ActionsManager.js | 1 + .../actions/GuildSoundboardSoundDelete.js | 29 +++ .../GUILD_SOUNDBOARD_SOUNDS_UPDATE.js | 24 +++ .../handlers/GUILD_SOUNDBOARD_SOUND_CREATE.js | 18 ++ .../handlers/GUILD_SOUNDBOARD_SOUND_DELETE.js | 5 + .../handlers/GUILD_SOUNDBOARD_SOUND_UPDATE.js | 20 ++ .../websocket/handlers/SOUNDBOARD_SOUNDS.js | 24 +++ .../src/client/websocket/handlers/index.js | 5 + packages/discord.js/src/errors/ErrorCodes.js | 5 + packages/discord.js/src/errors/Messages.js | 3 + packages/discord.js/src/index.js | 2 + .../discord.js/src/managers/GuildManager.js | 76 ++++++- .../managers/GuildSoundboardSoundManager.js | 192 +++++++++++++++++ packages/discord.js/src/structures/Guild.js | 7 + .../src/structures/GuildAuditLogsEntry.js | 5 +- .../src/structures/SoundboardSound.js | 204 ++++++++++++++++++ packages/discord.js/src/structures/Sticker.js | 2 +- .../discord.js/src/structures/VoiceChannel.js | 22 +- .../src/structures/VoiceChannelEffect.js | 9 + packages/discord.js/src/util/DataResolver.js | 7 +- packages/discord.js/src/util/Events.js | 10 + packages/discord.js/src/util/Partials.js | 2 + packages/discord.js/typings/index.d.ts | 90 +++++++- packages/rest/__tests__/CDN.test.ts | 4 + packages/rest/src/lib/CDN.ts | 10 + pnpm-lock.yaml | 3 + 29 files changed, 788 insertions(+), 10 deletions(-) create mode 100644 packages/discord.js/src/client/actions/GuildSoundboardSoundDelete.js create mode 100644 packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUNDS_UPDATE.js create mode 100644 packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_CREATE.js create mode 100644 packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_DELETE.js create mode 100644 packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_UPDATE.js create mode 100644 packages/discord.js/src/client/websocket/handlers/SOUNDBOARD_SOUNDS.js create mode 100644 packages/discord.js/src/managers/GuildSoundboardSoundManager.js create mode 100644 packages/discord.js/src/structures/SoundboardSound.js diff --git a/packages/discord.js/package.json b/packages/discord.js/package.json index 8c4bef4e9a10..9d3e2b6171d6 100644 --- a/packages/discord.js/package.json +++ b/packages/discord.js/package.json @@ -75,6 +75,7 @@ "discord-api-types": "^0.37.119", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.1" }, diff --git a/packages/discord.js/src/client/Client.js b/packages/discord.js/src/client/Client.js index 31b76cce0885..749def48a2e8 100644 --- a/packages/discord.js/src/client/Client.js +++ b/packages/discord.js/src/client/Client.js @@ -18,6 +18,7 @@ const ClientPresence = require('../structures/ClientPresence'); const GuildPreview = require('../structures/GuildPreview'); const GuildTemplate = require('../structures/GuildTemplate'); const Invite = require('../structures/Invite'); +const { SoundboardSound } = require('../structures/SoundboardSound'); const { Sticker } = require('../structures/Sticker'); const StickerPack = require('../structures/StickerPack'); const VoiceRegion = require('../structures/VoiceRegion'); @@ -390,6 +391,19 @@ class Client extends BaseClient { return this.fetchStickerPacks(); } + /** + * Obtains the list of default soundboard sounds. + * @returns {Promise>} + * @example + * client.fetchDefaultSoundboardSounds() + * .then(sounds => console.log(`Available soundboard sounds are: ${sounds.map(sound => sound.name).join(', ')}`)) + * .catch(console.error); + */ + async fetchDefaultSoundboardSounds() { + const data = await this.rest.get(Routes.soundboardDefaultSounds()); + return new Collection(data.map(sound => [sound.sound_id, new SoundboardSound(this, sound)])); + } + /** * Obtains a guild preview from Discord, available for all guilds the bot is in and all Discoverable guilds. * @param {GuildResolvable} guild The guild to fetch the preview for diff --git a/packages/discord.js/src/client/actions/Action.js b/packages/discord.js/src/client/actions/Action.js index b5f1f756b4a2..d56be9cfae60 100644 --- a/packages/discord.js/src/client/actions/Action.js +++ b/packages/discord.js/src/client/actions/Action.js @@ -112,6 +112,10 @@ class GenericAction { return this.getPayload({ user_id: id }, manager, id, Partials.ThreadMember, false); } + getSoundboardSound(data, guild) { + return this.getPayload(data, guild.soundboardSounds, data.sound_id, Partials.SoundboardSound); + } + spreadInjectedData(data) { return Object.fromEntries(Object.getOwnPropertySymbols(data).map(symbol => [symbol, data[symbol]])); } diff --git a/packages/discord.js/src/client/actions/ActionsManager.js b/packages/discord.js/src/client/actions/ActionsManager.js index dd305a94804a..ab94293492c8 100644 --- a/packages/discord.js/src/client/actions/ActionsManager.js +++ b/packages/discord.js/src/client/actions/ActionsManager.js @@ -43,6 +43,7 @@ class ActionsManager { this.register(require('./GuildScheduledEventUpdate')); this.register(require('./GuildScheduledEventUserAdd')); this.register(require('./GuildScheduledEventUserRemove')); + this.register(require('./GuildSoundboardSoundDelete.js')); this.register(require('./GuildStickerCreate')); this.register(require('./GuildStickerDelete')); this.register(require('./GuildStickerUpdate')); diff --git a/packages/discord.js/src/client/actions/GuildSoundboardSoundDelete.js b/packages/discord.js/src/client/actions/GuildSoundboardSoundDelete.js new file mode 100644 index 000000000000..5065d6fcfcc2 --- /dev/null +++ b/packages/discord.js/src/client/actions/GuildSoundboardSoundDelete.js @@ -0,0 +1,29 @@ +'use strict'; + +const Action = require('./Action.js'); +const Events = require('../../util/Events.js'); + +class GuildSoundboardSoundDeleteAction extends Action { + handle(data) { + const guild = this.client.guilds.cache.get(data.guild_id); + + if (!guild) return {}; + + const soundboardSound = this.getSoundboardSound(data, guild); + + if (soundboardSound) { + guild.soundboardSounds.cache.delete(soundboardSound.soundId); + + /** + * Emitted whenever a soundboard sound is deleted in a guild. + * @event Client#guildSoundboardSoundDelete + * @param {SoundboardSound} soundboardSound The soundboard sound that was deleted + */ + this.client.emit(Events.GuildSoundboardSoundDelete, soundboardSound); + } + + return { soundboardSound }; + } +} + +module.exports = GuildSoundboardSoundDeleteAction; diff --git a/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUNDS_UPDATE.js b/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUNDS_UPDATE.js new file mode 100644 index 000000000000..4ebadff91ce7 --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUNDS_UPDATE.js @@ -0,0 +1,24 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Events = require('../../../util/Events.js'); + +module.exports = (client, { d: data }) => { + const guild = client.guilds.cache.get(data.guild_id); + + if (!guild) return; + + const soundboardSounds = new Collection(); + + for (const soundboardSound of data.soundboard_sounds) { + soundboardSounds.set(soundboardSound.sound_id, guild.soundboardSounds._add(soundboardSound)); + } + + /** + * Emitted whenever multiple guild soundboard sounds are updated. + * @event Client#guildSoundboardSoundsUpdate + * @param {Collection} soundboardSounds The updated soundboard sounds + * @param {Guild} guild The guild that the soundboard sounds are from + */ + client.emit(Events.GuildSoundboardSoundsUpdate, soundboardSounds, guild); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_CREATE.js b/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_CREATE.js new file mode 100644 index 000000000000..2208547f3ef5 --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_CREATE.js @@ -0,0 +1,18 @@ +'use strict'; + +const Events = require('../../../util/Events.js'); + +module.exports = (client, { d: data }) => { + const guild = client.guilds.cache.get(data.guild_id); + + if (!guild) return; + + const soundboardSound = guild.soundboardSounds._add(data); + + /** + * Emitted whenever a guild soundboard sound is created. + * @event Client#guildSoundboardSoundCreate + * @param {SoundboardSound} soundboardSound The created guild soundboard sound + */ + client.emit(Events.GuildSoundboardSoundCreate, soundboardSound); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_DELETE.js b/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_DELETE.js new file mode 100644 index 000000000000..3adafdba77d7 --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, { d: data }) => { + client.actions.GuildSoundboardSoundDelete.handle(data); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_UPDATE.js b/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_UPDATE.js new file mode 100644 index 000000000000..0b01b859a147 --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/GUILD_SOUNDBOARD_SOUND_UPDATE.js @@ -0,0 +1,20 @@ +'use strict'; + +const Events = require('../../../util/Events.js'); + +module.exports = (client, { d: data }) => { + const guild = client.guilds.cache.get(data.guild_id); + + if (!guild) return; + + const oldGuildSoundboardSound = guild.soundboardSounds.cache.get(data.sound_id)?._clone() ?? null; + const newGuildSoundboardSound = guild.soundboardSounds._add(data); + + /** + * Emitted whenever a guild soundboard sound is updated. + * @event Client#guildSoundboardSoundUpdate + * @param {?SoundboardSound} oldGuildSoundboardSound The guild soundboard sound before the update + * @param {SoundboardSound} newGuildSoundboardSound The guild soundboard sound after the update + */ + client.emit(Events.GuildSoundboardSoundUpdate, oldGuildSoundboardSound, newGuildSoundboardSound); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/SOUNDBOARD_SOUNDS.js b/packages/discord.js/src/client/websocket/handlers/SOUNDBOARD_SOUNDS.js new file mode 100644 index 000000000000..541e6e985915 --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/SOUNDBOARD_SOUNDS.js @@ -0,0 +1,24 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const Events = require('../../../util/Events.js'); + +module.exports = (client, { d: data }) => { + const guild = client.guilds.cache.get(data.guild_id); + + if (!guild) return; + + const soundboardSounds = new Collection(); + + for (const soundboardSound of data.soundboard_sounds) { + soundboardSounds.set(soundboardSound.sound_id, guild.soundboardSounds._add(soundboardSound)); + } + + /** + * Emitted whenever soundboard sounds are received (all soundboard sounds come from the same guild). + * @event Client#soundboardSounds + * @param {Collection} soundboardSounds The sounds received + * @param {Guild} guild The guild that the soundboard sounds are from + */ + client.emit(Events.SoundboardSounds, soundboardSounds, guild); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/index.js b/packages/discord.js/src/client/websocket/handlers/index.js index 79ae2b39d2e4..c85d10ffc07e 100644 --- a/packages/discord.js/src/client/websocket/handlers/index.js +++ b/packages/discord.js/src/client/websocket/handlers/index.js @@ -32,6 +32,10 @@ const handlers = Object.fromEntries([ ['GUILD_SCHEDULED_EVENT_UPDATE', require('./GUILD_SCHEDULED_EVENT_UPDATE')], ['GUILD_SCHEDULED_EVENT_USER_ADD', require('./GUILD_SCHEDULED_EVENT_USER_ADD')], ['GUILD_SCHEDULED_EVENT_USER_REMOVE', require('./GUILD_SCHEDULED_EVENT_USER_REMOVE')], + ['GUILD_SOUNDBOARD_SOUNDS_UPDATE', require('./GUILD_SOUNDBOARD_SOUNDS_UPDATE.js')], + ['GUILD_SOUNDBOARD_SOUND_CREATE', require('./GUILD_SOUNDBOARD_SOUND_CREATE.js')], + ['GUILD_SOUNDBOARD_SOUND_DELETE', require('./GUILD_SOUNDBOARD_SOUND_DELETE.js')], + ['GUILD_SOUNDBOARD_SOUND_UPDATE', require('./GUILD_SOUNDBOARD_SOUND_UPDATE.js')], ['GUILD_STICKERS_UPDATE', require('./GUILD_STICKERS_UPDATE')], ['GUILD_UPDATE', require('./GUILD_UPDATE')], ['INTERACTION_CREATE', require('./INTERACTION_CREATE')], @@ -50,6 +54,7 @@ const handlers = Object.fromEntries([ ['PRESENCE_UPDATE', require('./PRESENCE_UPDATE')], ['READY', require('./READY')], ['RESUMED', require('./RESUMED')], + ['SOUNDBOARD_SOUNDS', require('./SOUNDBOARD_SOUNDS.js')], ['STAGE_INSTANCE_CREATE', require('./STAGE_INSTANCE_CREATE')], ['STAGE_INSTANCE_DELETE', require('./STAGE_INSTANCE_DELETE')], ['STAGE_INSTANCE_UPDATE', require('./STAGE_INSTANCE_UPDATE')], diff --git a/packages/discord.js/src/errors/ErrorCodes.js b/packages/discord.js/src/errors/ErrorCodes.js index c1552392aa90..ec0c56386239 100644 --- a/packages/discord.js/src/errors/ErrorCodes.js +++ b/packages/discord.js/src/errors/ErrorCodes.js @@ -106,6 +106,7 @@ * @property {'GuildChannelUnowned'} GuildChannelUnowned * @property {'GuildOwned'} GuildOwned * @property {'GuildMembersTimeout'} GuildMembersTimeout + * @property {'GuildSoundboardSoundsTimeout'} GuildSoundboardSoundsTimeout * @property {'GuildUncachedMe'} GuildUncachedMe * @property {'ChannelNotCached'} ChannelNotCached * @property {'StageChannelResolve'} StageChannelResolve @@ -131,6 +132,8 @@ * @property {'MissingManageEmojisAndStickersPermission'} MissingManageEmojisAndStickersPermission * This property is deprecated. Use `MissingManageGuildExpressionsPermission` instead. * + + * @property {'NotGuildSoundboardSound'} NotGuildSoundboardSound * @property {'NotGuildSticker'} NotGuildSticker * @property {'ReactionResolveUser'} ReactionResolveUser @@ -266,6 +269,7 @@ const keys = [ 'GuildChannelUnowned', 'GuildOwned', 'GuildMembersTimeout', + 'GuildSoundboardSoundsTimeout', 'GuildUncachedMe', 'ChannelNotCached', 'StageChannelResolve', @@ -290,6 +294,7 @@ const keys = [ 'MissingManageGuildExpressionsPermission', 'MissingManageEmojisAndStickersPermission', + 'NotGuildSoundboardSound', 'NotGuildSticker', 'ReactionResolveUser', diff --git a/packages/discord.js/src/errors/Messages.js b/packages/discord.js/src/errors/Messages.js index c61ce7d3f59b..03e9a1c4f697 100644 --- a/packages/discord.js/src/errors/Messages.js +++ b/packages/discord.js/src/errors/Messages.js @@ -91,6 +91,7 @@ const Messages = { [DjsErrorCodes.GuildChannelUnowned]: "The fetched channel does not belong to this manager's guild.", [DjsErrorCodes.GuildOwned]: 'Guild is owned by the client.', [DjsErrorCodes.GuildMembersTimeout]: "Members didn't arrive in time.", + [DjsErrorCodes.GuildSoundboardSoundsTimeout]: "Soundboard sounds didn't arrive in time.", [DjsErrorCodes.GuildUncachedMe]: 'The client user as a member of this guild is uncached.', [DjsErrorCodes.ChannelNotCached]: 'Could not find the channel where this message came from in the cache!', [DjsErrorCodes.StageChannelResolve]: 'Could not resolve channel to a stage channel.', @@ -118,6 +119,8 @@ const Messages = { [DjsErrorCodes.MissingManageEmojisAndStickersPermission]: guild => `Client must have Manage Emojis and Stickers permission in guild ${guild} to see emoji authors.`, + [DjsErrorCodes.NotGuildSoundboardSound]: action => + `Soundboard sound is a default (non-guild) soundboard sound and can't be ${action}.`, [DjsErrorCodes.NotGuildSticker]: 'Sticker is a standard (non-guild) sticker and has no author.', [DjsErrorCodes.ReactionResolveUser]: "Couldn't resolve the user id to remove from the reaction.", diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index e94c93270f47..a2ba3f4367b8 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -75,6 +75,7 @@ exports.GuildMemberManager = require('./managers/GuildMemberManager'); exports.GuildMemberRoleManager = require('./managers/GuildMemberRoleManager'); exports.GuildMessageManager = require('./managers/GuildMessageManager'); exports.GuildScheduledEventManager = require('./managers/GuildScheduledEventManager'); +exports.GuildSoundboardSoundManager = require('./managers/GuildSoundboardSoundManager.js').GuildSoundboardSoundManager; exports.GuildStickerManager = require('./managers/GuildStickerManager'); exports.GuildTextThreadManager = require('./managers/GuildTextThreadManager'); exports.MessageManager = require('./managers/MessageManager'); @@ -202,6 +203,7 @@ exports.StringSelectMenuInteraction = require('./structures/StringSelectMenuInte exports.UserSelectMenuInteraction = require('./structures/UserSelectMenuInteraction'); exports.SelectMenuOptionBuilder = require('./structures/SelectMenuOptionBuilder'); exports.SKU = require('./structures/SKU').SKU; +exports.SoundboardSound = require('./structures/SoundboardSound.js').SoundboardSound; exports.StringSelectMenuOptionBuilder = require('./structures/StringSelectMenuOptionBuilder'); exports.StageChannel = require('./structures/StageChannel'); exports.StageInstance = require('./structures/StageInstance').StageInstance; diff --git a/packages/discord.js/src/managers/GuildManager.js b/packages/discord.js/src/managers/GuildManager.js index 08913886b6e6..39b9b94eb8cd 100644 --- a/packages/discord.js/src/managers/GuildManager.js +++ b/packages/discord.js/src/managers/GuildManager.js @@ -4,8 +4,9 @@ const process = require('node:process'); const { setTimeout, clearTimeout } = require('node:timers'); const { Collection } = require('@discordjs/collection'); const { makeURLSearchParams } = require('@discordjs/rest'); -const { Routes, RouteBases } = require('discord-api-types/v10'); +const { GatewayOpcodes, Routes, RouteBases } = require('discord-api-types/v10'); const CachedManager = require('./CachedManager'); +const { ErrorCodes, DiscordjsError } = require('../errors/index.js'); const ShardClientUtil = require('../sharding/ShardClientUtil'); const { Guild } = require('../structures/Guild'); const GuildChannel = require('../structures/GuildChannel'); @@ -282,6 +283,79 @@ class GuildManager extends CachedManager { return data.reduce((coll, guild) => coll.set(guild.id, new OAuth2Guild(this.client, guild)), new Collection()); } + /** + * @typedef {Object} FetchSoundboardSoundsOptions + * @param {Snowflake[]} guildIds The ids of the guilds to fetch soundboard sounds for + * @param {number} [time=10_000] The timeout for receipt of the soundboard sounds + */ + + /** + * Fetches soundboard sounds for the specified guilds. + * @param {FetchSoundboardSoundsOptions} options The options for fetching soundboard sounds + * @returns {Promise>>} + * @example + * // Fetch soundboard sounds for multiple guilds + * const soundboardSounds = await client.guilds.fetchSoundboardSounds({ + * guildIds: ['123456789012345678', '987654321098765432'], + * }) + * + * console.log(soundboardSounds.get('123456789012345678')); + */ + async fetchSoundboardSounds({ guildIds, time = 10_000 }) { + const shardCount = await this.client.options.shardCount; + const shardIds = new Map(); + + for (const guildId of guildIds) { + const shardId = ShardClientUtil.shardIdForGuildId(guildId, shardCount); + const group = shardIds.get(shardId); + + if (group) group.push(guildId); + else shardIds.set(shardId, [guildId]); + } + + for (const [shardId, shardGuildIds] of shardIds) { + this.client.ws.shards.get(shardId).send({ + op: GatewayOpcodes.RequestSoundboardSounds, + d: { + guild_ids: shardGuildIds, + }, + }); + } + + return new Promise((resolve, reject) => { + const remainingGuildIds = new Set(guildIds); + + const fetchedSoundboardSounds = new Collection(); + + const handler = (soundboardSounds, guild) => { + timeout.refresh(); + + if (!remainingGuildIds.has(guild.id)) return; + + fetchedSoundboardSounds.set(guild.id, soundboardSounds); + + remainingGuildIds.delete(guild.id); + + if (remainingGuildIds.size === 0) { + clearTimeout(timeout); + this.client.removeListener(Events.SoundboardSounds, handler); + this.client.decrementMaxListeners(); + + resolve(fetchedSoundboardSounds); + } + }; + + const timeout = setTimeout(() => { + this.client.removeListener(Events.SoundboardSounds, handler); + this.client.decrementMaxListeners(); + reject(new DiscordjsError(ErrorCodes.GuildSoundboardSoundsTimeout)); + }, time).unref(); + + this.client.incrementMaxListeners(); + this.client.on(Events.SoundboardSounds, handler); + }); + } + /** * Options used to set incident actions. Supplying `null` to any option will disable the action. * @typedef {Object} IncidentActionsEditOptions diff --git a/packages/discord.js/src/managers/GuildSoundboardSoundManager.js b/packages/discord.js/src/managers/GuildSoundboardSoundManager.js new file mode 100644 index 000000000000..94eb1088c85c --- /dev/null +++ b/packages/discord.js/src/managers/GuildSoundboardSoundManager.js @@ -0,0 +1,192 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { lazy } = require('@discordjs/util'); +const { Routes } = require('discord-api-types/v10'); +const { CachedManager } = require('./CachedManager.js'); +const { DiscordjsTypeError, ErrorCodes } = require('../errors/index.js'); +const { SoundboardSound } = require('../structures/SoundboardSound.js'); +const { resolveBase64, resolveFile } = require('../util/DataResolver.js'); + +const fileTypeMime = lazy(() => require('magic-bytes.js').filetypemime); + +/** + * Manages API methods for Soundboard Sounds and stores their cache. + * @extends {CachedManager} + */ +class GuildSoundboardSoundManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, SoundboardSound, iterable); + + /** + * The guild this manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The cache of Soundboard Sounds + * @type {Collection} + * @name GuildSoundboardSoundManager#cache + */ + + _add(data, cache) { + return super._add(data, cache, { extras: [this.guild], id: data.sound_id }); + } + + /** + * Data that resolves to give a SoundboardSound object. This can be: + * * A SoundboardSound object + * * A Snowflake + * @typedef {SoundboardSound|Snowflake} SoundboardSoundResolvable + */ + + /** + * Resolves a SoundboardSoundResolvable to a SoundboardSound object. + * @method resolve + * @memberof GuildSoundboardSoundManager + * @instance + * @param {SoundboardSoundResolvable} soundboardSound The SoundboardSound resolvable to identify + * @returns {?SoundboardSound} + */ + + /** + * Resolves a {@link SoundboardSoundResolvable} to a {@link SoundboardSound} id. + * @param {SoundboardSoundResolvable} soundboardSound The soundboard sound resolvable to resolve + * @returns {?Snowflake} + */ + resolveId(soundboardSound) { + if (soundboardSound instanceof this.holds) return soundboardSound.soundId; + if (typeof soundboardSound === 'string') return soundboardSound; + return null; + } + + /** + * Options used to create a soundboard sound in a guild. + * @typedef {Object} GuildSoundboardSoundCreateOptions + * @property {BufferResolvable|Stream} file The file for the soundboard sound + * @property {string} name The name for the soundboard sound + * @property {string} [contentType] The content type for the soundboard sound file + * @property {number} [volume] The volume (a double) for the soundboard sound, from 0 (inclusive) to 1. Defaults to 1 + * @property {Snowflake} [emojiId] The emoji id for the soundboard sound + * @property {string} [emojiName] The emoji name for the soundboard sound + * @property {string} [reason] The reason for creating the soundboard sound + */ + + /** + * Creates a new guild soundboard sound. + * @param {GuildSoundboardSoundCreateOptions} options Options for creating a guild soundboard sound + * @returns {Promise} The created soundboard sound + * @example + * // Create a new soundboard sound from a file on your computer + * guild.soundboardSounds.create({ file: './sound.mp3', name: 'sound' }) + * .then(sound => console.log(`Created new soundboard sound with name ${sound.name}!`)) + * .catch(console.error); + */ + async create({ contentType, emojiId, emojiName, file, name, reason, volume }) { + const resolvedFile = await resolveFile(file); + + const resolvedContentType = contentType ?? resolvedFile.contentType ?? fileTypeMime()(resolvedFile.data)[0]; + + const sound = resolveBase64(resolvedFile.data, resolvedContentType); + + const body = { emoji_id: emojiId, emoji_name: emojiName, name, sound, volume }; + + const soundboardSound = await this.client.rest.post(Routes.guildSoundboardSounds(this.guild.id), { + body, + reason, + }); + + return this._add(soundboardSound); + } + + /** + * Data for editing a soundboard sound. + * @typedef {Object} GuildSoundboardSoundEditOptions + * @property {string} [name] The name of the soundboard sound + * @property {?number} [volume] The volume of the soundboard sound, from 0 to 1 + * @property {?Snowflake} [emojiId] The emoji id of the soundboard sound + * @property {?string} [emojiName] The emoji name of the soundboard sound + * @property {string} [reason] The reason for editing the soundboard sound + */ + + /** + * Edits a soundboard sound. + * @param {SoundboardSoundResolvable} soundboardSound The soundboard sound to edit + * @param {GuildSoundboardSoundEditOptions} [options={}] The new data for the soundboard sound + * @returns {Promise} + */ + async edit(soundboardSound, options = {}) { + const soundId = this.resolveId(soundboardSound); + + if (!soundId) throw new DiscordjsTypeError(ErrorCodes.InvalidType, 'soundboardSound', 'SoundboardSoundResolvable'); + + const { emojiId, emojiName, name, reason, volume } = options; + + const body = { emoji_id: emojiId, emoji_name: emojiName, name, volume }; + + const data = await this.client.rest.patch(Routes.guildSoundboardSound(this.guild.id, soundId), { + body, + reason, + }); + + const existing = this.cache.get(soundId); + + if (existing) { + const clone = existing._clone(); + + clone._patch(data); + return clone; + } + + return this._add(data); + } + + /** + * Deletes a soundboard sound. + * @param {SoundboardSoundResolvable} soundboardSound The soundboard sound to delete + * @param {string} [reason] Reason for deleting this soundboard sound + * @returns {Promise} + */ + async delete(soundboardSound, reason) { + const soundId = this.resolveId(soundboardSound); + + if (!soundId) throw new DiscordjsTypeError(ErrorCodes.InvalidType, 'soundboardSound', 'SoundboardSoundResolvable'); + + await this.client.rest.delete(Routes.guildSoundboardSound(this.guild.id, soundId), { reason }); + } + + /** + * Obtains one or more soundboard sounds from Discord, or the soundboard sound cache if they're already available. + * @param {Snowflake} [id] The soundboard sound's id + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise>} + * @example + * // Fetch all soundboard sounds from the guild + * guild.soundboardSounds.fetch() + * .then(sounds => console.log(`There are ${sounds.size} soundboard sounds.`)) + * .catch(console.error); + * @example + * // Fetch a single soundboard sound + * guild.soundboardSounds.fetch('222078108977594368') + * .then(sound => console.log(`The soundboard sound name is: ${sound.name}`)) + * .catch(console.error); + */ + async fetch(id, { cache = true, force = false } = {}) { + if (id) { + if (!force) { + const existing = this.cache.get(id); + if (existing) return existing; + } + + const sound = await this.client.rest.get(Routes.guildSoundboardSound(this.guild.id, id)); + return this._add(sound, cache); + } + + const data = await this.client.rest.get(Routes.guildSoundboardSounds(this.guild.id)); + return new Collection(data.map(sound => [sound.sound_id, this._add(sound, cache)])); + } +} + +exports.GuildSoundboardSoundManager = GuildSoundboardSoundManager; diff --git a/packages/discord.js/src/structures/Guild.js b/packages/discord.js/src/structures/Guild.js index 7c755a539e3e..7d4a900bf9d6 100644 --- a/packages/discord.js/src/structures/Guild.js +++ b/packages/discord.js/src/structures/Guild.js @@ -21,6 +21,7 @@ const GuildEmojiManager = require('../managers/GuildEmojiManager'); const GuildInviteManager = require('../managers/GuildInviteManager'); const GuildMemberManager = require('../managers/GuildMemberManager'); const GuildScheduledEventManager = require('../managers/GuildScheduledEventManager'); +const { GuildSoundboardSoundManager } = require('../managers/GuildSoundboardSoundManager'); const GuildStickerManager = require('../managers/GuildStickerManager'); const PresenceManager = require('../managers/PresenceManager'); const RoleManager = require('../managers/RoleManager'); @@ -108,6 +109,12 @@ class Guild extends AnonymousGuild { */ this.autoModerationRules = new AutoModerationRuleManager(this); + /** + * A manager of the soundboard sounds of this guild. + * @type {GuildSoundboardSoundManager} + */ + this.soundboardSounds = new GuildSoundboardSoundManager(this); + if (!data) return; if (data.unavailable) { /** diff --git a/packages/discord.js/src/structures/GuildAuditLogsEntry.js b/packages/discord.js/src/structures/GuildAuditLogsEntry.js index 53c5b3d3fcd3..c082191516bc 100644 --- a/packages/discord.js/src/structures/GuildAuditLogsEntry.js +++ b/packages/discord.js/src/structures/GuildAuditLogsEntry.js @@ -53,10 +53,11 @@ const Targets = { * * An application command * * An auto moderation rule * * A guild onboarding prompt + * * A soundboard sound * * An object with an id key if target was deleted or fake entity * * An object where the keys represent either the new value or the old value * @typedef {?(Object|Guild|BaseChannel|User|Role|Invite|Webhook|GuildEmoji|Message|Integration|StageInstance|Sticker| - * GuildScheduledEvent|ApplicationCommand|AutoModerationRule|GuildOnboardingPrompt)} AuditLogEntryTarget + * GuildScheduledEvent|ApplicationCommand|AutoModerationRule|GuildOnboardingPrompt|SoundboardSound)} AuditLogEntryTarget */ /** @@ -367,6 +368,8 @@ class GuildAuditLogsEntry { : changesReduce(this.changes, { id: data.target_id }); } else if (targetType === Targets.GuildOnboarding) { this.target = changesReduce(this.changes, { id: data.target_id }); + } else if (targetType === Targets.SoundboardSound) { + this.target = guild.soundboardSounds.cache.get(data.target_id) ?? { id: data.target_id }; } else if (data.target_id) { this.target = guild[`${targetType.toLowerCase()}s`]?.cache.get(data.target_id) ?? { id: data.target_id }; } diff --git a/packages/discord.js/src/structures/SoundboardSound.js b/packages/discord.js/src/structures/SoundboardSound.js new file mode 100644 index 000000000000..f05e3a19335b --- /dev/null +++ b/packages/discord.js/src/structures/SoundboardSound.js @@ -0,0 +1,204 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const Base = require('./Base.js'); +const { Emoji } = require('./Emoji.js'); +const { DiscordjsError, ErrorCodes } = require('../errors/index.js'); + +/** + * Represents a soundboard sound. + * @extends {Base} + */ +class SoundboardSound extends Base { + constructor(client, data) { + super(client); + + /** + * The id of this soundboard sound + * @type {Snowflake|string} + */ + this.soundId = data.sound_id; + + this._patch(data); + } + + _patch(data) { + if ('available' in data) { + /** + * Whether this soundboard sound is available + * @type {?boolean} + */ + this.available = data.available; + } else { + this.available ??= null; + } + + if ('name' in data) { + /** + * The name of this soundboard sound + * @type {?string} + */ + this.name = data.name; + } else { + this.name ??= null; + } + + if ('volume' in data) { + /** + * The volume (a double) of this soundboard sound, from 0 to 1 + * @type {?number} + */ + this.volume = data.volume; + } else { + this.volume ??= null; + } + + if ('emoji_id' in data) { + /** + * The raw emoji data of this soundboard sound + * @type {?Object} + * @private + */ + this._emoji = { + id: data.emoji_id, + name: data.emoji_name, + }; + } else { + this._emoji ??= null; + } + + if ('guild_id' in data) { + /** + * The guild id of this soundboard sound + * @type {?Snowflake} + */ + this.guildId = data.guild_id; + } else { + this.guildId ??= null; + } + + if ('user' in data) { + /** + * The user who created this soundboard sound + * @type {?User} + */ + this.user = this.client.users._add(data.user); + } else { + this.user ??= null; + } + } + + /** + * The timestamp this soundboard sound was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.soundId); + } + + /** + * The time this soundboard sound was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The emoji of this soundboard sound + * @type {?Emoji} + * @readonly + */ + get emoji() { + if (!this._emoji) return null; + + return this.guild?.emojis.cache.get(this._emoji.id) ?? new Emoji(this.client, this._emoji); + } + + /** + * The guild this soundboard sound is part of + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.resolve(this.guildId); + } + + /** + * A link to this soundboard sound + * @type {string} + * @readonly + */ + get url() { + return this.client.rest.cdn.soundboardSound(this.soundId); + } + + /** + * Edits this soundboard sound. + * @param {GuildSoundboardSoundEditOptions} options The options to provide + * @returns {Promise} + * @example + * // Update the name of a soundboard sound + * soundboardSound.edit({ name: 'new name' }) + * .then(sound => console.log(`Updated the name of the soundboard sound to ${sound.name}`)) + * .catch(console.error); + */ + async edit(options) { + if (!this.guildId) throw new DiscordjsError(ErrorCodes.NotGuildSoundboardSound, 'edited'); + + return this.guild.soundboardSounds.edit(this, options); + } + + /** + * Deletes this soundboard sound. + * @param {string} [reason] Reason for deleting this soundboard sound + * @returns {Promise} + * @example + * // Delete a soundboard sound + * soundboardSound.delete() + * .then(sound => console.log(`Deleted soundboard sound ${sound.name}`)) + * .catch(console.error); + */ + async delete(reason) { + if (!this.guildId) throw new DiscordjsError(ErrorCodes.NotGuildSoundboardSound, 'deleted'); + + await this.guild.soundboardSounds.delete(this, reason); + + return this; + } + + /** + * Whether this soundboard sound is the same as another one. + * @param {SoundboardSound|APISoundboardSound} other The soundboard sound to compare it to + * @returns {boolean} + */ + equals(other) { + if (other instanceof SoundboardSound) { + return ( + this.soundId === other.soundId && + this.available === other.available && + this.name === other.name && + this.volume === other.volume && + this.emojiId === other.emojiId && + this.emojiName === other.emojiName && + this.guildId === other.guildId && + this.user?.id === other.user?.id + ); + } + + return ( + this.soundId === other.sound_id && + this.available === other.available && + this.name === other.name && + this.volume === other.volume && + this.emojiId === other.emoji_id && + this.emojiName === other.emoji_name && + this.guildId === other.guild_id && + this.user?.id === other.user?.id + ); + } +} + +exports.SoundboardSound = SoundboardSound; diff --git a/packages/discord.js/src/structures/Sticker.js b/packages/discord.js/src/structures/Sticker.js index 6ff157f9e2d2..aefbc1fc388a 100644 --- a/packages/discord.js/src/structures/Sticker.js +++ b/packages/discord.js/src/structures/Sticker.js @@ -225,7 +225,7 @@ class Sticker extends Base { * @returns {Promise} * @param {string} [reason] Reason for deleting this sticker * @example - * // Delete a message + * // Delete a sticker * sticker.delete() * .then(sticker => console.log(`Deleted sticker ${sticker.name}`)) * .catch(console.error); diff --git a/packages/discord.js/src/structures/VoiceChannel.js b/packages/discord.js/src/structures/VoiceChannel.js index d4f33ca1224b..f6f74376da77 100644 --- a/packages/discord.js/src/structures/VoiceChannel.js +++ b/packages/discord.js/src/structures/VoiceChannel.js @@ -1,6 +1,6 @@ 'use strict'; -const { PermissionFlagsBits } = require('discord-api-types/v10'); +const { PermissionFlagsBits, Routes } = require('discord-api-types/v10'); const BaseGuildVoiceChannel = require('./BaseGuildVoiceChannel'); /** @@ -35,6 +35,26 @@ class VoiceChannel extends BaseGuildVoiceChannel { permissions.has(PermissionFlagsBits.Speak, false) ); } + + /** + * @typedef {Object} SendSoundboardSoundOptions + * @property {string} soundId The id of the soundboard sound to send + * @property {string} [guildId] The id of the guild the soundboard sound is a part of + */ + + /** + * Send a soundboard sound to a voice channel the user is connected to. + * @param {SoundboardSound|SendSoundboardSoundOptions} sound The sound to send + * @returns {Promise} + */ + async sendSoundboardSound(sound) { + await this.client.rest.post(Routes.sendSoundboardSound(this.id), { + body: { + sound_id: sound.soundId, + source_guild_id: sound.guildId ?? undefined, + }, + }); + } } /** diff --git a/packages/discord.js/src/structures/VoiceChannelEffect.js b/packages/discord.js/src/structures/VoiceChannelEffect.js index ee42ea6f72b9..3bd28b91d705 100644 --- a/packages/discord.js/src/structures/VoiceChannelEffect.js +++ b/packages/discord.js/src/structures/VoiceChannelEffect.js @@ -64,6 +64,15 @@ class VoiceChannelEffect { get channel() { return this.guild.channels.cache.get(this.channelId) ?? null; } + + /** + * The soundboard sound for soundboard effects. + * @type {?SoundboardSound} + * @readonly + */ + get soundboardSound() { + return this.guild.soundboardSounds.cache.get(this.soundId) ?? null; + } } module.exports = VoiceChannelEffect; diff --git a/packages/discord.js/src/util/DataResolver.js b/packages/discord.js/src/util/DataResolver.js index 681a0bf91957..1c1145b53027 100644 --- a/packages/discord.js/src/util/DataResolver.js +++ b/packages/discord.js/src/util/DataResolver.js @@ -113,13 +113,14 @@ async function resolveFile(resource) { */ /** - * Resolves a Base64Resolvable to a Base 64 image. + * Resolves a Base64Resolvable to a Base 64 string. * @param {Base64Resolvable} data The base 64 resolvable you want to resolve + * @param {string} [contentType='image/jpg'] The content type of the data * @returns {?string} * @private */ -function resolveBase64(data) { - if (Buffer.isBuffer(data)) return `data:image/jpg;base64,${data.toString('base64')}`; +function resolveBase64(data, contentType = 'image/jpg') { + if (Buffer.isBuffer(data)) return `data:${contentType};base64,${data.toString('base64')}`; return data; } diff --git a/packages/discord.js/src/util/Events.js b/packages/discord.js/src/util/Events.js index 0304c342a8cb..0696b95f7f35 100644 --- a/packages/discord.js/src/util/Events.js +++ b/packages/discord.js/src/util/Events.js @@ -41,6 +41,10 @@ * @property {string} GuildScheduledEventUpdate guildScheduledEventUpdate * @property {string} GuildScheduledEventUserAdd guildScheduledEventUserAdd * @property {string} GuildScheduledEventUserRemove guildScheduledEventUserRemove + * @property {string} GuildSoundboardSoundCreate guildSoundboardSoundCreate + * @property {string} GuildSoundboardSoundDelete guildSoundboardSoundDelete + * @property {string} GuildSoundboardSoundsUpdate guildSoundboardSoundsUpdate + * @property {string} GuildSoundboardSoundUpdate guildSoundboardSoundUpdate * @property {string} GuildStickerCreate stickerCreate * @property {string} GuildStickerDelete stickerDelete * @property {string} GuildStickerUpdate stickerUpdate @@ -61,6 +65,7 @@ * @property {string} MessageReactionRemoveEmoji messageReactionRemoveEmoji * @property {string} MessageUpdate messageUpdate * @property {string} PresenceUpdate presenceUpdate + * @property {string} SoundboardSounds soundboardSounds * @property {string} ShardDisconnect shardDisconnect * @property {string} ShardError shardError * @property {string} ShardReady shardReady @@ -132,6 +137,10 @@ module.exports = { GuildScheduledEventUpdate: 'guildScheduledEventUpdate', GuildScheduledEventUserAdd: 'guildScheduledEventUserAdd', GuildScheduledEventUserRemove: 'guildScheduledEventUserRemove', + GuildSoundboardSoundCreate: 'guildSoundboardSoundCreate', + GuildSoundboardSoundDelete: 'guildSoundboardSoundDelete', + GuildSoundboardSoundsUpdate: 'guildSoundboardSoundsUpdate', + GuildSoundboardSoundUpdate: 'guildSoundboardSoundUpdate', GuildStickerCreate: 'stickerCreate', GuildStickerDelete: 'stickerDelete', GuildStickerUpdate: 'stickerUpdate', @@ -152,6 +161,7 @@ module.exports = { MessageReactionRemoveEmoji: 'messageReactionRemoveEmoji', MessageUpdate: 'messageUpdate', PresenceUpdate: 'presenceUpdate', + SoundboardSounds: 'soundboardSounds', Raw: 'raw', ShardDisconnect: 'shardDisconnect', ShardError: 'shardError', diff --git a/packages/discord.js/src/util/Partials.js b/packages/discord.js/src/util/Partials.js index 31d92231c1d6..c4bebb89df77 100644 --- a/packages/discord.js/src/util/Partials.js +++ b/packages/discord.js/src/util/Partials.js @@ -26,6 +26,7 @@ const { createEnum } = require('./Enums'); * @property {number} Reaction The partial to receive uncached reactions. * @property {number} GuildScheduledEvent The partial to receive uncached guild scheduled events. * @property {number} ThreadMember The partial to receive uncached thread members. + * @property {number} SoundboardSound The partial to receive uncached soundboard sounds. */ // JSDoc for IntelliSense purposes @@ -41,4 +42,5 @@ module.exports = createEnum([ 'Reaction', 'GuildScheduledEvent', 'ThreadMember', + 'SoundboardSound', ]); diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 6491c79831cf..abe1c6f5dc79 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -191,8 +191,6 @@ import { SubscriptionStatus, ApplicationWebhookEventStatus, ApplicationWebhookEventType, - GatewaySendPayload, - GatewayDispatchPayload, RESTPostAPIInteractionCallbackWithResponseResult, RESTAPIInteractionCallbackObject, RESTAPIInteractionCallbackResourceObject, @@ -202,6 +200,7 @@ import { GatewayVoiceChannelEffectSendDispatchData, APIChatInputApplicationCommandInteractionData, APIContextMenuInteractionData, + APISoundboardSound, } from 'discord-api-types/v10'; import { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; @@ -1518,6 +1517,7 @@ export class Guild extends AnonymousGuild { public scheduledEvents: GuildScheduledEventManager; public get shard(): WebSocketShard; public shardId: number; + public soundboardSounds: GuildSoundboardSoundManager; public stageInstances: StageInstanceManager; public stickers: GuildStickerManager; public incidentsData: IncidentActions | null; @@ -3764,9 +3764,15 @@ export type ComponentData = | ModalActionRowComponentData | ActionRowData; +export interface SendSoundboardSoundOptions { + soundId: Snowflake; + guildId?: Snowflake; +} + export class VoiceChannel extends BaseGuildVoiceChannel { public get speakable(): boolean; public type: ChannelType.GuildVoice; + public sendSoundboardSound(sound: SoundboardSound | SendSoundboardSoundOptions): Promise; } export class VoiceChannelEffect { @@ -3780,6 +3786,7 @@ export class VoiceChannelEffect { public soundId: Snowflake | number | null; public soundVolume: number | null; public get channel(): VoiceChannel | null; + public get soundboardSound(): GuildSoundboardSound | null; } export class VoiceRegion { @@ -3966,6 +3973,30 @@ export class WidgetMember extends Base { public activity: WidgetActivity | null; } +export type SoundboardSoundResolvable = SoundboardSound | Snowflake | string; + +export class SoundboardSound extends Base { + private constructor(client: Client, data: APISoundboardSound); + public name: string; + public soundId: Snowflake | string; + public volume: number; + private _emoji: Omit | null; + public guildId: Snowflake | null; + public available: boolean; + public user: User | null; + public get createdAt(): Date; + public get createdTimestamp(): number; + public get emoji(): Emoji | null; + public get guild(): Guild | null; + public get url(): string; + public edit(options?: GuildSoundboardSoundEditOptions): Promise; + public delete(reason?: string): Promise; + public equals(other: SoundboardSound | APISoundboardSound): boolean; +} + +export type DefaultSoundboardSound = SoundboardSound & { get guild(): null; guildId: null; soundId: string }; +export type GuildSoundboardSound = SoundboardSound & { get guild(): Guild; guildId: Snowflake; soundId: Snowflake }; + export class WelcomeChannel extends Base { private constructor(guild: Guild, data: RawWelcomeChannelData); private _emoji: Omit; @@ -4151,6 +4182,7 @@ export enum DiscordjsErrorCodes { GuildChannelUnowned = 'GuildChannelUnowned', GuildOwned = 'GuildOwned', GuildMembersTimeout = 'GuildMembersTimeout', + GuildSoundboardSoundsTimeout = 'GuildSoundboardSoundsTimeout', GuildUncachedMe = 'GuildUncachedMe', ChannelNotCached = 'ChannelNotCached', StageChannelResolve = 'StageChannelResolve', @@ -4176,6 +4208,7 @@ export enum DiscordjsErrorCodes { /** @deprecated Use {@link DiscordjsErrorCodes.MissingManageGuildExpressionsPermission} instead. */ MissingManageEmojisAndStickersPermission = 'MissingManageEmojisAndStickersPermission', + NotGuildSoundboardSound = 'NotGuildSoundboardSound', NotGuildSticker = 'NotGuildSticker', ReactionResolveUser = 'ReactionResolveUser', @@ -4550,11 +4583,19 @@ export class GuildEmojiRoleManager extends DataManager; } +export interface FetchSoundboardSoundsOptions { + guildIds: readonly Snowflake[]; + time?: number; +} + export class GuildManager extends CachedManager { private constructor(client: Client, iterable?: Iterable); public create(options: GuildCreateOptions): Promise; public fetch(options: Snowflake | FetchGuildOptions): Promise; public fetch(options?: FetchGuildsOptions): Promise>; + public fetchSoundboardSounds( + options: FetchSoundboardSoundsOptions, + ): Promise>>; public setIncidentActions( guild: GuildResolvable, incidentActions: IncidentActionsEditOptions, @@ -4646,6 +4687,36 @@ export class GuildScheduledEventManager extends CachedManager< ): Promise>; } +export interface GuildSoundboardSoundCreateOptions { + file: BufferResolvable | Stream; + name: string; + contentType?: string; + volume?: number; + emojiId?: Snowflake; + emojiName?: string; + reason?: string; +} + +export interface GuildSoundboardSoundEditOptions { + name?: string; + volume?: number | null; + emojiId?: Snowflake | null; + emojiName?: string | null; +} + +export class GuildSoundboardSoundManager extends CachedManager { + private constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public create(options: GuildSoundboardSoundCreateOptions): Promise; + public edit( + soundboardSound: SoundboardSoundResolvable, + options: GuildSoundboardSoundEditOptions, + ): Promise; + public delete(soundboardSound: SoundboardSoundResolvable): Promise; + public fetch(id: Snowflake, options?: BaseFetchOptions): Promise; + public fetch(options?: BaseFetchOptions): Promise>; +} + export class GuildStickerManager extends CachedManager { private constructor(guild: Guild, iterable?: Iterable); public guild: Guild; @@ -4970,7 +5041,8 @@ export type AllowedPartial = | Message | MessageReaction | GuildScheduledEvent - | ThreadMember; + | ThreadMember + | SoundboardSound; export type AllowedThreadTypeForNewsChannel = ChannelType.AnnouncementThread; @@ -5522,6 +5594,9 @@ export interface ClientEvents { guildMembersChunk: [members: ReadonlyCollection, guild: Guild, data: GuildMembersChunk]; guildMemberUpdate: [oldMember: GuildMember | PartialGuildMember, newMember: GuildMember]; guildUpdate: [oldGuild: Guild, newGuild: Guild]; + guildSoundboardSoundCreate: [soundboardSound: SoundboardSound]; + guildSoundboardSoundDelete: [soundboardSound: SoundboardSound | PartialSoundboardSound]; + guildSoundboardSoundUpdate: [oldSoundboardSound: SoundboardSound | null, newSoundboardSound: SoundboardSound]; inviteCreate: [invite: Invite]; inviteDelete: [invite: Invite]; messageCreate: [message: OmitPartialGroupDMChannel]; @@ -5597,6 +5672,7 @@ export interface ClientEvents { guildScheduledEventDelete: [guildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent]; guildScheduledEventUserAdd: [guildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent, user: User]; guildScheduledEventUserRemove: [guildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent, user: User]; + soundboardSounds: [soundboardSounds: ReadonlyCollection, guild: Guild]; } export interface ClientFetchInviteOptions { @@ -5815,6 +5891,11 @@ export enum Events { GuildScheduledEventDelete = 'guildScheduledEventDelete', GuildScheduledEventUserAdd = 'guildScheduledEventUserAdd', GuildScheduledEventUserRemove = 'guildScheduledEventUserRemove', + GuildSoundboardSoundCreate = 'guildSoundboardSoundCreate', + GuildSoundboardSoundDelete = 'guildSoundboardSoundDelete', + GuildSoundboardSoundUpdate = 'guildSoundboardSoundUpdate', + GuildSoundboardSoundsUpdate = 'guildSoundboardSoundsUpdate', + SoundboardSounds = 'soundboardSounds', } export enum ShardEvents { @@ -6985,6 +7066,8 @@ export interface PartialGuildScheduledEvent export interface PartialThreadMember extends Partialize {} +export interface PartialSoundboardSound extends Partialize {} + export interface PartialOverwriteData { id: Snowflake | number; type?: OverwriteType; @@ -7004,6 +7087,7 @@ export enum Partials { Reaction, GuildScheduledEvent, ThreadMember, + SoundboardSound, } export interface PartialUser extends Partialize {} diff --git a/packages/rest/__tests__/CDN.test.ts b/packages/rest/__tests__/CDN.test.ts index 39f83c7a5b1f..1fd29fd6f3cc 100644 --- a/packages/rest/__tests__/CDN.test.ts +++ b/packages/rest/__tests__/CDN.test.ts @@ -130,6 +130,10 @@ test('teamIcon default', () => { expect(cdn.teamIcon(id, hash)).toEqual(`${baseCDN}/team-icons/${id}/${hash}.webp`); }); +test('soundboardSound', () => { + expect(cdn.soundboardSound(id)).toEqual(`${baseCDN}/soundboard-sounds/${id}`); +}); + test('makeURL throws on invalid size', () => { // @ts-expect-error: Invalid size expect(() => cdn.avatar(id, animatedHash, { size: 5 })).toThrow(RangeError); diff --git a/packages/rest/src/lib/CDN.ts b/packages/rest/src/lib/CDN.ts index af3734cc9a49..eefc5ceade0e 100644 --- a/packages/rest/src/lib/CDN.ts +++ b/packages/rest/src/lib/CDN.ts @@ -1,4 +1,5 @@ /* eslint-disable jsdoc/check-param-names */ +import { CDNRoutes } from 'discord-api-types/v10'; import { ALLOWED_EXTENSIONS, ALLOWED_SIZES, @@ -343,6 +344,15 @@ export class CDN { return this.makeURL(`/guild-events/${scheduledEventId}/${coverHash}`, options); } + /** + * Generates a URL for a soundboard sound. + * + * @param soundId - The soundboard sound id + */ + public soundboardSound(soundId: string): string { + return `${this.cdn}${CDNRoutes.soundboardSound(soundId)}`; + } + /** * Constructs the URL for the resource, checking whether or not `hash` starts with `a_` if `dynamic` is set to `true`. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c3cacd25632..7171fcdcaab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -949,6 +949,9 @@ importers: lodash.snakecase: specifier: 4.1.1 version: 4.1.1 + magic-bytes.js: + specifier: ^1.10.0 + version: 1.10.0 tslib: specifier: ^2.6.3 version: 2.6.3 From a1c048dfb5e530ec92584a061e9d9008004034aa Mon Sep 17 00:00:00 2001 From: Danial Raza Date: Fri, 25 Apr 2025 22:35:40 +0200 Subject: [PATCH 2/2] refactor: remove unnecessary `await` --- packages/discord.js/src/managers/GuildManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/discord.js/src/managers/GuildManager.js b/packages/discord.js/src/managers/GuildManager.js index 39b9b94eb8cd..3aa68dae60c0 100644 --- a/packages/discord.js/src/managers/GuildManager.js +++ b/packages/discord.js/src/managers/GuildManager.js @@ -302,7 +302,7 @@ class GuildManager extends CachedManager { * console.log(soundboardSounds.get('123456789012345678')); */ async fetchSoundboardSounds({ guildIds, time = 10_000 }) { - const shardCount = await this.client.options.shardCount; + const shardCount = this.client.options.shardCount; const shardIds = new Map(); for (const guildId of guildIds) {