diff --git a/esm/discord.mjs b/esm/discord.mjs index d8a451f1f..8ebf08652 100644 --- a/esm/discord.mjs +++ b/esm/discord.mjs @@ -65,6 +65,7 @@ export const { GuildEmoji, GuildMember, GuildPreview, + GuildTemplate, Integration, Invite, Message, diff --git a/src/client/Client.js b/src/client/Client.js index fd02197cd..5847f7573 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -12,6 +12,7 @@ const UserManager = require('../managers/UserManager'); const ShardClientUtil = require('../sharding/ShardClientUtil'); const ClientApplication = require('../structures/ClientApplication'); const GuildPreview = require('../structures/GuildPreview'); +const GuildTemplate = require('../structures/GuildTemplate'); const Invite = require('../structures/Invite'); const VoiceRegion = require('../structures/VoiceRegion'); const Webhook = require('../structures/Webhook'); @@ -254,6 +255,23 @@ class Client extends BaseClient { .then(data => new Invite(this, data)); } + /** + * Obtains a template from Discord. + * @param {GuildTemplateResolvable} template Template code or URL + * @returns {Promise} + * @example + * client.fetchGuildTemplate('https://discord.new/FKvmczH2HyUf') + * .then(template => console.log(`Obtained template with code: ${template.code}`)) + * .catch(console.error); + */ + fetchGuildTemplate(template) { + const code = DataResolver.resolveGuildTemplateCode(template); + return this.api.guilds + .templates(code) + .get() + .then(data => new GuildTemplate(this, data)); + } + /** * Obtains a webhook from Discord. * @param {Snowflake} id ID of the webhook diff --git a/src/index.js b/src/index.js index 32b98a075..7319115fe 100644 --- a/src/index.js +++ b/src/index.js @@ -77,6 +77,7 @@ module.exports = { GuildEmoji: require('./structures/GuildEmoji'), GuildMember: require('./structures/GuildMember'), GuildPreview: require('./structures/GuildPreview'), + GuildTemplate: require('./structures/GuildTemplate'), Integration: require('./structures/Integration'), Invite: require('./structures/Invite'), Message: require('./structures/Message'), diff --git a/src/structures/Guild.js b/src/structures/Guild.js index 664aad238..dda3a7c67 100644 --- a/src/structures/Guild.js +++ b/src/structures/Guild.js @@ -4,6 +4,7 @@ const { deprecate } = require('util'); const Base = require('./Base'); const GuildAuditLogs = require('./GuildAuditLogs'); const GuildPreview = require('./GuildPreview'); +const GuildTemplate = require('./GuildTemplate'); const Integration = require('./Integration'); const Invite = require('./Invite'); const VoiceRegion = require('./VoiceRegion'); @@ -733,6 +734,20 @@ class Guild extends Base { ); } + /** + * Fetches a collection of templates from this guild. + * Resolves with a collection mapping templates by their codes. + * @returns {Promise>} + */ + fetchTemplates() { + return this.client.api + .guilds(this.id) + .templates.get() + .then(templates => + templates.reduce((col, data) => col.set(data.code, new GuildTemplate(this.client, data)), new Collection()), + ); + } + /** * The data for creating an integration. * @typedef {Object} IntegrationData @@ -753,6 +768,19 @@ class Guild extends Base { .then(() => this); } + /** + * Creates a template for the guild. + * @param {string} name The name for the template + * @param {string} [description] The description for the template + * @returns {Promise} + */ + createTemplate(name, description) { + return this.client.api + .guilds(this.id) + .templates.post({ data: { name, description } }) + .then(data => new GuildTemplate(this.client, data)); + } + /** * Fetches a collection of invites to this guild. * Resolves with a collection mapping invites by their codes. diff --git a/src/structures/GuildTemplate.js b/src/structures/GuildTemplate.js new file mode 100644 index 000000000..123996b9f --- /dev/null +++ b/src/structures/GuildTemplate.js @@ -0,0 +1,224 @@ +'use strict'; + +const Base = require('./Base'); +const { Events } = require('../util/Constants'); +const DataResolver = require('../util/DataResolver'); + +/** + * Represents the template for a guild. + * @extends {Base} + */ +class GuildTemplate extends Base { + /** + * @param {Client} client The instantiating client + * @param {Object} data The raw data for the template + */ + constructor(client, data) { + super(client); + this._patch(data); + } + + /** + * Builds or updates the template with the provided data. + * @param {Object} data The raw data for the template + * @returns {GuildTemplate} + * @private + */ + _patch(data) { + /** + * The unique code of this template + * @type {string} + */ + this.code = data.code; + + /** + * The name of this template + * @type {string} + */ + this.name = data.name; + + /** + * The description of this template + * @type {?string} + */ + this.description = data.description; + + /** + * The amount of times this template has been used + * @type {number} + */ + this.usageCount = data.usage_count; + + /** + * The ID of the user that created this template + * @type {Snowflake} + */ + this.creatorID = data.creator_id; + + /** + * The user that created this template + * @type {User} + */ + this.creator = this.client.users.add(data.creator); + + /** + * The time of when this template was created at + * @type {Date} + */ + this.createdAt = new Date(data.created_at); + + /** + * The time of when this template was last synced to the guild + * @type {Date} + */ + this.updatedAt = new Date(data.updated_at); + + /** + * The ID of the guild that this template belongs to + * @type {Snowflake} + */ + this.guildID = data.source_guild_id; + + /** + * The data of the guild that this template would create + * @type {Object} + * @see {@link https://discord.com/developers/docs/resources/guild#guild-resource} + */ + this.serializedGuild = data.serialized_source_guild; + + /** + * Whether this template has unsynced changes + * @type {?boolean} + */ + this.unSynced = 'is_dirty' in data ? Boolean(data.is_dirty) : null; + + return this; + } + + /** + * Creates a guild based from this template. + * This is only available to bots in fewer than 10 guilds. + * @param {string} name The name of the guild + * @param {BufferResolvable|Base64Resolvable} [icon] The icon for the guild + * @returns {Promise} + */ + async createGuild(name, icon) { + const { client } = this; + const data = await client.api.guilds.templates(this.code).post({ + data: { + name, + icon: await DataResolver.resolveImage(icon), + }, + }); + // eslint-disable-next-line consistent-return + return new Promise(resolve => { + const createdGuild = client.guilds.cache.get(data.id); + if (createdGuild) return resolve(createdGuild); + + const resolveGuild = guild => { + client.off(Events.GUILD_CREATE, handleGuild); + client.decrementMaxListeners(); + resolve(guild); + }; + + const handleGuild = guild => { + if (guild.id === data.id) { + client.clearTimeout(timeout); + resolveGuild(guild); + } + }; + + client.incrementMaxListeners(); + client.on(Events.GUILD_CREATE, handleGuild); + + const timeout = client.setTimeout(() => resolveGuild(client.guilds.add(data)), 10000); + }); + } + + /** + * Updates the metadata on this template. + * @param {Object} options Options for the template + * @param {string} [options.name] The name of this template + * @param {string} [options.description] The description of this template + * @returns {Promise} + */ + edit({ name, description } = {}) { + return this.client.api + .guilds(this.guildID) + .templates(this.code) + .patch({ data: { name, description } }) + .then(data => this._patch(data)); + } + + /** + * Deletes this template. + * @returns {Promise} + */ + delete() { + return this.client.api + .guilds(this.guildID) + .templates(this.code) + .delete() + .then(() => this); + } + + /** + * Syncs this template to the current state of the guild. + * @returns {Promise} + */ + sync() { + return this.client.api + .guilds(this.guildID) + .templates(this.code) + .put() + .then(data => this._patch(data)); + } + + /** + * The timestamp of when this template was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return this.createdAt.getTime(); + } + + /** + * The timestamp of when this template was last synced to the guild + * @type {number} + * @readonly + */ + get updatedTimestamp() { + return this.updatedAt.getTime(); + } + + /** + * The guild that this template belongs to + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.get(this.guildID) || null; + } + /** + * The URL of this template + * @type {string} + * @readonly + */ + get url() { + return `${this.client.options.http.template}/${this.code}`; + } + + /** + * When concatenated with a string, this automatically returns the templates's code instead of the template object. + * @returns {string} + * @example + * // Logs: Template: FKvmczH2HyUf + * console.log(`Template: ${guildTemplate}!`); + */ + toString() { + return this.code; + } +} + +module.exports = GuildTemplate; diff --git a/src/util/Constants.js b/src/util/Constants.js index 61a03ccb8..826999229 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -81,12 +81,14 @@ exports.DefaultOptions = { * @property {string} [api='https://discord.com/api'] Base url of the API * @property {string} [cdn='https://cdn.discordapp.com'] Base url of the CDN * @property {string} [invite='https://discord.gg'] Base url of invites + * @property {string} [template='https://discord.new'] Base url of templates */ http: { version: 7, api: 'https://discord.com/api', cdn: 'https://cdn.discordapp.com', invite: 'https://discord.gg', + template: 'https://discord.new', }, }; @@ -520,6 +522,7 @@ exports.VerificationLevels = ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH']; * * UNKNOWN_EMOJI * * UNKNOWN_WEBHOOK * * UNKNOWN_BAN + * * UNKNOWN_GUILD_TEMPLATE * * BOT_PROHIBITED_ENDPOINT * * BOT_ONLY_ENDPOINT * * CHANNEL_HIT_WRITE_RATELIMIT @@ -532,6 +535,7 @@ exports.VerificationLevels = ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH']; * * MAXIMUM_CHANNELS * * MAXIMUM_ATTACHMENTS * * MAXIMUM_INVITES + * * GUILD_ALREADY_HAS_TEMPLATE * * UNAUTHORIZED * * ACCOUNT_VERIFICATION_REQUIRED * * REQUEST_ENTITY_TOO_LARGE @@ -584,6 +588,7 @@ exports.APIErrors = { UNKNOWN_EMOJI: 10014, UNKNOWN_WEBHOOK: 10015, UNKNOWN_BAN: 10026, + UNKNOWN_GUILD_TEMPLATE: 10057, BOT_PROHIBITED_ENDPOINT: 20001, BOT_ONLY_ENDPOINT: 20002, CHANNEL_HIT_WRITE_RATELIMIT: 20028, @@ -596,6 +601,7 @@ exports.APIErrors = { MAXIMUM_CHANNELS: 30013, MAXIMUM_ATTACHMENTS: 30015, MAXIMUM_INVITES: 30016, + GUILD_ALREADY_HAS_TEMPLATE: 30031, UNAUTHORIZED: 40001, ACCOUNT_VERIFICATION_REQUIRED: 40002, REQUEST_ENTITY_TOO_LARGE: 40005, diff --git a/src/util/DataResolver.js b/src/util/DataResolver.js index 3285ffede..fbddbd5ab 100644 --- a/src/util/DataResolver.js +++ b/src/util/DataResolver.js @@ -24,16 +24,40 @@ class DataResolver { * @typedef {string} InviteResolvable */ + /** + * Data that can be resolved to give an template code. This can be: + * * A template code + * * A template URL + * @typedef {string} GuildTemplateResolvable + */ + + /** + * Resolves the string to a code based on the passed regex. + * @param {string} data The string to resolve + * @param {RegExp} regex The RegExp used to extract the code + * @returns {string} + */ + static resolveCode(data, regex) { + const match = regex.exec(data); + return match ? match[1] || data : data; + } + /** * Resolves InviteResolvable to an invite code. * @param {InviteResolvable} data The invite resolvable to resolve * @returns {string} */ static resolveInviteCode(data) { - const inviteRegex = /discord(?:(?:app)?\.com\/invite|\.gg(?:\/invite)?)\/([\w-]{2,255})/i; - const match = inviteRegex.exec(data); - if (match && match[1]) return match[1]; - return data; + return this.resolveCode(data, /discord(?:(?:app)?\.com\/invite|\.gg(?:\/invite)?)\/([\w-]{2,255})/i); + } + + /** + * Resolves GuildTemplateResolvable to a template code. + * @param {GuildTemplateResolvable} data The template resolvable to resolve + * @returns {string} + */ + static resolveGuildTemplateCode(data) { + return this.resolveCode(data, /discord(?:app)?\.(?:com\/template|new)\/([\w-]{2,255})/i); } /** diff --git a/typings/index.d.ts b/typings/index.d.ts index 056c628b7..d0b730338 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -214,6 +214,7 @@ declare module 'discord.js' { public fetchApplication(): Promise; public fetchGuildPreview(guild: GuildResolvable): Promise; public fetchInvite(invite: InviteResolvable): Promise; + public fetchGuildTemplate(template: GuildTemplateResolvable): Promise; public fetchVoiceRegions(): Promise>; public fetchWebhook(id: Snowflake, token?: string): Promise; public generateInvite(options?: InviteGenerationOptions | PermissionResolvable): Promise; @@ -524,10 +525,12 @@ declare module 'discord.js' { export class DataResolver { public static resolveBase64(data: Base64Resolvable): string; + public static resolveCode(data: string, regx: RegExp): string; public static resolveFile(resource: BufferResolvable | Stream): Promise; public static resolveFileAsBuffer(resource: BufferResolvable | Stream): Promise; public static resolveImage(resource: BufferResolvable | Base64Resolvable): Promise; public static resolveInviteCode(data: InviteResolvable): string; + public static resolveGuildTemplateCode(data: GuildTemplateResolvable): string; } export class DiscordAPIError extends Error { @@ -634,6 +637,7 @@ declare module 'discord.js' { public addMember(user: UserResolvable, options: AddGuildMemberOptions): Promise; public bannerURL(options?: ImageURLOptions): string | null; public createIntegration(data: IntegrationData, reason?: string): Promise; + public createTemplate(name: string, description?: string): Promise; public delete(): Promise; public discoverySplashURL(options?: ImageURLOptions): string | null; public edit(data: GuildEditData, reason?: string): Promise; @@ -646,6 +650,7 @@ declare module 'discord.js' { public fetchIntegrations(options?: FetchIntegrationsOptions): Promise>; public fetchInvites(): Promise>; public fetchPreview(): Promise; + public fetchTemplates(): Promise>; public fetchVanityCode(): Promise; public fetchVanityData(): Promise<{ code: string; uses: number }>; public fetchVoiceRegions(): Promise>; @@ -852,6 +857,29 @@ declare module 'discord.js' { public toString(): string; } + export class GuildTemplate extends Base { + constructor(client: Client, data: object); + public readonly createdTimestamp: number; + public readonly updatedTimestamp: number; + public readonly url: string; + public code: string; + public name: string; + public description: string | null; + public usageCount: number; + public creator: User; + public creatorID: Snowflake; + public createdAt: Date; + public updatedAt: Date; + public guild: Guild | null; + public guildID: Snowflake; + public serializedGuild: object; + public unSynced: boolean | null; + public createGuild(name: string, icon?: BufferResolvable | Base64Resolvable): Promise; + public delete(): Promise; + public edit(options?: { name?: string; description?: string }): Promise; + public sync(): Promise; + } + export class GuildPreviewEmoji extends BaseGuildEmoji { constructor(client: Client, data: object, guild: GuildPreview); public guild: GuildPreview; @@ -2101,6 +2129,7 @@ declare module 'discord.js' { UNKNOWN_EMOJI: 10014; UNKNOWN_WEBHOOK: 10015; UNKNOWN_BAN: 10026; + UNKNOWN_GUILD_TEMPLATE: 10057; BOT_PROHIBITED_ENDPOINT: 20001; BOT_ONLY_ENDPOINT: 20002; CHANNEL_HIT_WRITE_RATELIMIT: 20028; @@ -2113,6 +2142,7 @@ declare module 'discord.js' { MAXIMUM_CHANNELS: 30013; MAXIMUM_ATTACHMENTS: 30015; MAXIMUM_INVITES: 30016; + GUILD_ALREADY_HAS_TEMPLATE: 30031; UNAUTHORIZED: 40001; ACCOUNT_VERIFICATION_REQUIRED: 40002; REQUEST_ENTITY_TOO_LARGE: 40005; @@ -2637,6 +2667,7 @@ declare module 'discord.js' { host?: string; cdn?: string; invite?: string; + template?: string; } type ImageSize = 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096; @@ -2694,6 +2725,8 @@ declare module 'discord.js' { type InviteResolvable = string; + type GuildTemplateResolvable = string; + type MembershipStates = 'INVITED' | 'ACCEPTED'; type MessageAdditions = MessageEmbed | MessageAttachment | (MessageEmbed | MessageAttachment)[];