diff --git a/src/client/Client.js b/src/client/Client.js index b28d5faf80d0..99b81055e813 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -14,6 +14,8 @@ const ClientPresence = require('../structures/ClientPresence'); const GuildPreview = require('../structures/GuildPreview'); const GuildTemplate = require('../structures/GuildTemplate'); const Invite = require('../structures/Invite'); +const Sticker = require('../structures/Sticker'); +const StickerPack = require('../structures/StickerPack'); const VoiceRegion = require('../structures/VoiceRegion'); const Webhook = require('../structures/Webhook'); const Widget = require('../structures/Widget'); @@ -318,6 +320,33 @@ class Client extends BaseClient { }); } + /** + * Obtains a sticker from Discord. + * @param {Snowflake} id The sticker's id + * @returns {Promise} + * @example + * client.fetchSticker('id') + * .then(sticker => console.log(`Obtained sticker with name: ${sticker.name}`)) + * .catch(console.error); + */ + async fetchSticker(id) { + const data = await this.api.stickers(id).get(); + return new Sticker(this, data); + } + + /** + * Obtains the list of sticker packs available to Nitro subscribers from Discord. + * @returns {Promise>} + * @example + * client.fetchPremiumStickerPacks() + * .then(packs => console.log(`Available sticker packs are: ${packs.map(pack => pack.name).join(', ')}`)) + * .catch(console.error); + */ + async fetchPremiumStickerPacks() { + const data = await this.api('sticker-packs').get(); + return new Collection(data.sticker_packs.map(p => [p.id, new StickerPack(this, p)])); + } + /** * Sweeps all text-based channels' messages and removes the ones older than the max message lifetime. * If the message has been edited, the time of the edit is used rather than the time of the original message. diff --git a/src/client/actions/GuildStickerCreate.js b/src/client/actions/GuildStickerCreate.js new file mode 100644 index 000000000000..c02cafa941ee --- /dev/null +++ b/src/client/actions/GuildStickerCreate.js @@ -0,0 +1,20 @@ +'use strict'; + +const Action = require('./Action'); +const { Events } = require('../../util/Constants'); + +class GuildStickerCreateAction extends Action { + handle(guild, createdSticker) { + const already = guild.stickers.cache.has(createdSticker.id); + const sticker = guild.stickers._add(createdSticker); + /** + * Emitted whenever a custom sticker is created in a guild. + * @event Client#stickerCreate + * @param {Sticker} sticker The sticker that was created + */ + if (!already) this.client.emit(Events.GUILD_STICKER_CREATE, sticker); + return { sticker }; + } +} + +module.exports = GuildStickerCreateAction; diff --git a/src/client/actions/GuildStickerDelete.js b/src/client/actions/GuildStickerDelete.js new file mode 100644 index 000000000000..2d845eb8f8b0 --- /dev/null +++ b/src/client/actions/GuildStickerDelete.js @@ -0,0 +1,20 @@ +'use strict'; + +const Action = require('./Action'); +const { Events } = require('../../util/Constants'); + +class GuildStickerDeleteAction extends Action { + handle(sticker) { + sticker.guild.stickers.cache.delete(sticker.id); + sticker.deleted = true; + /** + * Emitted whenever a custom sticker is deleted in a guild. + * @event Client#stickerDelete + * @param {Sticker} sticker The sticker that was deleted + */ + this.client.emit(Events.GUILD_STICKER_DELETE, sticker); + return { sticker }; + } +} + +module.exports = GuildStickerDeleteAction; diff --git a/src/client/actions/GuildStickerUpdate.js b/src/client/actions/GuildStickerUpdate.js new file mode 100644 index 000000000000..0c3edc76d833 --- /dev/null +++ b/src/client/actions/GuildStickerUpdate.js @@ -0,0 +1,20 @@ +'use strict'; + +const Action = require('./Action'); +const { Events } = require('../../util/Constants'); + +class GuildStickerUpdateAction extends Action { + handle(current, data) { + const old = current._update(data); + /** + * Emitted whenever a custom sticker is updated in a guild. + * @event Client#stickerUpdate + * @param {Sticker} oldSticker The old sticker + * @param {Sticker} newSticker The new sticker + */ + this.client.emit(Events.GUILD_STICKER_UPDATE, old, current); + return { sticker: current }; + } +} + +module.exports = GuildStickerUpdateAction; diff --git a/src/client/actions/GuildStickersUpdate.js b/src/client/actions/GuildStickersUpdate.js new file mode 100644 index 000000000000..ccf1d639c813 --- /dev/null +++ b/src/client/actions/GuildStickersUpdate.js @@ -0,0 +1,34 @@ +'use strict'; + +const Action = require('./Action'); + +class GuildStickersUpdateAction extends Action { + handle(data) { + const guild = this.client.guilds.cache.get(data.guild_id); + if (!guild?.stickers) return; + + const deletions = new Map(guild.stickers.cache); + + for (const sticker of data.stickers) { + // Determine type of sticker event + const cachedSticker = guild.stickers.cache.get(sticker.id); + if (cachedSticker) { + deletions.delete(sticker.id); + if (!cachedSticker.equals(sticker)) { + // Sticker updated + this.client.actions.GuildStickerUpdate.handle(cachedSticker, sticker); + } + } else { + // Sticker added + this.client.actions.GuildStickerCreate.handle(guild, sticker); + } + } + + for (const sticker of deletions.values()) { + // Sticker deleted + this.client.actions.GuildStickerDelete.handle(sticker); + } + } +} + +module.exports = GuildStickersUpdateAction; diff --git a/src/client/websocket/handlers/GUILD_STICKERS_UPDATE.js b/src/client/websocket/handlers/GUILD_STICKERS_UPDATE.js new file mode 100644 index 000000000000..e3aba61e54c8 --- /dev/null +++ b/src/client/websocket/handlers/GUILD_STICKERS_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.GuildStickersUpdate.handle(packet.d); +}; diff --git a/src/errors/Messages.js b/src/errors/Messages.js index 584505f0183b..650400707104 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -105,8 +105,9 @@ const Messages = { EMOJI_TYPE: 'Emoji must be a string or GuildEmoji/ReactionEmoji', EMOJI_MANAGED: 'Emoji is managed and has no Author.', - MISSING_MANAGE_EMOJIS_PERMISSION: guild => - `Client must have Manage Emoji permission in guild ${guild} to see emoji authors.`, + MISSING_MANAGE_EMOJIS_AND_STICKERS_PERMISSION: guild => + `Client must have Manage Emojis and Stickers permission in guild ${guild} to see emoji authors.`, + NOT_GUILD_STICKER: 'Sticker is a standard (non-guild) sticker and has no author.', REACTION_RESOLVE_USER: "Couldn't resolve the user id to remove from the reaction.", diff --git a/src/index.js b/src/index.js index 34cc9f298c68..ac54ec16d136 100644 --- a/src/index.js +++ b/src/index.js @@ -45,6 +45,7 @@ module.exports = { GuildMemberManager: require('./managers/GuildMemberManager'), GuildMemberRoleManager: require('./managers/GuildMemberRoleManager'), GuildManager: require('./managers/GuildManager'), + GuildStickerManager: require('./managers/GuildStickerManager'), ReactionManager: require('./managers/ReactionManager'), ReactionUserManager: require('./managers/ReactionUserManager'), MessageManager: require('./managers/MessageManager'), diff --git a/src/managers/GuildStickerManager.js b/src/managers/GuildStickerManager.js new file mode 100644 index 000000000000..d3576594e513 --- /dev/null +++ b/src/managers/GuildStickerManager.js @@ -0,0 +1,160 @@ +'use strict'; + +const CachedManager = require('./CachedManager'); +const { TypeError } = require('../errors'); +const MessagePayload = require('../structures/MessagePayload'); +const Sticker = require('../structures/Sticker'); +const Collection = require('../util/Collection'); + +/** + * Manages API methods for Guild Stickers and stores their cache. + * @extends {CachedManager} + */ +class GuildStickerManager extends CachedManager { + constructor(guild, iterable) { + super(guild.client, Sticker, iterable); + + /** + * The guild this manager belongs to + * @type {Guild} + */ + this.guild = guild; + } + + /** + * The cache of Guild Stickers + * @type {Collection} + * @name GuildStickerManager#cache + */ + + _add(data, cache) { + return super._add(data, cache, { extras: [this.guild] }); + } + + /** + * Creates a new custom sticker in the guild. + * @param {BufferResolvable|Stream|FileOptions|MessageAttachment} file The file for the sticker + * @param {string} name The name for the sticker + * @param {string} tags The Discord name of a unicode emoji representing the sticker's expression + * @param {Object} [options] Options + * @param {?string} [options.description] The description for the sticker + * @param {string} [options.reason] Reason for creating the sticker + * @returns {Promise} The created sticker + * @example + * // Create a new sticker from a url + * guild.stickers.create('https://i.imgur.com/w3duR07.png', 'rip') + * .then(sticker => console.log(`Created new sticker with name ${sticker.name}!`)) + * .catch(console.error); + * @example + * // Create a new sticker from a file on your computer + * guild.stickers.create('./memes/banana.png', 'banana') + * .then(sticker => console.log(`Created new sticker with name ${sticker.name}!`)) + * .catch(console.error); + */ + async create(file, name, tags, { description, reason } = {}) { + file = { ...(await MessagePayload.resolveFile(file)), key: 'file' }; + if (!file) throw new TypeError('REQ_RESOURCE_TYPE'); + + const data = { name, tags, description: description ?? '' }; + + return this.client.api + .guilds(this.guild.id) + .stickers.post({ data, files: [file], reason, dontUsePayloadJSON: true }) + .then(sticker => this.client.actions.GuildStickerCreate.handle(this.guild, sticker).sticker); + } + + /** + * Data that resolves to give a Sticker object. This can be: + * * An Sticker object + * * A Snowflake + * @typedef {Sticker|Snowflake} StickerResolvable + */ + + /** + * Resolves an StickerResolvable to a Sticker object. + * @method resolve + * @memberof GuildStickerManager + * @instance + * @param {StickerResolvable} sticker The Sticker resolvable to identify + * @returns {?Sticker} + */ + + /** + * Resolves an StickerResolvable to an Sticker id string. + * @method resolveId + * @memberof GuildStickerManager + * @instance + * @param {StickerResolvable} sticker The Sticker resolvable to identify + * @returns {?Snowflake} + */ + + /** + * Edits a sticker. + * @param {StickerResolvable} sticker The sticker to edit + * @param {GuildStickerEditData} [data] The new data for the sticker + * @param {string} [reason] Reason for editing this sticker + * @returns {Promise} + */ + async edit(sticker, data, reason) { + const stickerId = this.resolveId(sticker); + if (!stickerId) throw new TypeError('INVALID_TYPE', 'sticker', 'StickerResolvable'); + + const d = await this.client.api.guilds(this.guild.id).stickers(stickerId).patch({ + data, + reason, + }); + + const existing = this.cache.get(stickerId); + if (existing) { + const clone = existing._clone(); + clone._patch(d); + return clone; + } + return this._add(d); + } + + /** + * Deletes a sticker. + * @param {StickerResolvable} sticker The sticker to delete + * @param {string} [reason] Reason for deleting this sticker + * @returns {Promise} + */ + async delete(sticker, reason) { + sticker = this.resolveId(sticker); + if (!sticker) throw new TypeError('INVALID_TYPE', 'sticker', 'StickerResolvable'); + + await this.client.api.guilds(this.guild.id).stickers(sticker).delete({ reason }); + } + + /** + * Obtains one or more stickers from Discord, or the sticker cache if they're already available. + * @param {Snowflake} [id] The Sticker's id + * @param {BaseFetchOptions} [options] Additional options for this fetch + * @returns {Promise>} + * @example + * // Fetch all stickers from the guild + * message.guild.stickers.fetch() + * .then(stickers => console.log(`There are ${stickers.size} stickers.`)) + * .catch(console.error); + * @example + * // Fetch a single sticker + * message.guild.stickers.fetch('222078108977594368') + * .then(sticker => console.log(`The sticker name is: ${sticker.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 sticker = await this.client.api.guilds(this.guild.id).stickers(id).get(); + return this._add(sticker, cache); + } + + const data = await this.client.api.guilds(this.guild.id).stickers.get(); + return new Collection(data.map(sticker => [sticker.id, this._add(sticker, cache)])); + } +} + +module.exports = GuildStickerManager; diff --git a/src/rest/APIRequest.js b/src/rest/APIRequest.js index 8402abd6b9c7..d710c58724f3 100644 --- a/src/rest/APIRequest.js +++ b/src/rest/APIRequest.js @@ -49,8 +49,16 @@ class APIRequest { let body; if (this.options.files && this.options.files.length) { body = new FormData(); - for (const file of this.options.files) if (file && file.file) body.append(file.name, file.file, file.name); - if (typeof this.options.data !== 'undefined') body.append('payload_json', JSON.stringify(this.options.data)); + for (const file of this.options.files) { + if (file?.file) body.append(file.key ?? file.name, file.file, file.name); + } + if (typeof this.options.data !== 'undefined') { + if (this.options.dontUsePayloadJSON) { + for (const [key, value] of Object.entries(this.options.data)) body.append(key, value); + } else { + body.append('payload_json', JSON.stringify(this.options.data)); + } + } headers = Object.assign(headers, body.getHeaders()); // eslint-disable-next-line eqeqeq } else if (this.options.data != null) { diff --git a/src/structures/Guild.js b/src/structures/Guild.js index 5004c86a3869..f217f8f51e7b 100644 --- a/src/structures/Guild.js +++ b/src/structures/Guild.js @@ -14,6 +14,7 @@ const GuildChannelManager = require('../managers/GuildChannelManager'); const GuildEmojiManager = require('../managers/GuildEmojiManager'); const GuildInviteManager = require('../managers/GuildInviteManager'); const GuildMemberManager = require('../managers/GuildMemberManager'); +const GuildStickerManager = require('../managers/GuildStickerManager'); const PresenceManager = require('../managers/PresenceManager'); const RoleManager = require('../managers/RoleManager'); const StageInstanceManager = require('../managers/StageInstanceManager'); @@ -404,6 +405,20 @@ class Guild extends AnonymousGuild { emojis: data.emojis, }); } + + if (!this.stickers) { + /** + * A manager of the stickers belonging to this guild + * @type {GuildStickerManager} + */ + this.stickers = new GuildStickerManager(this); + if (data.stickers) for (const sticker of data.stickers) this.stickers._add(sticker); + } else if (data.stickers) { + this.client.actions.GuildStickersUpdate.handle({ + guild_id: this.id, + stickers: data.stickers, + }); + } } /** diff --git a/src/structures/GuildAuditLogs.js b/src/structures/GuildAuditLogs.js index 7fefa09fc5ed..c4431b7b82bb 100644 --- a/src/structures/GuildAuditLogs.js +++ b/src/structures/GuildAuditLogs.js @@ -2,6 +2,7 @@ const Integration = require('./Integration'); const StageInstance = require('./StageInstance'); +const Sticker = require('./Sticker'); const Webhook = require('./Webhook'); const Collection = require('../util/Collection'); const { OverwriteTypes, PartialTypes } = require('../util/Constants'); @@ -21,6 +22,7 @@ const Util = require('../util/Util'); * * MESSAGE * * INTEGRATION * * STAGE_INSTANCE + * * STICKER * @typedef {string} AuditLogTargetType */ @@ -41,6 +43,7 @@ const Targets = { MESSAGE: 'MESSAGE', INTEGRATION: 'INTEGRATION', STAGE_INSTANCE: 'STAGE_INSTANCE', + STICKER: 'STICKER', UNKNOWN: 'UNKNOWN', }; @@ -85,6 +88,9 @@ const Targets = { * * STAGE_INSTANCE_CREATE: 83 * * STAGE_INSTANCE_UPDATE: 84 * * STAGE_INSTANCE_DELETE: 85 + * * STICKER_CREATE: 90 + * * STICKER_UPDATE: 91 + * * STICKER_DELETE: 92 * @typedef {?(number|string)} AuditLogAction */ @@ -133,6 +139,9 @@ const Actions = { STAGE_INSTANCE_CREATE: 83, STAGE_INSTANCE_UPDATE: 84, STAGE_INSTANCE_DELETE: 85, + STICKER_CREATE: 90, + STICKER_UPDATE: 91, + STICKER_DELETE: 92, }; /** @@ -197,9 +206,10 @@ class GuildAuditLogs { * * A message * * An integration * * A stage instance + * * A sticker * * An object with an id key if target was deleted * * An object where the keys represent either the new value or the old value - * @typedef {?(Object|Guild|Channel|User|Role|Invite|Webhook|GuildEmoji|Message|Integration|StageInstance)} + * @typedef {?(Object|Guild|Channel|User|Role|Invite|Webhook|GuildEmoji|Message|Integration|StageInstance|Sticker)} * AuditLogEntryTarget */ @@ -219,6 +229,7 @@ class GuildAuditLogs { if (target < 80) return Targets.MESSAGE; if (target < 83) return Targets.INTEGRATION; if (target < 86) return Targets.STAGE_INSTANCE; + if (target < 100) return Targets.STICKER; return Targets.UNKNOWN; } @@ -250,6 +261,7 @@ class GuildAuditLogs { Actions.MESSAGE_PIN, Actions.INTEGRATION_CREATE, Actions.STAGE_INSTANCE_CREATE, + Actions.STICKER_CREATE, ].includes(action) ) { return 'CREATE'; @@ -272,6 +284,7 @@ class GuildAuditLogs { Actions.MESSAGE_UNPIN, Actions.INTEGRATION_DELETE, Actions.STAGE_INSTANCE_DELETE, + Actions.STICKER_DELETE, ].includes(action) ) { return 'DELETE'; @@ -291,6 +304,7 @@ class GuildAuditLogs { Actions.EMOJI_UPDATE, Actions.INTEGRATION_UPDATE, Actions.STAGE_INSTANCE_UPDATE, + Actions.STICKER_UPDATE, ].includes(action) ) { return 'UPDATE'; @@ -533,6 +547,19 @@ class GuildAuditLogsEntry { }, ), ); + } else if (targetType === Targets.STICKER) { + this.target = + guild.stickers.cache.get(data.target_id) ?? + new Sticker( + guild.client, + this.changes.reduce( + (o, c) => { + o[c.key] = c.new ?? c.old; + return o; + }, + { id: data.target_id }, + ), + ); } else if (data.target_id) { this.target = guild[`${targetType.toLowerCase()}s`]?.cache.get(data.target_id) ?? { id: data.target_id }; } diff --git a/src/structures/GuildEmoji.js b/src/structures/GuildEmoji.js index 0359abebb214..fabc9c60a830 100644 --- a/src/structures/GuildEmoji.js +++ b/src/structures/GuildEmoji.js @@ -59,7 +59,7 @@ class GuildEmoji extends BaseGuildEmoji { */ get deletable() { if (!this.guild.me) throw new Error('GUILD_UNCACHED_ME'); - return !this.managed && this.guild.me.permissions.has(Permissions.FLAGS.MANAGE_EMOJIS); + return !this.managed && this.guild.me.permissions.has(Permissions.FLAGS.MANAGE_EMOJIS_AND_STICKERS); } /** @@ -80,8 +80,8 @@ class GuildEmoji extends BaseGuildEmoji { throw new Error('EMOJI_MANAGED'); } else { if (!this.guild.me) throw new Error('GUILD_UNCACHED_ME'); - if (!this.guild.me.permissions.has(Permissions.FLAGS.MANAGE_EMOJIS)) { - throw new Error('MISSING_MANAGE_EMOJIS_PERMISSION', this.guild); + if (!this.guild.me.permissions.has(Permissions.FLAGS.MANAGE_EMOJIS_AND_STICKERS)) { + throw new Error('MISSING_MANAGE_EMOJIS_AND_STICKERS_PERMISSION', this.guild); } } const data = await this.client.api.guilds(this.guild.id).emojis(this.id).get(); diff --git a/src/structures/Message.js b/src/structures/Message.js index dfa801cf03a4..1b958dfa3a90 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -153,15 +153,12 @@ class Message extends Base { } /** - * A collection of stickers in the message + * A collection of (partial) stickers in the message * @type {Collection} */ - this.stickers = new Collection(); - if (data.stickers) { - for (const sticker of data.stickers) { - this.stickers.set(sticker.id, new Sticker(this.client, sticker)); - } - } + this.stickers = new Collection( + (data.sticker_items ?? data.stickers)?.map(s => [s.id, new Sticker(this.client, s)]), + ); /** * The timestamp the message was sent at diff --git a/src/structures/MessagePayload.js b/src/structures/MessagePayload.js index d6aea7787555..8ab988ee4779 100644 --- a/src/structures/MessagePayload.js +++ b/src/structures/MessagePayload.js @@ -191,6 +191,7 @@ class MessagePayload { flags, message_reference, attachments: this.options.attachments, + sticker_ids: this.options.stickers?.map(sticker => sticker.id ?? sticker), }; return this; } diff --git a/src/structures/Sticker.js b/src/structures/Sticker.js index 1fabfed8fe53..b72bad8975eb 100644 --- a/src/structures/Sticker.js +++ b/src/structures/Sticker.js @@ -1,7 +1,7 @@ 'use strict'; const Base = require('./Base'); -const { StickerFormatTypes } = require('../util/Constants'); +const { StickerFormatTypes, StickerTypes } = require('../util/Constants'); const SnowflakeUtil = require('../util/SnowflakeUtil'); /** @@ -9,8 +9,17 @@ const SnowflakeUtil = require('../util/SnowflakeUtil'); * @extends {Base} */ class Sticker extends Base { + /** + * @param {Client} client The instantiating client + * @param {APISticker | APIStickerItem} sticker The data for the sticker + */ constructor(client, sticker) { super(client); + + this._patch(sticker); + } + + _patch(sticker) { /** * The sticker's id * @type {Snowflake} @@ -18,16 +27,16 @@ class Sticker extends Base { this.id = sticker.id; /** - * The sticker image's id - * @type {string} + * The description of the sticker + * @type {?string} */ - this.asset = sticker.asset; + this.description = sticker.description ?? null; /** - * The description of the sticker - * @type {string} + * The type of the sticker + * @type {?StickerType} */ - this.description = sticker.description; + this.type = StickerTypes[sticker.type] ?? null; /** * The format of the sticker @@ -42,16 +51,40 @@ class Sticker extends Base { this.name = sticker.name; /** - * The id of the pack the sticker is from - * @type {Snowflake} + * The id of the pack the sticker is from, for standard stickers + * @type {?Snowflake} + */ + this.packId = sticker.pack_id ?? null; + + /** + * An array of tags for the sticker + * @type {?string[]} */ - this.packId = sticker.pack_id; + this.tags = sticker.tags?.split(', ') ?? null; /** - * An array of tags for the sticker, if any - * @type {string[]} + * Whether or not the guild sticker is available + * @type {?boolean} */ - this.tags = sticker.tags?.split(', ') ?? []; + this.available = sticker.available ?? null; + + /** + * The id of the guild that owns this sticker + * @type {?Snowflake} + */ + this.guildId = sticker.guild_id ?? null; + + /** + * The user that uploaded the guild sticker + * @type {?User} + */ + this.user = sticker.user ? this.client.users.add(sticker.user) : null; + + /** + * The standard sticker's sort order within its pack + * @type {?number} + */ + this.sortValue = sticker.sort_value ?? null; } /** @@ -72,17 +105,141 @@ class Sticker extends Base { return new Date(this.createdTimestamp); } + /** + * Whether this sticker is partial + * @type {boolean} + * @readonly + */ + get partial() { + return !this.type; + } + + /** + * The guild that owns this sticker + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.resolve(this.guildId); + } + /** * A link to the sticker - * If the sticker's format is LOTTIE, it returns the URL of the Lottie json file. - * Lottie json files must be converted in order to be displayed in Discord. + * If the sticker's format is LOTTIE, it returns the URL of the Lottie json file. * @type {string} */ get url() { - return `${this.client.options.http.cdn}/stickers/${this.id}/${this.asset}.${ - this.format === 'LOTTIE' ? 'json' : 'png' - }`; + return this.client.rest.cdn.Sticker(this.id, this.format); + } + + /** + * Fetches this sticker. + * @returns {Promise} + */ + async fetch() { + const data = await this.client.api.stickers(this.id).get(); + this._patch(data); + return this; + } + + /** + * Fetches the pack this sticker is part of from Discord, if this is a Nitro sticker. + * @returns {Promise} + */ + async fetchPack() { + return (this.packId && (await this.client.fetchPremiumStickerPacks()).get(this.packId)) ?? null; + } + + /** + * Fetches the user who uploaded this sticker, if this is a guild sticker. + * @returns {Promise} + */ + async fetchUser() { + if (this.partial) await this.fetch(); + if (!this.guildID) throw new Error('NOT_GUILD_STICKER'); + + const data = await this.client.api.guilds(this.guildId).stickers(this.id).get(); + this._patch(data); + return this.user; + } + + /** + * Data for editing a sticker. + * @typedef {Object} GuildStickerEditData + * @property {string} [name] The name of the sticker + * @property {?string} [description] The description of the sticker + * @property {string} [tags] The Discord name of a unicode emoji representing the sticker's expression + */ + + /** + * Edits the sticker. + * @param {GuildStickerEditData} [data] The new data for the sticker + * @param {string} [reason] Reason for editing this sticker + * @returns {Promise} + * @example + * // Update the name of a sticker + * sticker.edit({ name: 'new name' }) + * .then(s => console.log(`Updated the name of the sticker to ${s.name}`)) + * .catch(console.error); + */ + edit(data, reason) { + return this.guild.stickers.edit(this, data, reason); + } + + /** + * Deletes the sticker. + * @returns {Promise} + * @param {string} [reason] Reason for deleting this sticker + * @example + * // Delete a message + * sticker.delete() + * .then(s => console.log(`Deleted sticker ${s.name}`)) + * .catch(console.error); + */ + async delete(reason) { + await this.guild.stickers.delete(this, reason); + return this; + } + + /** + * Whether this sticker is the same as another one. + * @param {Sticker|APISticker} other The sticker to compare it to + * @returns {boolean} Whether the sticker is equal to the given sticker or not + */ + equals(other) { + if (other instanceof Sticker) { + return ( + other.id === this.id && + other.description === this.description && + other.type === this.type && + other.format === this.format && + other.name === this.name && + other.packId === this.packId && + other.tags.length === this.tags.length && + other.tags.every(tag => this.tags.includes(tag)) && + other.available === this.available && + other.guildId === this.guildId && + other.sortValue === this.sortValue + ); + } else { + return ( + other.id === this.id && + other.description === this.description && + other.name === this.name && + other.tags === this.tags.join(', ') + ); + } } } module.exports = Sticker; + +/** + * @external APISticker + * @see {@link https://discord.com/developers/docs/resources/sticker#sticker-object} + */ + +/** + * @external APIStickerItem + * @see {@link https://discord.com/developers/docs/resources/sticker#sticker-item-object} + */ diff --git a/src/structures/StickerPack.js b/src/structures/StickerPack.js new file mode 100644 index 000000000000..08e669899979 --- /dev/null +++ b/src/structures/StickerPack.js @@ -0,0 +1,104 @@ +'use strict'; + +const Base = require('./Base'); +const Sticker = require('./Sticker'); +const Collection = require('../util/Collection'); +const SnowflakeUtil = require('../util/SnowflakeUtil'); + +/** + * Represents a pack of standard stickers. + * @extends {Base} + */ +class StickerPack extends Base { + /** + * @param {Client} client The instantiating client + * @param {APIStickerPack} pack The data for the sticker pack + */ + constructor(client, pack) { + super(client); + /** + * The Sticker pack's id + * @type {Snowflake} + */ + this.id = pack.id; + + /** + * The stickers in the pack + * @type {Collection} + */ + this.stickers = new Collection(pack.stickers.map(s => [s.id, new Sticker(client, s)])); + + /** + * The name of the sticker pack + * @type {string} + */ + this.name = pack.name; + + /** + * The id of the pack's SKU + * @type {Snowflake} + */ + this.skuId = pack.sku_id; + + /** + * The id of a sticker in the pack which is shown as the pack's icon + * @type {?Snowflake} + */ + this.coverStickerId = pack.cover_sticker_id ?? null; + + /** + * The description of the sticker pack + * @type {string} + */ + this.description = pack.description; + + /** + * The id of the sticker pack's banner image + * @type {Snowflake} + */ + this.bannerId = pack.banner_asset_id; + } + + /** + * The timestamp the sticker was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return SnowflakeUtil.deconstruct(this.id).timestamp; + } + + /** + * The time the sticker was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The sticker which is shown as the pack's icon + * @type {?Sticker} + * @readonly + */ + get coverSticker() { + return this.coverStickerId && this.stickers.get(this.coverStickerId); + } + + /** + * The URL to this sticker pack's banner. + * @param {StaticImageURLOptions} [options={}] Options for the Image URL + * @returns {string} + */ + bannerURL({ format, size } = {}) { + return this.client.rest.cdn.StickerPackBanner(this.bannerId, format, size); + } +} + +module.exports = StickerPack; + +/** + * @external APIStickerPack + * @see {@link https://discord.com/developers/docs/resources/sticker#sticker-pack-object} + */ diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js index d50ed88b78df..09fbe47b21b0 100644 --- a/src/structures/interfaces/TextBasedChannel.js +++ b/src/structures/interfaces/TextBasedChannel.js @@ -65,6 +65,7 @@ class TextBasedChannel { * @property {FileOptions[]|BufferResolvable[]|MessageAttachment[]} [files] Files to send with the message * @property {MessageActionRow[]|MessageActionRowOptions[]} [components] * Action rows containing interactive components for the message (buttons, select menus) + * @property {StickerResolvable[]} [stickers=[]] Stickers to send in the message */ /** diff --git a/src/util/Constants.js b/src/util/Constants.js index cf50a0beff8c..8d02fbc83ffe 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -59,6 +59,8 @@ exports.Endpoints = { makeImageUrl(`${root}/app-icons/${clientId}/${hash}`, { size, format }), AppAsset: (clientId, hash, { format = 'webp', size } = {}) => makeImageUrl(`${root}/app-assets/${clientId}/${hash}`, { size, format }), + StickerPackBanner: (bannerId, format = 'webp', size) => + makeImageUrl(`${root}/app-assets/710982414301790216/store/${bannerId}`, { size, format }), GDMIcon: (channelId, hash, format = 'webp', size) => makeImageUrl(`${root}/channel-icons/${channelId}/${hash}`, { size, format }), Splash: (guildId, hash, format = 'webp', size) => @@ -67,6 +69,8 @@ exports.Endpoints = { makeImageUrl(`${root}/discovery-splashes/${guildId}/${hash}`, { size, format }), TeamIcon: (teamId, hash, { format = 'webp', size } = {}) => makeImageUrl(`${root}/team-icons/${teamId}/${hash}`, { size, format }), + Sticker: (stickerId, stickerFormat) => + `${root}/stickers/${stickerId}.${stickerFormat === 'LOTTIE' ? 'json' : 'png'}`, }; }, invite: (root, code) => `${root}/${code}`, @@ -178,6 +182,9 @@ exports.Events = { STAGE_INSTANCE_CREATE: 'stageInstanceCreate', STAGE_INSTANCE_UPDATE: 'stageInstanceUpdate', STAGE_INSTANCE_DELETE: 'stageInstanceDelete', + GUILD_STICKER_CREATE: 'stickerCreate', + GUILD_STICKER_DELETE: 'stickerDelete', + GUILD_STICKER_UPDATE: 'stickerUpdate', }; exports.ShardEvents = { @@ -253,6 +260,7 @@ exports.PartialTypes = keyMirror(['USER', 'CHANNEL', 'GUILD_MEMBER', 'MESSAGE', * * STAGE_INSTANCE_CREATE * * STAGE_INSTANCE_UPDATE * * STAGE_INSTANCE_DELETE + * * GUILD_STICKERS_UPDATE * @typedef {string} WSEventType */ exports.WSEvents = keyMirror([ @@ -305,6 +313,7 @@ exports.WSEvents = keyMirror([ 'STAGE_INSTANCE_CREATE', 'STAGE_INSTANCE_UPDATE', 'STAGE_INSTANCE_DELETE', + 'GUILD_STICKERS_UPDATE', ]); /** @@ -765,6 +774,8 @@ exports.APIErrors = { INVALID_FORM_BODY: 50035, INVITE_ACCEPTED_TO_GUILD_NOT_CONTAINING_BOT: 50036, INVALID_API_VERSION: 50041, + FILE_UPLOADED_EXCEEDS_MAXIMUM_SIZE: 50045, + INVALID_FILE_UPLOADED: 50046, CANNOT_SELF_REDEEM_GIFT: 50054, PAYMENT_SOURCE_REQUIRED: 50070, CANNOT_DELETE_COMMUNITY_REQUIRED_CHANNEL: 50074, @@ -780,7 +791,14 @@ exports.APIErrors = { MESSAGE_ALREADY_HAS_THREAD: 160004, THREAD_LOCKED: 160005, MAXIMUM_ACTIVE_THREADS: 160006, - MAXIMUM_ACTIVE_ANNOUCEMENT_THREAD: 160007, + MAXIMUM_ACTIVE_ANNOUNCEMENT_THREADS: 160007, + INVALID_JSON_FOR_UPLOADED_LOTTIE_FILE: 170001, + UPLOADED_LOTTIES_CANNOT_CONTAIN_RASTERIZED_IMAGES: 170002, + STICKER_MAXIMUM_FRAMERATE_EXCEEDED: 170003, + STICKER_FRAME_COUNT_EXCEEDS_MAXIMUM_OF_1000_FRAMES: 170004, + LOTTIE_ANIMATION_MAXIMUM_DIMENSIONS_EXCEEDED: 170005, + STICKER_FRAME_RATE_IS_TOO_SMALL_OR_TOO_LARGE: 170006, + STICKER_ANIMATION_DURATION_EXCEEDS_MAXIMUM_OF_5_SECONDS: 170007, }; /** @@ -809,6 +827,14 @@ exports.WebhookTypes = createEnum([null, 'Incoming', 'Channel Follower']); /** * The value set for a sticker's type: + * * STANDARD + * * GUILD + * @typedef {string} StickerFormatType + */ +exports.StickerTypes = createEnum([null, 'STANDARD', 'GUILD']); + +/** + * The value set for a sticker's format type: * * PNG * * APNG * * LOTTIE diff --git a/src/util/Intents.js b/src/util/Intents.js index 732a732e567d..a47ca7555c62 100644 --- a/src/util/Intents.js +++ b/src/util/Intents.js @@ -28,7 +28,7 @@ class Intents extends BitField {} * * `GUILDS` * * `GUILD_MEMBERS` * * `GUILD_BANS` - * * `GUILD_EMOJIS` + * * `GUILD_EMOJIS_AND_STICKERS` * * `GUILD_INTEGRATIONS` * * `GUILD_WEBHOOKS` * * `GUILD_INVITES` @@ -47,7 +47,7 @@ Intents.FLAGS = { GUILDS: 1 << 0, GUILD_MEMBERS: 1 << 1, GUILD_BANS: 1 << 2, - GUILD_EMOJIS: 1 << 3, + GUILD_EMOJIS_AND_STICKERS: 1 << 3, GUILD_INTEGRATIONS: 1 << 4, GUILD_WEBHOOKS: 1 << 5, GUILD_INVITES: 1 << 6, diff --git a/src/util/Permissions.js b/src/util/Permissions.js index c7809844dc8b..fceb0527591d 100644 --- a/src/util/Permissions.js +++ b/src/util/Permissions.js @@ -87,12 +87,13 @@ class Permissions extends BitField { * * `MANAGE_NICKNAMES` (change other members' nicknames) * * `MANAGE_ROLES` * * `MANAGE_WEBHOOKS` - * * `MANAGE_EMOJIS` + * * `MANAGE_EMOJIS_AND_STICKERS` * * `USE_APPLICATION_COMMANDS` * * `REQUEST_TO_SPEAK` * * `MANAGE_THREADS` * * `USE_PUBLIC_THREADS` * * `USE_PRIVATE_THREADS` + * * `USE_EXTERNAL_STICKERS` (use stickers from different guilds) * @type {Object} * @see {@link https://discord.com/developers/docs/topics/permissions} */ @@ -127,12 +128,13 @@ Permissions.FLAGS = { MANAGE_NICKNAMES: 1n << 27n, MANAGE_ROLES: 1n << 28n, MANAGE_WEBHOOKS: 1n << 29n, - MANAGE_EMOJIS: 1n << 30n, + MANAGE_EMOJIS_AND_STICKERS: 1n << 30n, USE_APPLICATION_COMMANDS: 1n << 31n, REQUEST_TO_SPEAK: 1n << 32n, MANAGE_THREADS: 1n << 34n, USE_PUBLIC_THREADS: 1n << 35n, USE_PRIVATE_THREADS: 1n << 36n, + USE_EXTERNAL_STICKERS: 1n << 37n, }; /** diff --git a/typings/enums.d.ts b/typings/enums.d.ts index d9445a174f1b..56cef3edbe77 100644 --- a/typings/enums.d.ts +++ b/typings/enums.d.ts @@ -126,6 +126,11 @@ export enum StickerFormatTypes { LOTTIE = 3, } +export enum StickerTypes { + STANDARD = 1, + GUILD = 2, +} + export enum VerificationLevels { NONE = 0, LOW = 1, diff --git a/typings/index.d.ts b/typings/index.d.ts index 1403d47e3e02..c3ed88b41804 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -25,6 +25,9 @@ import { APIOverwrite, APIPartialEmoji, APIRole, + APISticker, + APIStickerItem, + APIStickerPack, APIUser, GatewayVoiceServerUpdateDispatchData, GatewayVoiceStateUpdateDispatchData, @@ -52,6 +55,7 @@ import { PremiumTiers, PrivacyLevels, StickerFormatTypes, + StickerTypes, VerificationLevels, WebhookTypes, } from './enums'; @@ -290,6 +294,8 @@ export class Client extends BaseClient { public fetchInvite(invite: InviteResolvable): Promise; public fetchGuildTemplate(template: GuildTemplateResolvable): Promise; public fetchVoiceRegions(): Promise>; + public fetchSticker(id: Snowflake): Promise; + public fetchPremiumStickerPacks(): Promise>; public fetchWebhook(id: Snowflake, token?: string): Promise; public fetchWidget(guild: GuildResolvable): Promise; public generateInvite(options?: InviteGenerationOptions): string; @@ -552,6 +558,7 @@ export class Guild extends AnonymousGuild { public readonly shard: WebSocketShard; public shardId: number; public stageInstances: StageInstanceManager; + public stickers: GuildStickerManager; public readonly systemChannel: TextChannel | null; public systemChannelFlags: Readonly; public systemChannelId: Snowflake | null; @@ -645,6 +652,7 @@ export class GuildAuditLogsEntry { | Message | Integration | StageInstance + | Sticker | { id: Snowflake } | null; public targetType: GuildAuditLogsTarget; @@ -1550,17 +1558,44 @@ export class StageInstance extends Base { } export class Sticker extends Base { - public constructor(client: Client, data: unknown); - public asset: string; + public constructor(client: Client, data: APISticker | APIStickerItem); public readonly createdTimestamp: number; public readonly createdAt: Date; - public description: string; + public available: boolean | null; + public description: string | null; public format: StickerFormatType; + public readonly guild: Guild | null; + public guildId: Snowflake | null; public id: Snowflake; public name: string; - public packId: Snowflake; - public tags: string[]; + public packId: Snowflake | null; + public readonly partial: boolean; + public sortValue: number | null; + public tags: string[] | null; + public type: StickerType | null; + public user: User | null; public readonly url: string; + public fetch(): Promise; + public fetchPack(): Promise; + public fetchUser(): Promise; + public edit(data?: GuildStickerEditData, reason?: string): Promise; + public delete(reason?: string): Promise; + public equals(other: Sticker | unknown): boolean; +} + +export class StickerPack extends Base { + public constructor(client: Client, data: APIStickerPack); + public readonly createdTimestamp: number; + public readonly createdAt: Date; + public bannerId: Snowflake; + public readonly coverSticker: Sticker | null; + public coverStickerId: Snowflake | null; + public description: string; + public id: Snowflake; + public name: string; + public skuId: Snowflake; + public stickers: Collection; + public bannerURL(options?: StaticImageURLOptions): string; } export class StoreChannel extends GuildChannel { @@ -2029,10 +2064,12 @@ export const Constants: { Icon: (userId: Snowflake | number, hash: string, format: 'default' | AllowedImageFormat, size: number) => string; AppIcon: (userId: Snowflake | number, hash: string, format: AllowedImageFormat, size: number) => string; AppAsset: (userId: Snowflake | number, hash: string, format: AllowedImageFormat, size: number) => string; + StickerPackBanner: (bannerId: Snowflake, format: AllowedImageFormat, size: number) => string; GDMIcon: (userId: Snowflake | number, hash: string, format: AllowedImageFormat, size: number) => string; Splash: (guildId: Snowflake | number, hash: string, format: AllowedImageFormat, size: number) => string; DiscoverySplash: (guildId: Snowflake | number, hash: string, format: AllowedImageFormat, size: number) => string; TeamIcon: (teamId: Snowflake | number, hash: string, format: AllowedImageFormat, size: number) => string; + Sticker: (stickerId: Snowflake, stickerFormat: StickerFormatType) => string; }; }; WSCodes: { @@ -2062,6 +2099,7 @@ export const Constants: { MessageTypes: MessageType[]; SystemMessageTypes: SystemMessageType[]; ActivityTypes: typeof ActivityTypes; + StickerTypes: typeof StickerTypes; StickerFormatTypes: typeof StickerFormatTypes; OverwriteTypes: typeof OverwriteTypes; ExplicitContentFilterLevels: typeof ExplicitContentFilterLevels; @@ -2313,6 +2351,21 @@ export class GuildInviteManager extends DataManager; } +export class GuildStickerManager extends CachedManager { + public constructor(guild: Guild, iterable?: Iterable); + public guild: Guild; + public create( + file: BufferResolvable | Stream | FileOptions | MessageAttachment, + name: string, + tags: string, + options?: GuildStickerCreateOptions, + ): Promise; + public edit(sticker: StickerResolvable, data?: GuildStickerEditData, reason?: string): Promise; + public delete(sticker: StickerResolvable, reason?: string): Promise; + public fetch(id: Snowflake, options?: BaseFetchOptions): Promise; + public fetch(id?: Snowflake, options?: BaseFetchOptions): Promise>; +} + export class GuildMemberRoleManager extends DataManager { public constructor(member: GuildMember); public readonly hoist: Role | null; @@ -2648,6 +2701,8 @@ export interface APIErrors { INVALID_FORM_BODY: 50035; INVITE_ACCEPTED_TO_GUILD_NOT_CONTAINING_BOT: 50036; INVALID_API_VERSION: 50041; + FILE_UPLOADED_EXCEEDS_MAXIMUM_SIZE: 50045; + INVALID_FILE_UPLOADED: 50046; CANNOT_SELF_REDEEM_GIFT: 50054; PAYMENT_SOURCE_REQUIRED: 50070; CANNOT_DELETE_COMMUNITY_REQUIRED_CHANNEL: 50074; @@ -2663,7 +2718,14 @@ export interface APIErrors { MESSAGE_ALREADY_HAS_THREAD: 160004; THREAD_LOCKED: 160005; MAXIMUM_ACTIVE_THREADS: 160006; - MAXIMUM_ACTIVE_ANNOUCEMENT_THREAD: 160007; + MAXIMUM_ACTIVE_ANNOUNCEMENT_THREADS: 160007; + INVALID_JSON_FOR_UPLOADED_LOTTIE_FILE: 170001; + UPLOADED_LOTTIES_CANNOT_CONTAIN_RASTERIZED_IMAGES: 170002; + STICKER_MAXIMUM_FRAMERATE_EXCEEDED: 170003; + STICKER_FRAME_COUNT_EXCEEDS_MAXIMUM_OF_1000_FRAMES: 170004; + LOTTIE_ANIMATION_MAXIMUM_DIMENSIONS_EXCEEDED: 170005; + STICKER_FRAME_RATE_IS_TOO_SMALL_OR_TOO_LARGE: 170006; + STICKER_ANIMATION_DURATION_EXCEEDS_MAXIMUM_OF_5_SECONDS: 170007; } export interface ApplicationAsset { @@ -2923,6 +2985,9 @@ export interface ClientEvents { stageInstanceCreate: [stageInstance: StageInstance]; stageInstanceUpdate: [oldStageInstance: StageInstance | null, newStageInstance: StageInstance]; stageInstanceDelete: [stageInstance: StageInstance]; + stickerCreate: [sticker: Sticker]; + stickerDelete: [sticker: Sticker]; + stickerUpdate: [oldSticker: Sticker, newSticker: Sticker]; } export interface ClientOptions { @@ -3133,6 +3198,9 @@ export interface ConstantsEvents { STAGE_INSTANCE_CREATE: 'stageInstanceCreate'; STAGE_INSTANCE_UPDATE: 'stageInstanceUpdate'; STAGE_INSTANCE_DELETE: 'stageInstanceDelete'; + GUILD_STICKER_CREATE: 'stickerCreate'; + GUILD_STICKER_DELETE: 'stickerDelete'; + GUILD_STICKER_UPDATE: 'stickerUpdate'; } export interface ConstantsOpcodes { @@ -3363,6 +3431,9 @@ export interface GuildAuditLogsActions { STAGE_INSTANCE_CREATE?: number; STAGE_INSTANCE_UPDATE?: number; STAGE_INSTANCE_DELETE?: number; + STICKER_CREATE?: number; + STICKER_UPDATE?: number; + STICKER_DELETE?: number; } export type GuildAuditLogsActionType = 'CREATE' | 'DELETE' | 'UPDATE' | 'ALL'; @@ -3388,6 +3459,7 @@ export interface GuildAuditLogsTargets { MESSAGE?: string; INTEGRATION?: string; STAGE_INSTANCE?: string; + STICKER?: string; UNKNOWN?: string; } @@ -3483,6 +3555,17 @@ export interface GuildEmojiEditData { roles?: Collection | RoleResolvable[]; } +export interface GuildStickerCreateOptions { + description?: string | null; + reason?: string; +} + +export interface GuildStickerEditData { + name?: string; + description?: string | null; + tags?: string; +} + export type GuildFeatures = | 'ANIMATED_ICON' | 'BANNER' @@ -3617,7 +3700,7 @@ export type IntentsString = | 'GUILDS' | 'GUILD_MEMBERS' | 'GUILD_BANS' - | 'GUILD_EMOJIS' + | 'GUILD_EMOJIS_AND_STICKERS' | 'GUILD_INTEGRATIONS' | 'GUILD_WEBHOOKS' | 'GUILD_INVITES' @@ -3836,6 +3919,7 @@ export interface MessageOptions { allowedMentions?: MessageMentionOptions; files?: (FileOptions | BufferResolvable | Stream | MessageAttachment)[]; reply?: ReplyOptions; + stickers?: StickerResolvable[]; } export type MessageReactionResolvable = @@ -3983,12 +4067,13 @@ export type PermissionString = | 'MANAGE_NICKNAMES' | 'MANAGE_ROLES' | 'MANAGE_WEBHOOKS' - | 'MANAGE_EMOJIS' + | 'MANAGE_EMOJIS_AND_STICKERS' | 'USE_APPLICATION_COMMANDS' | 'REQUEST_TO_SPEAK' | 'MANAGE_THREADS' | 'USE_PUBLIC_THREADS' - | 'USE_PRIVATE_THREADS'; + | 'USE_PRIVATE_THREADS' + | 'USE_EXTERNAL_STICKERS'; export type RecursiveArray = ReadonlyArray>; @@ -4164,6 +4249,10 @@ export type Status = number; export type StickerFormatType = keyof typeof StickerFormatTypes; +export type StickerResolvable = Sticker | Snowflake; + +export type StickerType = keyof typeof StickerTypes; + export type SystemChannelFlagsString = | 'SUPPRESS_JOIN_NOTIFICATIONS' | 'SUPPRESS_PREMIUM_SUBSCRIPTIONS' @@ -4341,7 +4430,8 @@ export type WSEventType = | 'INTERACTION_CREATE' | 'STAGE_INSTANCE_CREATE' | 'STAGE_INSTANCE_UPDATE' - | 'STAGE_INSTANCE_DELETE'; + | 'STAGE_INSTANCE_DELETE' + | 'GUILD_STICKERS_UPDATE'; export type Serialized = T extends symbol | bigint | (() => any) ? never