diff --git a/packages/discord.js/package.json b/packages/discord.js/package.json index 620edabbce5f..47fdcc8e1723 100644 --- a/packages/discord.js/package.json +++ b/packages/discord.js/package.json @@ -76,6 +76,7 @@ "discord-api-types": "^0.37.118", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", "tslib": "^2.8.1", "undici": "6.21.1" }, diff --git a/packages/discord.js/src/client/Client.js b/packages/discord.js/src/client/Client.js index 7f28e1567f5f..c04844878bec 100644 --- a/packages/discord.js/src/client/Client.js +++ b/packages/discord.js/src/client/Client.js @@ -19,6 +19,7 @@ const { ClientPresence } = require('../structures/ClientPresence.js'); const { GuildPreview } = require('../structures/GuildPreview.js'); const { GuildTemplate } = require('../structures/GuildTemplate.js'); const { Invite } = require('../structures/Invite.js'); +const { SoundboardSound } = require('../structures/SoundboardSound.js'); const { Sticker } = require('../structures/Sticker.js'); const { StickerPack } = require('../structures/StickerPack.js'); const { VoiceRegion } = require('../structures/VoiceRegion.js'); @@ -538,6 +539,20 @@ class Client extends BaseClient { return new Collection(data.sticker_packs.map(stickerPack => [stickerPack.id, new StickerPack(this, stickerPack)])); } + /** + * 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 c5191e8e0969..9ed55e4a01c6 100644 --- a/packages/discord.js/src/client/actions/Action.js +++ b/packages/discord.js/src/client/actions/Action.js @@ -131,6 +131,10 @@ class Action { 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 947e98374932..01dacb33cbec 100644 --- a/packages/discord.js/src/client/actions/ActionsManager.js +++ b/packages/discord.js/src/client/actions/ActionsManager.js @@ -27,6 +27,7 @@ class ActionsManager { this.register(require('./GuildScheduledEventDelete.js').GuildScheduledEventDeleteAction); this.register(require('./GuildScheduledEventUserAdd.js').GuildScheduledEventUserAddAction); this.register(require('./GuildScheduledEventUserRemove.js').GuildScheduledEventUserRemoveAction); + this.register(require('./GuildSoundboardSoundDelete.js').GuildSoundboardSoundDeleteAction); this.register(require('./GuildStickerCreate.js').GuildStickerCreateAction); this.register(require('./GuildStickerDelete.js').GuildStickerDeleteAction); this.register(require('./GuildStickerUpdate.js').GuildStickerUpdateAction); 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..6783a981335f --- /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 }; + } +} + +exports.GuildSoundboardSoundDeleteAction = 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..208eee1bdead --- /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..b5c0b77d56bc --- /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..e57cf139f73f --- /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..b898343a1776 --- /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 7c35702c262c..148bcd729faf 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 PacketHandlers = Object.fromEntries([ ['GUILD_SCHEDULED_EVENT_UPDATE', require('./GUILD_SCHEDULED_EVENT_UPDATE.js')], ['GUILD_SCHEDULED_EVENT_USER_ADD', require('./GUILD_SCHEDULED_EVENT_USER_ADD.js')], ['GUILD_SCHEDULED_EVENT_USER_REMOVE', require('./GUILD_SCHEDULED_EVENT_USER_REMOVE.js')], + ['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.js')], ['GUILD_UPDATE', require('./GUILD_UPDATE.js')], ['INTERACTION_CREATE', require('./INTERACTION_CREATE.js')], @@ -49,6 +53,7 @@ const PacketHandlers = Object.fromEntries([ ['MESSAGE_UPDATE', require('./MESSAGE_UPDATE.js')], ['PRESENCE_UPDATE', require('./PRESENCE_UPDATE.js')], ['READY', require('./READY.js')], + ['SOUNDBOARD_SOUNDS', require('./SOUNDBOARD_SOUNDS.js')], ['STAGE_INSTANCE_CREATE', require('./STAGE_INSTANCE_CREATE.js')], ['STAGE_INSTANCE_DELETE', require('./STAGE_INSTANCE_DELETE.js')], ['STAGE_INSTANCE_UPDATE', require('./STAGE_INSTANCE_UPDATE.js')], diff --git a/packages/discord.js/src/errors/ErrorCodes.js b/packages/discord.js/src/errors/ErrorCodes.js index 39393583ad07..9edaefdf8833 100644 --- a/packages/discord.js/src/errors/ErrorCodes.js +++ b/packages/discord.js/src/errors/ErrorCodes.js @@ -61,6 +61,7 @@ * @property {'GuildChannelUnowned'} GuildChannelUnowned * @property {'GuildOwned'} GuildOwned * @property {'GuildMembersTimeout'} GuildMembersTimeout + * @property {'GuildSoundboardSoundsTimeout'} GuildSoundboardSoundsTimeout * @property {'GuildUncachedMe'} GuildUncachedMe * @property {'ChannelNotCached'} ChannelNotCached * @property {'StageChannelResolve'} StageChannelResolve @@ -85,6 +86,8 @@ * @property {'EmojiManaged'} EmojiManaged * @property {'MissingManageGuildExpressionsPermission'} MissingManageGuildExpressionsPermission * + + * @property {'NotGuildSoundboardSound'} NotGuildSoundboardSound * @property {'NotGuildSticker'} NotGuildSticker * @property {'ReactionResolveUser'} ReactionResolveUser @@ -193,6 +196,7 @@ const keys = [ 'GuildChannelUnowned', 'GuildOwned', 'GuildMembersTimeout', + 'GuildSoundboardSoundsTimeout', 'GuildUncachedMe', 'ChannelNotCached', 'StageChannelResolve', @@ -217,6 +221,7 @@ const keys = [ 'EmojiManaged', 'MissingManageGuildExpressionsPermission', + 'NotGuildSoundboardSound', 'NotGuildSticker', 'ReactionResolveUser', diff --git a/packages/discord.js/src/errors/Messages.js b/packages/discord.js/src/errors/Messages.js index 09810f50a51a..7c40bcd0f145 100644 --- a/packages/discord.js/src/errors/Messages.js +++ b/packages/discord.js/src/errors/Messages.js @@ -66,6 +66,7 @@ const Messages = { [ErrorCodes.GuildChannelUnowned]: "The fetched channel does not belong to this manager's guild.", [ErrorCodes.GuildOwned]: 'Guild is owned by the client.', [ErrorCodes.GuildMembersTimeout]: "Members didn't arrive in time.", + [ErrorCodes.GuildSoundboardSoundsTimeout]: "Soundboard sounds didn't arrive in time.", [ErrorCodes.GuildUncachedMe]: 'The client user as a member of this guild is uncached.', [ErrorCodes.ChannelNotCached]: 'Could not find the channel where this message came from in the cache!', [ErrorCodes.StageChannelResolve]: 'Could not resolve channel to a stage channel.', @@ -91,6 +92,8 @@ const Messages = { [ErrorCodes.MissingManageGuildExpressionsPermission]: guild => `Client must have Manage Guild Expressions permission in guild ${guild} to see emoji authors.`, + [ErrorCodes.NotGuildSoundboardSound]: action => + `Soundboard sound is a default (non-guild) soundboard sound and can't be ${action}.`, [ErrorCodes.NotGuildSticker]: 'Sticker is a standard (non-guild) sticker and has no author.', [ErrorCodes.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 a93413cd241a..0c4c4cec7b00 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -74,6 +74,7 @@ exports.GuildMemberManager = require('./managers/GuildMemberManager.js').GuildMe exports.GuildMemberRoleManager = require('./managers/GuildMemberRoleManager.js').GuildMemberRoleManager; exports.GuildMessageManager = require('./managers/GuildMessageManager.js').GuildMessageManager; exports.GuildScheduledEventManager = require('./managers/GuildScheduledEventManager.js').GuildScheduledEventManager; +exports.GuildSoundboardSoundManager = require('./managers/GuildSoundboardSoundManager.js').GuildSoundboardSoundManager; exports.GuildStickerManager = require('./managers/GuildStickerManager.js').GuildStickerManager; exports.GuildTextThreadManager = require('./managers/GuildTextThreadManager.js').GuildTextThreadManager; exports.MessageManager = require('./managers/MessageManager.js').MessageManager; @@ -194,6 +195,7 @@ exports.Role = require('./structures/Role.js').Role; exports.RoleSelectMenuComponent = require('./structures/RoleSelectMenuComponent.js').RoleSelectMenuComponent; exports.RoleSelectMenuInteraction = require('./structures/RoleSelectMenuInteraction.js').RoleSelectMenuInteraction; exports.SKU = require('./structures/SKU.js').SKU; +exports.SoundboardSound = require('./structures/SoundboardSound.js').SoundboardSound; exports.StageChannel = require('./structures/StageChannel.js').StageChannel; exports.StageInstance = require('./structures/StageInstance.js').StageInstance; exports.Sticker = require('./structures/Sticker.js').Sticker; diff --git a/packages/discord.js/src/managers/GuildManager.js b/packages/discord.js/src/managers/GuildManager.js index 011810304989..0e3984c240d0 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.js'); +const { DiscordjsError, ErrorCodes } = require('../errors/index.js'); const { ShardClientUtil } = require('../sharding/ShardClientUtil.js'); const { Guild } = require('../structures/Guild.js'); const { GuildChannel } = require('../structures/GuildChannel.js'); @@ -282,6 +283,71 @@ 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.ws.getShardCount(); + const shardIds = Map.groupBy(guildIds, guildId => ShardClientUtil.shardIdForGuildId(guildId, shardCount)); + + for (const [shardId, shardGuildIds] of shardIds) { + this.client.ws.send(shardId, { + 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..0cd99dfcdd5c --- /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 for the soundboard sound, from 0 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 0f69bb41db50..8c971bb660e9 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.js'); const { GuildInviteManager } = require('../managers/GuildInviteManager.js'); const { GuildMemberManager } = require('../managers/GuildMemberManager.js'); const { GuildScheduledEventManager } = require('../managers/GuildScheduledEventManager.js'); +const { GuildSoundboardSoundManager } = require('../managers/GuildSoundboardSoundManager.js'); const { GuildStickerManager } = require('../managers/GuildStickerManager.js'); const { PresenceManager } = require('../managers/PresenceManager.js'); const { RoleManager } = require('../managers/RoleManager.js'); @@ -107,6 +108,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 51ff5c3ee150..cf88c016930a 100644 --- a/packages/discord.js/src/structures/GuildAuditLogsEntry.js +++ b/packages/discord.js/src/structures/GuildAuditLogsEntry.js @@ -34,7 +34,6 @@ const Targets = { Unknown: 'Unknown', }; -// TODO: Add soundboard sounds when https://github.com/discordjs/discord.js/pull/10590 is merged /** * The target of a guild audit log entry. It can be one of: * * A guild @@ -52,10 +51,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|Integration|StageInstance|Sticker| - * GuildScheduledEvent|ApplicationCommand|AutoModerationRule|GuildOnboardingPrompt)} AuditLogEntryTarget + * GuildScheduledEvent|ApplicationCommand|AutoModerationRule|GuildOnboardingPrompt|SoundboardSound)} AuditLogEntryTarget */ /** @@ -369,9 +369,8 @@ class GuildAuditLogsEntry { this.target = guild.roles.cache.get(data.target_id) ?? { id: data.target_id }; } else if (targetType === Targets.Emoji) { this.target = guild.emojis.cache.get(data.target_id) ?? { id: data.target_id }; - // TODO: Uncomment after https://github.com/discordjs/discord.js/pull/10590 is merged - // } else if (targetType === Targets.SoundboardSound) { - // this.target = guild.soundboardSounds.cache.get(data.target_id) ?? { 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 = { 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..677343b8b067 --- /dev/null +++ b/packages/discord.js/src/structures/SoundboardSound.js @@ -0,0 +1,198 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); +const { Base } = require('./Base.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 the 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 the soundboard sound + * @type {?string} + */ + this.name = data.name; + } else { + this.name ??= null; + } + + if ('volume' in data) { + /** + * The volume of the soundboard sound, from 0 to 1 + * @type {?number} + */ + this.volume = data.volume; + } else { + this.volume ??= null; + } + + if ('emoji_id' in data) { + /** + * The emoji id of the soundboard sound + * @type {?Snowflake} + */ + this.emojiId = data.emoji_id; + } else { + this.emojiId ??= null; + } + + if ('emoji_name' in data) { + /** + * The emoji name of the soundboard sound + * @type {?string} + */ + this.emojiName = data.emoji_name; + } else { + this.emojiName ??= null; + } + + if ('guild_id' in data) { + /** + * The guild id of the 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 guild this soundboard sound is part of + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.resolve(this.guildId); + } + + /** + * The timestamp the soundboard sound was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.soundId); + } + + /** + * The time the sticker was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * A link to the soundboard sound + * @type {string} + * @readonly + */ + get url() { + return this.client.rest.cdn.soundboardSound(this.soundId); + } + + /** + * Edits the 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.guild) throw new DiscordjsError(ErrorCodes.NotGuildSoundboardSound, 'edited'); + + return this.guild.soundboardSounds.edit(this, options); + } + + /** + * Deletes the soundboard sound. + * @param {string} [reason] Reason for deleting this soundboard sound + * @returns {Promise} + * @example + * // Delete a message + * soundboardSound.delete() + * .then(sound => console.log(`Deleted soundboard sound ${sound.name}`)) + * .catch(console.error); + */ + async delete(reason) { + if (!this.guild) 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/VoiceChannel.js b/packages/discord.js/src/structures/VoiceChannel.js index 048f54118cbc..1f409079f635 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.js'); /** @@ -35,6 +35,20 @@ class VoiceChannel extends BaseGuildVoiceChannel { permissions.has(PermissionFlagsBits.Speak, false) ); } + + /** + * Send a soundboard sound to a voice channel the user is connected to. + * @param {SoundboardSound} sound the sound to send + * @returns {void} + */ + 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 c8c589464302..3365ab5937fe 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; + } } exports.VoiceChannelEffect = VoiceChannelEffect; diff --git a/packages/discord.js/src/util/DataResolver.js b/packages/discord.js/src/util/DataResolver.js index ab43ff1e929b..6f1351591290 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 - * @returns {?string} + * @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 04c6eadf68cf..c546055e755b 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} StageInstanceCreate stageInstanceCreate * @property {string} StageInstanceDelete stageInstanceDelete * @property {string} StageInstanceUpdate stageInstanceUpdate @@ -127,6 +132,10 @@ exports.Events = { GuildScheduledEventUpdate: 'guildScheduledEventUpdate', GuildScheduledEventUserAdd: 'guildScheduledEventUserAdd', GuildScheduledEventUserRemove: 'guildScheduledEventUserRemove', + GuildSoundboardSoundCreate: 'guildSoundboardSoundCreate', + GuildSoundboardSoundDelete: 'guildSoundboardSoundDelete', + GuildSoundboardSoundsUpdate: 'guildSoundboardSoundsUpdate', + GuildSoundboardSoundUpdate: 'guildSoundboardSoundUpdate', GuildStickerCreate: 'stickerCreate', GuildStickerDelete: 'stickerDelete', GuildStickerUpdate: 'stickerUpdate', @@ -147,6 +156,7 @@ exports.Events = { MessageReactionRemoveEmoji: 'messageReactionRemoveEmoji', MessageUpdate: 'messageUpdate', PresenceUpdate: 'presenceUpdate', + SoundboardSounds: 'soundboardSounds', StageInstanceCreate: 'stageInstanceCreate', StageInstanceDelete: 'stageInstanceDelete', StageInstanceUpdate: 'stageInstanceUpdate', diff --git a/packages/discord.js/src/util/Partials.js b/packages/discord.js/src/util/Partials.js index 8a9ce134f452..5dbddbefa618 100644 --- a/packages/discord.js/src/util/Partials.js +++ b/packages/discord.js/src/util/Partials.js @@ -28,6 +28,7 @@ const { createEnum } = require('./Enums.js'); * @property {number} ThreadMember The partial to receive uncached thread members. * @property {number} Poll The partial to receive uncached polls. * @property {number} PollAnswer The partial to receive uncached poll answers. + * @property {number} SoundboardSound The partial to receive uncached soundboard sounds. */ // JSDoc for IntelliSense purposes @@ -45,4 +46,5 @@ exports.Partials = createEnum([ 'ThreadMember', 'Poll', 'PollAnswer', + 'SoundboardSound', ]); diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 58d20189acfc..995d0b1e8bf8 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -159,6 +159,7 @@ import { VoiceChannelEffectSendAnimationType, GatewayVoiceChannelEffectSendDispatchData, RESTAPIPoll, + APISoundboardSound, } from 'discord-api-types/v10'; import { ChildProcess } from 'node:child_process'; import { Stream } from 'node:stream'; @@ -902,6 +903,7 @@ export class Client extends BaseClient; public fetchStickerPacks(options: { packId: Snowflake }): Promise; public fetchStickerPacks(options?: StickerPackFetchOptions): Promise>; + public fetchDefaultSoundboardSounds(): Promise>; public fetchWebhook(id: Snowflake, token?: string): Promise; public fetchGuildWidget(guild: GuildResolvable): Promise; public generateInvite(options?: InviteGenerationOptions): string; @@ -1302,6 +1304,7 @@ export class Guild extends AnonymousGuild { public safetyAlertsChannelId: Snowflake | null; public scheduledEvents: GuildScheduledEventManager; public shardId: number; + public soundboardSounds: GuildSoundboardSoundManager; public stageInstances: StageInstanceManager; public stickers: GuildStickerManager; public incidentsData: IncidentActions | null; @@ -3461,6 +3464,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 { @@ -3590,6 +3594,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; + public emojiId: Snowflake | null; + public emojiName: string | null; + public guildId: Snowflake | null; + public get guild(): Guild | null; + public available: boolean; + public user: User | null; + public get createdAt(): Date; + public get createdTimestamp(): number; + 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; @@ -3704,6 +3732,7 @@ export enum DiscordjsErrorCodes { GuildChannelUnowned = 'GuildChannelUnowned', GuildOwned = 'GuildOwned', GuildMembersTimeout = 'GuildMembersTimeout', + GuildSoundboardSoundsTimeout = 'GuildSoundboardSoundsTimeout', GuildUncachedMe = 'GuildUncachedMe', ChannelNotCached = 'ChannelNotCached', StageChannelResolve = 'StageChannelResolve', @@ -3727,6 +3756,7 @@ export enum DiscordjsErrorCodes { EmojiManaged = 'EmojiManaged', MissingManageGuildExpressionsPermission = 'MissingManageGuildExpressionsPermission', + NotGuildSoundboardSound = 'NotGuildSoundboardSound', NotGuildSticker = 'NotGuildSticker', ReactionResolveUser = 'ReactionResolveUser', @@ -4100,11 +4130,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, @@ -4196,6 +4234,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; @@ -4510,7 +4578,8 @@ export type AllowedPartial = | GuildScheduledEvent | ThreadMember | Poll - | PollAnswer; + | PollAnswer + | SoundboardSound; export type AllowedThreadTypeForAnnouncementChannel = ChannelType.AnnouncementThread; @@ -5057,6 +5126,9 @@ export interface ClientEventTypes { 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]; @@ -5124,6 +5196,7 @@ export interface ClientEventTypes { guildScheduledEventDelete: [guildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent]; guildScheduledEventUserAdd: [guildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent, user: User]; guildScheduledEventUserRemove: [guildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent, user: User]; + soundboardSounds: [soundboardSounds: ReadonlyCollection, guild: Guild]; } export interface ClientFetchInviteOptions { @@ -5326,6 +5399,11 @@ export enum Events { GuildScheduledEventDelete = 'guildScheduledEventDelete', GuildScheduledEventUserAdd = 'guildScheduledEventUserAdd', GuildScheduledEventUserRemove = 'guildScheduledEventUserRemove', + GuildSoundboardSoundCreate = 'guildSoundboardSoundCreate', + GuildSoundboardSoundDelete = 'guildSoundboardSoundDelete', + GuildSoundboardSoundUpdate = 'guildSoundboardSoundUpdate', + GuildSoundboardSoundsUpdate = 'guildSoundboardSoundsUpdate', + SoundboardSounds = 'soundboardSounds', } export enum ShardEvents { @@ -6489,6 +6567,8 @@ export interface PartialGuildScheduledEvent export interface PartialThreadMember extends Partialize {} +export interface PartialSoundboardSound extends Partialize {} + export interface PartialOverwriteData { id: Snowflake | number; type?: OverwriteType; @@ -6510,6 +6590,7 @@ export enum Partials { ThreadMember, Poll, PollAnswer, + SoundboardSound, } export interface PartialUser extends Partialize {} diff --git a/packages/rest/__tests__/CDN.test.ts b/packages/rest/__tests__/CDN.test.ts index 09302af0e9d8..acad9f5b647c 100644 --- a/packages/rest/__tests__/CDN.test.ts +++ b/packages/rest/__tests__/CDN.test.ts @@ -142,6 +142,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 99243e29d332..9b59b3afe8b4 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, @@ -288,6 +289,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 b12a0ffc3e63..b151b4f49df8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -928,6 +928,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.8.1 version: 2.8.1 @@ -9495,12 +9498,14 @@ packages: lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -9516,6 +9521,7 @@ packages: lodash.omit@4.5.0: resolution: {integrity: sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==} + deprecated: This package is deprecated. Use destructuring assignment syntax instead. lodash.padend@4.6.1: resolution: {integrity: sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==}