feat: add soundboard (#10590)

* feat: add soundboard

* types(PartialSoundboardSound): add `available`

* feat(VoiceChannelEffect): add `soundboardSound` getter

* types: improve return types

* docs: requested changes

* feat: support multiple audio file types

* types(GuildSoundboardSoundCreateOptions): add `contentType`

* types: add default and guild soundboard sound

* fix: requested changes

* docs: use `@fires` tag

* docs: remove misleading tag

* chore: requested changes and missing things

* feat: add send soundboard sound options
This commit is contained in:
Danial Raza
2025-04-25 21:43:17 +02:00
committed by GitHub
parent 8e4e319c24
commit d81b4be2cd
29 changed files with 782 additions and 13 deletions

View File

@@ -76,6 +76,7 @@
"discord-api-types": "^0.38.1",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.8.1",
"undici": "7.8.0"
},

View File

@@ -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');
@@ -536,6 +537,19 @@ 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<Collection<string, SoundboardSound>>}
* @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

View File

@@ -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]]));
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<Snowflake, SoundboardSound>} soundboardSounds The updated soundboard sounds
* @param {Guild} guild The guild that the soundboard sounds are from
*/
client.emit(Events.GuildSoundboardSoundsUpdate, soundboardSounds, guild);
};

View File

@@ -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);
};

View File

@@ -0,0 +1,5 @@
'use strict';
module.exports = (client, { d: data }) => {
client.actions.GuildSoundboardSoundDelete.handle(data);
};

View File

@@ -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);
};

View File

@@ -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<Snowflake, SoundboardSound>} soundboardSounds The sounds received
* @param {Guild} guild The guild that the soundboard sounds are from
*/
client.emit(Events.SoundboardSounds, soundboardSounds, guild);
};

View File

@@ -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')],

View File

@@ -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',

View File

@@ -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.",

View File

@@ -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;
@@ -196,6 +197,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;

View File

@@ -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<Collection<Snowflake, Collection<Snowflake, SoundboardSound>>>}
* @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

View File

@@ -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<Snowflake, SoundboardSound>}
* @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<SoundboardSound>} 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<SoundboardSound>}
*/
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<void>}
*/
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<SoundboardSound|Collection<Snowflake, SoundboardSound>>}
* @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;

View File

@@ -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) {
/**

View File

@@ -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 };
}

View File

@@ -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<SoundboardSound>}
* @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<SoundboardSound>}
* @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;

View File

@@ -225,7 +225,7 @@ class Sticker extends Base {
* @returns {Promise<Sticker>}
* @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);

View File

@@ -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,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<void>}
*/
async sendSoundboardSound(sound) {
await this.client.rest.post(Routes.sendSoundboardSound(this.id), {
body: {
sound_id: sound.soundId,
source_guild_id: sound.guildId ?? undefined,
},
});
}
}
/**

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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',
]);

View File

@@ -160,6 +160,7 @@ import {
GatewayVoiceChannelEffectSendDispatchData,
RESTAPIPoll,
EntryPointCommandHandlerType,
APISoundboardSound,
} from 'discord-api-types/v10';
import { ChildProcess } from 'node:child_process';
import { Stream } from 'node:stream';
@@ -894,6 +895,7 @@ export class Client<Ready extends boolean = boolean> extends BaseClient<ClientEv
public fetchSticker(id: Snowflake): Promise<Sticker>;
public fetchStickerPacks(options: { packId: Snowflake }): Promise<StickerPack>;
public fetchStickerPacks(options?: StickerPackFetchOptions): Promise<Collection<Snowflake, StickerPack>>;
public fetchDefaultSoundboardSounds(): Promise<Collection<string, DefaultSoundboardSound>>;
public fetchWebhook(id: Snowflake, token?: string): Promise<Webhook>;
public fetchGuildWidget(guild: GuildResolvable): Promise<Widget>;
public generateInvite(options?: InviteGenerationOptions): string;
@@ -1320,6 +1322,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;
@@ -3480,9 +3483,15 @@ export type ComponentData =
| ModalActionRowComponentData
| ActionRowData<MessageActionRowComponentData | ModalActionRowComponentData>;
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<void>;
}
export class VoiceChannelEffect {
@@ -3496,6 +3505,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 {
@@ -3625,6 +3635,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<true>, data: APISoundboardSound);
public name: string;
public soundId: Snowflake | string;
public volume: number;
private _emoji: Omit<APIEmoji, 'animated'> | 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<GuildSoundboardSound>;
public delete(reason?: string): Promise<GuildSoundboardSound>;
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<APIEmoji, 'animated'>;
@@ -3739,6 +3773,7 @@ export enum DiscordjsErrorCodes {
GuildChannelUnowned = 'GuildChannelUnowned',
GuildOwned = 'GuildOwned',
GuildMembersTimeout = 'GuildMembersTimeout',
GuildSoundboardSoundsTimeout = 'GuildSoundboardSoundsTimeout',
GuildUncachedMe = 'GuildUncachedMe',
ChannelNotCached = 'ChannelNotCached',
StageChannelResolve = 'StageChannelResolve',
@@ -3762,6 +3797,7 @@ export enum DiscordjsErrorCodes {
EmojiManaged = 'EmojiManaged',
MissingManageGuildExpressionsPermission = 'MissingManageGuildExpressionsPermission',
NotGuildSoundboardSound = 'NotGuildSoundboardSound',
NotGuildSticker = 'NotGuildSticker',
ReactionResolveUser = 'ReactionResolveUser',
@@ -4135,11 +4171,19 @@ export class GuildEmojiRoleManager extends DataManager<Snowflake, Role, RoleReso
): Promise<GuildEmoji>;
}
export interface FetchSoundboardSoundsOptions {
guildIds: readonly Snowflake[];
time?: number;
}
export class GuildManager extends CachedManager<Snowflake, Guild, GuildResolvable> {
private constructor(client: Client<true>, iterable?: Iterable<RawGuildData>);
public create(options: GuildCreateOptions): Promise<Guild>;
public fetch(options: Snowflake | FetchGuildOptions): Promise<Guild>;
public fetch(options?: FetchGuildsOptions): Promise<Collection<Snowflake, OAuth2Guild>>;
public fetchSoundboardSounds(
options: FetchSoundboardSoundsOptions,
): Promise<Collection<Snowflake, Collection<Snowflake, GuildSoundboardSound>>>;
public setIncidentActions(
guild: GuildResolvable,
incidentActions: IncidentActionsEditOptions,
@@ -4231,6 +4275,36 @@ export class GuildScheduledEventManager extends CachedManager<
): Promise<GuildScheduledEventManagerFetchSubscribersResult<Options>>;
}
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<Snowflake, SoundboardSound, SoundboardSoundResolvable> {
private constructor(guild: Guild, iterable?: Iterable<APISoundboardSound>);
public guild: Guild;
public create(options: GuildSoundboardSoundCreateOptions): Promise<GuildSoundboardSound>;
public edit(
soundboardSound: SoundboardSoundResolvable,
options: GuildSoundboardSoundEditOptions,
): Promise<GuildSoundboardSound>;
public delete(soundboardSound: SoundboardSoundResolvable): Promise<void>;
public fetch(id: Snowflake, options?: BaseFetchOptions): Promise<GuildSoundboardSound>;
public fetch(options?: BaseFetchOptions): Promise<Collection<Snowflake, GuildSoundboardSound>>;
}
export class GuildStickerManager extends CachedManager<Snowflake, Sticker, StickerResolvable> {
private constructor(guild: Guild, iterable?: Iterable<RawStickerData>);
public guild: Guild;
@@ -4545,7 +4619,8 @@ export type AllowedPartial =
| GuildScheduledEvent
| ThreadMember
| Poll
| PollAnswer;
| PollAnswer
| SoundboardSound;
export type AllowedThreadTypeForAnnouncementChannel = ChannelType.AnnouncementThread;
@@ -5100,6 +5175,9 @@ export interface ClientEventTypes {
guildMembersChunk: [members: ReadonlyCollection<Snowflake, GuildMember>, 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<Message>];
@@ -5167,6 +5245,7 @@ export interface ClientEventTypes {
guildScheduledEventDelete: [guildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent];
guildScheduledEventUserAdd: [guildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent, user: User];
guildScheduledEventUserRemove: [guildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent, user: User];
soundboardSounds: [soundboardSounds: ReadonlyCollection<Snowflake, SoundboardSound>, guild: Guild];
}
export interface ClientFetchInviteOptions {
@@ -5369,6 +5448,11 @@ export enum Events {
GuildScheduledEventDelete = 'guildScheduledEventDelete',
GuildScheduledEventUserAdd = 'guildScheduledEventUserAdd',
GuildScheduledEventUserRemove = 'guildScheduledEventUserRemove',
GuildSoundboardSoundCreate = 'guildSoundboardSoundCreate',
GuildSoundboardSoundDelete = 'guildSoundboardSoundDelete',
GuildSoundboardSoundUpdate = 'guildSoundboardSoundUpdate',
GuildSoundboardSoundsUpdate = 'guildSoundboardSoundsUpdate',
SoundboardSounds = 'soundboardSounds',
}
export enum ShardEvents {
@@ -6535,6 +6619,8 @@ export interface PartialGuildScheduledEvent
export interface PartialThreadMember extends Partialize<ThreadMember, 'flags' | 'joinedAt' | 'joinedTimestamp'> {}
export interface PartialSoundboardSound extends Partialize<SoundboardSound, 'available' | 'name' | 'volume'> {}
export interface PartialOverwriteData {
id: Snowflake | number;
type?: OverwriteType;
@@ -6556,6 +6642,7 @@ export enum Partials {
ThreadMember,
Poll,
PollAnswer,
SoundboardSound,
}
export interface PartialUser extends Partialize<User, 'username' | 'tag' | 'discriminator'> {}

View File

@@ -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);

View File

@@ -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`.
*

3
pnpm-lock.yaml generated
View File

@@ -910,6 +910,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