diff --git a/packages/discord.js/src/client/Client.js b/packages/discord.js/src/client/Client.js index 7add1a75d..dcedc0f76 100644 --- a/packages/discord.js/src/client/Client.js +++ b/packages/discord.js/src/client/Client.js @@ -14,7 +14,6 @@ const { ShardClientUtil } = require('../sharding/ShardClientUtil.js'); 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'); @@ -24,6 +23,7 @@ const { Widget } = require('../structures/Widget.js'); const { resolveInviteCode, resolveGuildTemplateCode } = require('../util/DataResolver.js'); const { Events } = require('../util/Events.js'); const { IntentsBitField } = require('../util/IntentsBitField.js'); +const { createInvite } = require('../util/Invites.js'); const { Options } = require('../util/Options.js'); const { PermissionsBitField } = require('../util/PermissionsBitField.js'); const { Status } = require('../util/Status.js'); @@ -459,6 +459,7 @@ class Client extends BaseClient { * Options used when fetching an invite from Discord. * * @typedef {Object} ClientFetchInviteOptions + * @property {boolean} [withCounts] Whether to include approximate member counts * @property {Snowflake} [guildScheduledEventId] The id of the guild scheduled event to include with * the invite */ @@ -474,14 +475,16 @@ class Client extends BaseClient { * .then(invite => console.log(`Obtained invite with code: ${invite.code}`)) * .catch(console.error); */ - async fetchInvite(invite, options) { + async fetchInvite(invite, { withCounts, guildScheduledEventId } = {}) { const code = resolveInviteCode(invite); + const query = makeURLSearchParams({ - with_counts: true, - guild_scheduled_event_id: options?.guildScheduledEventId, + with_counts: withCounts, + guild_scheduled_event_id: guildScheduledEventId, }); + const data = await this.rest.get(Routes.invite(code), { query }); - return new Invite(this, data); + return createInvite(this, data); } /** diff --git a/packages/discord.js/src/client/websocket/handlers/INVITE_CREATE.js b/packages/discord.js/src/client/websocket/handlers/INVITE_CREATE.js index 16ab2ab41..d3e1fc7bb 100644 --- a/packages/discord.js/src/client/websocket/handlers/INVITE_CREATE.js +++ b/packages/discord.js/src/client/websocket/handlers/INVITE_CREATE.js @@ -15,7 +15,7 @@ module.exports = (client, { d: data }) => { * This event requires the {@link PermissionFlagsBits.ManageChannels} permission for the channel. * * @event Client#inviteCreate - * @param {Invite} invite The invite that was created + * @param {GuildInvite} invite The invite that was created */ client.emit(Events.InviteCreate, invite); }; diff --git a/packages/discord.js/src/client/websocket/handlers/INVITE_DELETE.js b/packages/discord.js/src/client/websocket/handlers/INVITE_DELETE.js index 9ca09a5bb..aa124683f 100644 --- a/packages/discord.js/src/client/websocket/handlers/INVITE_DELETE.js +++ b/packages/discord.js/src/client/websocket/handlers/INVITE_DELETE.js @@ -1,6 +1,6 @@ 'use strict'; -const { Invite } = require('../../../structures/Invite.js'); +const { GuildInvite } = require('../../../structures/GuildInvite.js'); const { Events } = require('../../../util/Events.js'); module.exports = (client, { d: data }) => { @@ -9,7 +9,7 @@ module.exports = (client, { d: data }) => { if (!channel) return; const inviteData = Object.assign(data, { channel, guild }); - const invite = new Invite(client, inviteData); + const invite = new GuildInvite(client, inviteData); guild.invites.cache.delete(invite.code); @@ -18,7 +18,7 @@ module.exports = (client, { d: data }) => { * This event requires the {@link PermissionFlagsBits.ManageChannels} permission for the channel. * * @event Client#inviteDelete - * @param {Invite} invite The invite that was deleted + * @param {GuildInvite} invite The invite that was deleted */ client.emit(Events.InviteDelete, invite); }; diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index f382df4e4..53af1b0fd 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -124,6 +124,7 @@ exports.BaseGuildEmoji = require('./structures/BaseGuildEmoji.js').BaseGuildEmoj exports.BaseGuildTextChannel = require('./structures/BaseGuildTextChannel.js').BaseGuildTextChannel; exports.BaseGuildVoiceChannel = require('./structures/BaseGuildVoiceChannel.js').BaseGuildVoiceChannel; exports.BaseInteraction = require('./structures/BaseInteraction.js').BaseInteraction; +exports.BaseInvite = require('./structures/BaseInvite.js').BaseInvite; exports.BaseSelectMenuComponent = require('./structures/BaseSelectMenuComponent.js').BaseSelectMenuComponent; exports.ButtonComponent = require('./structures/ButtonComponent.js').ButtonComponent; exports.ButtonInteraction = require('./structures/ButtonInteraction.js').ButtonInteraction; @@ -151,12 +152,14 @@ exports.Emoji = require('./structures/Emoji.js').Emoji; exports.Entitlement = require('./structures/Entitlement.js').Entitlement; exports.FileComponent = require('./structures/FileComponent.js').FileComponent; exports.ForumChannel = require('./structures/ForumChannel.js').ForumChannel; +exports.GroupDMInvite = require('./structures/GroupDMInvite.js').GroupDMInvite; exports.Guild = require('./structures/Guild.js').Guild; exports.GuildAuditLogs = require('./structures/GuildAuditLogs.js').GuildAuditLogs; exports.GuildAuditLogsEntry = require('./structures/GuildAuditLogsEntry.js').GuildAuditLogsEntry; exports.GuildBan = require('./structures/GuildBan.js').GuildBan; exports.GuildChannel = require('./structures/GuildChannel.js').GuildChannel; exports.GuildEmoji = require('./structures/GuildEmoji.js').GuildEmoji; +exports.GuildInvite = require('./structures/GuildInvite.js').GuildInvite; exports.GuildMember = require('./structures/GuildMember.js').GuildMember; exports.GuildOnboarding = require('./structures/GuildOnboarding.js').GuildOnboarding; exports.GuildOnboardingPrompt = require('./structures/GuildOnboardingPrompt.js').GuildOnboardingPrompt; @@ -175,7 +178,6 @@ exports.InteractionCallbackResponse = require('./structures/InteractionCallbackResponse.js').InteractionCallbackResponse; exports.InteractionCollector = require('./structures/InteractionCollector.js').InteractionCollector; exports.InteractionWebhook = require('./structures/InteractionWebhook.js').InteractionWebhook; -exports.Invite = require('./structures/Invite.js').Invite; exports.InviteGuild = require('./structures/InviteGuild.js').InviteGuild; exports.MediaChannel = require('./structures/MediaChannel.js').MediaChannel; exports.MediaGalleryComponent = require('./structures/MediaGalleryComponent.js').MediaGalleryComponent; diff --git a/packages/discord.js/src/managers/GuildInviteManager.js b/packages/discord.js/src/managers/GuildInviteManager.js index 69b8b0458..960dbf364 100644 --- a/packages/discord.js/src/managers/GuildInviteManager.js +++ b/packages/discord.js/src/managers/GuildInviteManager.js @@ -3,7 +3,7 @@ const { Collection } = require('@discordjs/collection'); const { Routes } = require('discord-api-types/v10'); const { DiscordjsError, ErrorCodes } = require('../errors/index.js'); -const { Invite } = require('../structures/Invite.js'); +const { GuildInvite } = require('../structures/GuildInvite.js'); const { resolveInviteCode } = require('../util/DataResolver.js'); const { CachedManager } = require('./CachedManager.js'); @@ -14,7 +14,7 @@ const { CachedManager } = require('./CachedManager.js'); */ class GuildInviteManager extends CachedManager { constructor(guild, iterable) { - super(guild.client, Invite, iterable); + super(guild.client, GuildInvite, iterable); /** * The guild this Manager belongs to @@ -27,7 +27,7 @@ class GuildInviteManager extends CachedManager { /** * The cache of this Manager * - * @type {Collection} + * @type {Collection} * @name GuildInviteManager#cache */ @@ -36,35 +36,44 @@ class GuildInviteManager extends CachedManager { } /** - * Data that resolves to give an Invite object. This can be: + * Data that resolves to give a `GuildInvite`. This can be: + * * - An invite code * - An invite URL * - * @typedef {string} InviteResolvable + * @typedef {string} GuildInviteResolvable */ /** - * Data that can be resolved to a channel that an invite can be created on. This can be: + * A guild channel where an invite may be created on. This can be: * - TextChannel * - VoiceChannel * - AnnouncementChannel * - StageChannel * - ForumChannel * - MediaChannel + * + * @typedef {TextChannel|VoiceChannel|AnnouncementChannel|StageChannel|ForumChannel|MediaChannel} + * GuildInvitableChannel + */ + + /** + * Data that can be resolved to a guild channel where an invite may be created on. This can be: + * - GuildInvitableChannel * - Snowflake * - * @typedef {TextChannel|VoiceChannel|AnnouncementChannel|StageChannel|ForumChannel|MediaChannel|Snowflake} + * @typedef {GuildInvitableChannel|Snowflake} * GuildInvitableChannelResolvable */ /** - * Resolves an InviteResolvable to an Invite object. + * Resolves an `GuildInviteResolvable` to a `GuildInvite` object. * * @method resolve * @memberof GuildInviteManager * @instance - * @param {InviteResolvable} invite The invite resolvable to resolve - * @returns {?Invite} + * @param {GuildInviteResolvable} invite The invite resolvable to resolve + * @returns {?GuildInvite} */ /** @@ -98,8 +107,9 @@ class GuildInviteManager extends CachedManager { /** * Fetches invite(s) from Discord. * - * @param {InviteResolvable|FetchInviteOptions|FetchInvitesOptions} [options] Options for fetching guild invite(s) - * @returns {Promise>} + * @param {GuildInviteResolvable|FetchInviteOptions|FetchInvitesOptions} [options] + * Options for fetching guild invite(s) + * @returns {Promise>} * @example * // Fetch all invites from a guild * guild.invites.fetch() @@ -183,7 +193,7 @@ class GuildInviteManager extends CachedManager { * * @param {GuildInvitableChannelResolvable} channel The options for creating the invite from a channel. * @param {InviteCreateOptions} [options={}] The options for creating the invite from a channel. - * @returns {Promise} + * @returns {Promise} * @example * // Create an invite to a selected channel * guild.invites.create('599942732013764608') @@ -209,7 +219,7 @@ class GuildInviteManager extends CachedManager { }, reason, }); - return new Invite(this.client, invite); + return new GuildInvite(this.client, invite); } /** diff --git a/packages/discord.js/src/managers/GuildManager.js b/packages/discord.js/src/managers/GuildManager.js index 3f56ffa0a..7453a044c 100644 --- a/packages/discord.js/src/managers/GuildManager.js +++ b/packages/discord.js/src/managers/GuildManager.js @@ -10,8 +10,8 @@ const { ShardClientUtil } = require('../sharding/ShardClientUtil.js'); const { Guild } = require('../structures/Guild.js'); const { GuildChannel } = require('../structures/GuildChannel.js'); const { GuildEmoji } = require('../structures/GuildEmoji.js'); +const { GuildInvite } = require('../structures/GuildInvite.js'); const { GuildMember } = require('../structures/GuildMember.js'); -const { Invite } = require('../structures/Invite.js'); const { OAuth2Guild } = require('../structures/OAuth2Guild.js'); const { Role } = require('../structures/Role.js'); const { resolveImage } = require('../util/DataResolver.js'); @@ -119,7 +119,7 @@ class GuildManager extends CachedManager { guild instanceof GuildMember || guild instanceof GuildEmoji || guild instanceof Role || - (guild instanceof Invite && guild.guild) + (guild instanceof GuildInvite && guild.guild) ) { return super.resolve(guild.guild); } @@ -142,7 +142,7 @@ class GuildManager extends CachedManager { guild instanceof GuildMember || guild instanceof GuildEmoji || guild instanceof Role || - (guild instanceof Invite && guild.guild) + (guild instanceof GuildInvite && guild.guild) ) { return super.resolveId(guild.guild.id); } diff --git a/packages/discord.js/src/structures/BaseInvite.js b/packages/discord.js/src/structures/BaseInvite.js new file mode 100644 index 000000000..024b08fa8 --- /dev/null +++ b/packages/discord.js/src/structures/BaseInvite.js @@ -0,0 +1,187 @@ +'use strict'; + +const { RouteBases } = require('discord-api-types/v10'); +const { Base } = require('./Base.js'); + +/** + * The base invite class. + * + * @extends {Base} + */ +class BaseInvite extends Base { + constructor(client, data) { + super(client); + + /** + * The type of this invite. + * + * @type {InviteType} + */ + this.type = data.type; + + /** + * The invite code. + * + * @type {string} + */ + this.code = data.code; + + this._patch(data); + } + + _patch(data) { + if ('inviter_id' in data) { + /** + * The id of the user that created this invite. + * + * @type {?Snowflake} + */ + this.inviterId = data.inviter_id; + } else { + this.inviterId ??= null; + } + + if ('inviter' in data) { + this.client.users._add(data.inviter); + this.inviterId ??= data.inviter.id; + } + + if ('max_age' in data) { + /** + * The maximum age of the invite in seconds. `0` for no expiry. + * + * @type {?number} + */ + this.maxAge = data.max_age; + } else { + this.maxAge ??= null; + } + + if ('created_at' in data) { + /** + * The timestamp this invite was created at. + * + * @type {?number} + */ + this.createdTimestamp = Date.parse(data.created_at); + } else { + this.createdTimestamp ??= null; + } + + if ('expires_at' in data) { + this._expiresTimestamp = data.expires_at && Date.parse(data.expires_at); + } else { + this._expiresTimestamp ??= null; + } + + if ('channel_id' in data) { + /** + * The id of the channel this invite is for. + * + * @type {?Snowflake} + */ + this.channelId = data.channel_id; + } + + if ('approximate_member_count' in data) { + /** + * The approximate total number of members. + * + * @type {?number} + */ + this.approximateMemberCount = data.approximate_member_count; + } else { + this.approximateMemberCount ??= null; + } + } + + /** + * The user that created this invite. + * + * @type {?User} + * @readonly + */ + get inviter() { + return this.inviterId && this.client.users.resolve(this.inviterId); + } + + /** + * The creation date of this invite. + * + * @type {?Date} + * @readonly + */ + get createdAt() { + return this.createdTimestamp && new Date(this.createdTimestamp); + } + + /** + * The timestamp this invite expires at. + * + * @type {?number} + * @readonly + */ + get expiresTimestamp() { + return ( + this._expiresTimestamp ?? + (this.createdTimestamp && this.maxAge ? this.createdTimestamp + this.maxAge * 1_000 : null) + ); + } + + /** + * The expiry date of this invite. + * + * @type {?Date} + * @readonly + */ + get expiresAt() { + return this.expiresTimestamp && new Date(this.expiresTimestamp); + } + + /** + * The URL to the invite. + * + * @type {string} + * @readonly + */ + get url() { + return `${RouteBases.invite}/${this.code}`; + } + + /** + * A regular expression that matches Discord invite links. + * The `code` group property is present on the `exec()` result of this expression. + * + * @type {RegExp} + * @memberof BaseInvite + */ + static InvitesPattern = /discord(?:(?:app)?\.com\/invite|\.gg(?:\/invite)?)\/(?[\w-]{2,255})/i; + + /** + * When concatenated with a string, this automatically concatenates the invite's URL instead of the object. + * + * @returns {string} + * @example + * // Logs: Invite: https://discord.gg/djs + * console.log(`Invite: ${invite}`); + */ + toString() { + return this.url; + } + + toJSON() { + return super.toJSON({ + url: true, + expiresTimestamp: true, + uses: false, + channel: 'channelId', + inviter: 'inviterId', + }); + } + + valueOf() { + return this.code; + } +} + +exports.BaseInvite = BaseInvite; diff --git a/packages/discord.js/src/structures/GroupDMInvite.js b/packages/discord.js/src/structures/GroupDMInvite.js new file mode 100644 index 000000000..e5a41cc8b --- /dev/null +++ b/packages/discord.js/src/structures/GroupDMInvite.js @@ -0,0 +1,37 @@ +'use strict'; + +const { BaseInvite } = require('./BaseInvite.js'); + +/** + * A channel invite leading to a group direct message channel. + * + * @extends {BaseInvite} + */ +class GroupDMInvite extends BaseInvite { + /** + * The approximate total number of members of in the group direct message channel. + * This is only available when the invite was fetched through {@link Client#fetchInvite}. + * + * @name GroupDMInvite#approximateMemberCount + * @type {?number} + */ + + _patch(data) { + super._patch(data); + + if ('channel' in data) { + /** + * The channel this invite is for. + * + * @type {?PartialGroupDMChannel} + */ + this.channel = + this.client.channels._add(data.channel, null, { cache: false }) ?? + this.client.channels.cache.get(this.channelId); + + this.channelId ??= data.channel.id; + } + } +} + +exports.GroupDMInvite = GroupDMInvite; diff --git a/packages/discord.js/src/structures/GuildAuditLogsEntry.js b/packages/discord.js/src/structures/GuildAuditLogsEntry.js index 697b7c67f..dd4ab4e5e 100644 --- a/packages/discord.js/src/structures/GuildAuditLogsEntry.js +++ b/packages/discord.js/src/structures/GuildAuditLogsEntry.js @@ -5,10 +5,10 @@ const { AuditLogOptionsType, AuditLogEvent } = require('discord-api-types/v10'); const { Partials } = require('../util/Partials.js'); const { flatten } = require('../util/Util.js'); const { AutoModerationRule } = require('./AutoModerationRule.js'); +const { GuildInvite } = require('./GuildInvite.js'); const { GuildOnboardingPrompt } = require('./GuildOnboardingPrompt.js'); const { GuildScheduledEvent } = require('./GuildScheduledEvent.js'); const { Integration } = require('./Integration.js'); -const { Invite } = require('./Invite.js'); const { StageInstance } = require('./StageInstance.js'); const { Sticker } = require('./Sticker.js'); const { Webhook } = require('./Webhook.js'); @@ -337,7 +337,7 @@ class GuildAuditLogsEntry { this.target = guild.invites.cache.get(inviteChange.new ?? inviteChange.old) ?? - new Invite(guild.client, changesReduce(this.changes, { guild })); + new GuildInvite(guild.client, changesReduce(this.changes, { guild })); } else if (targetType === Targets.Message) { // Discord sends a channel id for the MessageBulkDelete action type. this.target = diff --git a/packages/discord.js/src/structures/GuildInvite.js b/packages/discord.js/src/structures/GuildInvite.js new file mode 100644 index 000000000..33e94a42e --- /dev/null +++ b/packages/discord.js/src/structures/GuildInvite.js @@ -0,0 +1,211 @@ +'use strict'; + +const { Routes, PermissionFlagsBits, InviteType } = require('discord-api-types/v10'); +const { DiscordjsError, ErrorCodes } = require('../errors/index.js'); +const { BaseInvite } = require('./BaseInvite.js'); +const { GuildScheduledEvent } = require('./GuildScheduledEvent.js'); +const { IntegrationApplication } = require('./IntegrationApplication.js'); +const { InviteGuild } = require('./InviteGuild.js'); + +/** + * A channel invite leading to a guild. + * + * @extends {BaseInvite} + */ +class GuildInvite extends BaseInvite { + constructor(client, data) { + super(client, data); + + // Type may be missing from audit logs. + this.type = InviteType.Guild; + + /** + * The id of the guild this invite is for. + * + * @type {Snowflake} + */ + // Guild id may be missing from audit logs. + this.guildId = data.guild_id ?? data.guild.id; + + /** + * The maximum age of the invite in seconds. `0` for no expiry. + * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} + * or created through {@link GuildInviteManager#create}. + * + * @name GuildInvite#maxAge + * @type {?number} + */ + + /** + * The approximate total number of members of the guild. + * This is only available when the invite was fetched through {@link Client#fetchInvite}. + * + * @name GuildInvite#approximateMemberCount + * @type {?number} + */ + } + + _patch(data) { + super._patch(data); + + if ('guild' in data) { + /** + * The guild the invite is for. May include welcome screen data. + * + * @type {?(Guild|InviteGuild)} + */ + this.guild = this.client.guilds.cache.get(data.guild.id) ?? new InviteGuild(this.client, data.guild); + } else { + this.guild ??= null; + } + + if ('channel' in data) { + /** + * The channel this invite is for. + * + * @type {?GuildInvitableChannel} + */ + this.channel = + this.client.channels._add(data.channel, this.guild, { cache: false }) ?? + this.client.channels.cache.get(this.channelId); + + this.channelId ??= data.channel.id; + } + + if ('target_type' in data) { + /** + * The target type. + * + * @type {?InviteTargetType} + */ + this.targetType = data.target_type; + } else { + this.targetType ??= null; + } + + if ('target_user' in data) { + /** + * The user whose stream to display for this voice channel stream invite. + * + * @type {?User} + */ + this.targetUser = this.client.users._add(data.target_user); + } else { + this.targetUser ??= null; + } + + if ('target_application' in data) { + /** + * The embedded application to open for this voice channel embedded application invite. + * + * @type {?IntegrationApplication} + */ + this.targetApplication = new IntegrationApplication(this.client, data.target_application); + } else { + this.targetApplication ??= null; + } + + if ('guild_scheduled_event' in data) { + /** + * The guild scheduled event data if there is a {@link GuildScheduledEvent} in the channel. + * + * @type {?GuildScheduledEvent} + */ + this.guildScheduledEvent = new GuildScheduledEvent(this.client, data.guild_scheduled_event); + } else { + this.guildScheduledEvent ??= null; + } + + if ('uses' in data) { + /** + * How many times this invite has been used. + * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} + * or created through {@link GuildInviteManager#create}. + * + * @type {?number} + */ + this.uses = data.uses; + } else { + this.uses ??= null; + } + + if ('max_uses' in data) { + /** + * The maximum uses of this invite. + * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} + * or created through {@link GuildInviteManager#create}. + * + * @type {?number} + */ + this.maxUses = data.max_uses; + } else { + this.maxUses ??= null; + } + + if ('temporary' in data) { + /** + * Whether this invite grants temporary membership. + * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} + * or created through {@link GuildInviteManager#create}. + * + * @type {?boolean} + */ + this.temporary = data.temporary ?? null; + } else { + this.temporary ??= null; + } + + if ('approximate_presence_count' in data) { + /** + * The approximate number of online members of the guild. + * This is only available when the invite was fetched through {@link Client#fetchInvite}. + * + * @type {?number} + */ + this.approximatePresenceCount = data.approximate_presence_count; + } else { + this.approximatePresenceCount ??= null; + } + } + + /** + * Whether the invite is deletable by the client user. + * + * @type {boolean} + * @readonly + */ + get deletable() { + const guild = this.guild; + if (!guild || !this.client.guilds.cache.has(guild.id)) return false; + if (!guild.members.me) throw new DiscordjsError(ErrorCodes.GuildUncachedMe); + return Boolean( + this.channel?.permissionsFor(this.client.user).has(PermissionFlagsBits.ManageChannels, false) || + guild.members.me.permissions.has(PermissionFlagsBits.ManageGuild), + ); + } + + /** + * Delete this invite. + * + * @param {string} [reason] Reason for deleting this invite + * @returns {Promise} + */ + async delete(reason) { + await this.client.rest.delete(Routes.invite(this.code), { reason }); + } + + toJSON() { + return super.toJSON({ + url: true, + expiresTimestamp: true, + presenceCount: false, + memberCount: false, + uses: false, + channel: 'channelId', + inviter: 'inviterId', + guild: 'guildId', + }); + } +} + +exports.GuildInvite = GuildInvite; diff --git a/packages/discord.js/src/structures/Invite.js b/packages/discord.js/src/structures/Invite.js deleted file mode 100644 index c5b50ddee..000000000 --- a/packages/discord.js/src/structures/Invite.js +++ /dev/null @@ -1,345 +0,0 @@ -'use strict'; - -const { RouteBases, Routes, PermissionFlagsBits } = require('discord-api-types/v10'); -const { DiscordjsError, ErrorCodes } = require('../errors/index.js'); -const { Base } = require('./Base.js'); -const { GuildScheduledEvent } = require('./GuildScheduledEvent.js'); -const { IntegrationApplication } = require('./IntegrationApplication.js'); - -/** - * Represents an invitation to a guild channel. - * - * @extends {Base} - */ -class Invite extends Base { - /** - * A regular expression that matches Discord invite links. - * The `code` group property is present on the `exec()` result of this expression. - * - * @type {RegExp} - * @memberof Invite - */ - static InvitesPattern = /discord(?:(?:app)?\.com\/invite|\.gg(?:\/invite)?)\/(?[\w-]{2,255})/i; - - constructor(client, data) { - super(client); - - /** - * The type of this invite - * - * @type {InviteType} - */ - this.type = data.type; - - this._patch(data); - } - - _patch(data) { - const { InviteGuild } = require('./InviteGuild.js'); - /** - * The guild the invite is for including welcome screen data if present - * - * @type {?(Guild|InviteGuild)} - */ - this.guild ??= null; - if (data.guild) { - this.guild = this.client.guilds.cache.get(data.guild.id) ?? new InviteGuild(this.client, data.guild); - } - - if ('code' in data) { - /** - * The code for this invite - * - * @type {string} - */ - this.code = data.code; - } - - if ('approximate_presence_count' in data) { - /** - * The approximate number of online members of the guild this invite is for - * This is only available when the invite was fetched through {@link Client#fetchInvite}. - * - * @type {?number} - */ - this.presenceCount = data.approximate_presence_count; - } else { - this.presenceCount ??= null; - } - - if ('approximate_member_count' in data) { - /** - * The approximate total number of members of the guild this invite is for - * This is only available when the invite was fetched through {@link Client#fetchInvite}. - * - * @type {?number} - */ - this.memberCount = data.approximate_member_count; - } else { - this.memberCount ??= null; - } - - if ('temporary' in data) { - /** - * Whether or not this invite only grants temporary membership - * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} - * or created through {@link GuildInviteManager#create}. - * - * @type {?boolean} - */ - this.temporary = data.temporary ?? null; - } else { - this.temporary ??= null; - } - - if ('max_age' in data) { - /** - * The maximum age of the invite, in seconds, 0 if never expires - * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} - * or created through {@link GuildInviteManager#create}. - * - * @type {?number} - */ - this.maxAge = data.max_age; - } else { - this.maxAge ??= null; - } - - if ('uses' in data) { - /** - * How many times this invite has been used - * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} - * or created through {@link GuildInviteManager#create}. - * - * @type {?number} - */ - this.uses = data.uses; - } else { - this.uses ??= null; - } - - if ('max_uses' in data) { - /** - * The maximum uses of this invite - * This is only available when the invite was fetched through {@link GuildInviteManager#fetch} - * or created through {@link GuildInviteManager#create}. - * - * @type {?number} - */ - this.maxUses = data.max_uses; - } else { - this.maxUses ??= null; - } - - if ('inviter_id' in data) { - /** - * The user's id who created this invite - * - * @type {?Snowflake} - */ - this.inviterId = data.inviter_id; - } else { - this.inviterId ??= null; - } - - if ('inviter' in data) { - this.client.users._add(data.inviter); - this.inviterId = data.inviter.id; - } - - if ('target_user' in data) { - /** - * The user whose stream to display for this voice channel stream invite - * - * @type {?User} - */ - this.targetUser = this.client.users._add(data.target_user); - } else { - this.targetUser ??= null; - } - - if ('target_application' in data) { - /** - * The embedded application to open for this voice channel embedded application invite - * - * @type {?IntegrationApplication} - */ - this.targetApplication = new IntegrationApplication(this.client, data.target_application); - } else { - this.targetApplication ??= null; - } - - if ('target_type' in data) { - /** - * The target type - * - * @type {?InviteTargetType} - */ - this.targetType = data.target_type; - } else { - this.targetType ??= null; - } - - if ('channel_id' in data) { - /** - * The id of the channel this invite is for - * - * @type {?Snowflake} - */ - this.channelId = data.channel_id; - } - - if ('channel' in data) { - /** - * The channel this invite is for - * - * @type {?BaseChannel} - */ - this.channel = - this.client.channels._add(data.channel, this.guild, { cache: false }) ?? - this.client.channels.resolve(this.channelId); - - this.channelId ??= data.channel.id; - } - - if ('created_at' in data) { - /** - * The timestamp this invite was created at - * - * @type {?number} - */ - this.createdTimestamp = Date.parse(data.created_at); - } else { - this.createdTimestamp ??= null; - } - - if ('expires_at' in data) { - this._expiresTimestamp = data.expires_at && Date.parse(data.expires_at); - } else { - this._expiresTimestamp ??= null; - } - - if ('guild_scheduled_event' in data) { - /** - * The guild scheduled event data if there is a {@link GuildScheduledEvent} in the channel this invite is for - * - * @type {?GuildScheduledEvent} - */ - this.guildScheduledEvent = new GuildScheduledEvent(this.client, data.guild_scheduled_event); - } else { - this.guildScheduledEvent ??= null; - } - } - - /** - * The time the invite was created at - * - * @type {?Date} - * @readonly - */ - get createdAt() { - return this.createdTimestamp && new Date(this.createdTimestamp); - } - - /** - * Whether the invite is deletable by the client user - * - * @type {boolean} - * @readonly - */ - get deletable() { - const guild = this.guild; - if (!guild || !this.client.guilds.cache.has(guild.id)) return false; - if (!guild.members.me) throw new DiscordjsError(ErrorCodes.GuildUncachedMe); - - return ( - this.channel?.permissionsFor(this.client.user).has(PermissionFlagsBits.ManageChannels, false) || - guild.members.me.permissions.has(PermissionFlagsBits.ManageGuild) - ); - } - - /** - * The timestamp the invite will expire at - * - * @type {?number} - * @readonly - */ - get expiresTimestamp() { - return ( - this._expiresTimestamp ?? - (this.createdTimestamp && this.maxAge ? this.createdTimestamp + this.maxAge * 1_000 : null) - ); - } - - /** - * The time the invite will expire at - * - * @type {?Date} - * @readonly - */ - get expiresAt() { - return this.expiresTimestamp && new Date(this.expiresTimestamp); - } - - /** - * The user who created this invite - * - * @type {?User} - * @readonly - */ - get inviter() { - return this.inviterId && this.client.users.resolve(this.inviterId); - } - - /** - * The URL to the invite - * - * @type {string} - * @readonly - */ - get url() { - return `${RouteBases.invite}/${this.code}`; - } - - /** - * Deletes this invite. - * - * @param {string} [reason] Reason for deleting this invite - * @returns {Promise} - */ - async delete(reason) { - await this.client.rest.delete(Routes.invite(this.code), { reason }); - return this; - } - - /** - * When concatenated with a string, this automatically concatenates the invite's URL instead of the object. - * - * @returns {string} - * @example - * // Logs: Invite: https://discord.gg/A1b2C3 - * console.log(`Invite: ${invite}`); - */ - toString() { - return this.url; - } - - toJSON() { - return super.toJSON({ - url: true, - expiresTimestamp: true, - presenceCount: false, - memberCount: false, - uses: false, - channel: 'channelId', - inviter: 'inviterId', - guild: 'guildId', - }); - } - - valueOf() { - return this.code; - } -} - -exports.Invite = Invite; diff --git a/packages/discord.js/src/util/DataResolver.js b/packages/discord.js/src/util/DataResolver.js index 411aba65e..5e2c0adfd 100644 --- a/packages/discord.js/src/util/DataResolver.js +++ b/packages/discord.js/src/util/DataResolver.js @@ -5,7 +5,7 @@ const fs = require('node:fs/promises'); const path = require('node:path'); const { fetch } = require('undici'); const { DiscordjsError, DiscordjsTypeError, ErrorCodes } = require('../errors/index.js'); -const { Invite } = require('../structures/Invite.js'); +const { BaseInvite } = require('../structures/BaseInvite.js'); /** * Data that can be resolved to give an invite code. This can be: @@ -43,7 +43,7 @@ function resolveCode(data, regex) { * @private */ function resolveInviteCode(data) { - return resolveCode(data, Invite.InvitesPattern); + return resolveCode(data, BaseInvite.InvitesPattern); } /** diff --git a/packages/discord.js/src/util/Invites.js b/packages/discord.js/src/util/Invites.js new file mode 100644 index 000000000..fb68a7b1e --- /dev/null +++ b/packages/discord.js/src/util/Invites.js @@ -0,0 +1,31 @@ +'use strict'; + +const { InviteType } = require('discord-api-types/v10'); +const { BaseInvite } = require('../structures/BaseInvite.js'); +const { GroupDMInvite } = require('../structures/GroupDMInvite.js'); +const { GuildInvite } = require('../structures/GuildInvite.js'); + +/** + * Any invite. + * + * @typedef {GuildInvite|GroupDMInvite} Invite + */ + +const InviteTypeToClass = { + [InviteType.Guild]: GuildInvite, + [InviteType.GroupDM]: GroupDMInvite, +}; + +/** + * Creates an invite. + * + * @param {Client} client The client + * @param {Object} data The data + * @returns {BaseInvite} + * @ignore + */ +function createInvite(client, data) { + return new (InviteTypeToClass[data.type] ?? BaseInvite)(client, data); +} + +exports.createInvite = createInvite; diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 3198570d3..b7e9dd722 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -615,8 +615,8 @@ export class BaseGuildTextChannel extends GuildChannel { public nsfw: boolean; public threads: GuildTextThreadManager; public topic: string | null; - public createInvite(options?: InviteCreateOptions): Promise; - public fetchInvites(cache?: boolean): Promise>; + public createInvite(options?: InviteCreateOptions): Promise; + public fetchInvites(cache?: boolean): Promise>; public setDefaultAutoArchiveDuration( defaultAutoArchiveDuration: ThreadAutoArchiveDuration, reason?: string, @@ -644,8 +644,8 @@ export class BaseGuildVoiceChannel extends GuildChannel { public rtcRegion: string | null; public userLimit: number; public videoQualityMode: VideoQualityMode | null; - public createInvite(options?: InviteCreateOptions): Promise; - public fetchInvites(cache?: boolean): Promise>; + public createInvite(options?: InviteCreateOptions): Promise; + public fetchInvites(cache?: boolean): Promise>; public setBitrate(bitrate: number, reason?: string): Promise; public setRTCRegion(rtcRegion: string | null, reason?: string): Promise; public setUserLimit(userLimit: number, reason?: string): Promise; @@ -931,6 +931,10 @@ export class Client extends BaseClient; public deleteWebhook(id: Snowflake, options?: WebhookDeleteOptions): Promise; public fetchGuildPreview(guild: GuildResolvable): Promise; + public fetchInvite( + invite: InviteResolvable, + options: ClientFetchInviteOptions & { withCounts: true }, + ): Promise>; public fetchInvite(invite: InviteResolvable, options?: ClientFetchInviteOptions): Promise; public fetchGuildTemplate(template: GuildTemplateResolvable): Promise; public fetchVoiceRegions(): Promise>; @@ -2018,37 +2022,49 @@ export class InteractionWebhook { public fetchMessage(message: Snowflake | '@original'): Promise; } -export class Invite extends Base { - private constructor(client: Client, data: unknown); - public channel: NonThreadGuildBasedChannel | PartialGroupDMChannel | null; - public channelId: Snowflake | null; - public code: string; - public get deletable(): boolean; +export class BaseInvite extends Base { + protected constructor(client: Client, data: unknown); + public readonly type: InviteType; + public readonly code: string; + public readonly inviterId: Snowflake | null; + public get inviter(): User | null; + public maxAge: number | null; public get createdAt(): Date | null; public createdTimestamp: number | null; public get expiresAt(): Date | null; public get expiresTimestamp(): number | null; - public guild: Guild | InviteGuild | null; - public get inviter(): User | null; - public inviterId: Snowflake | null; - public maxAge: number | null; - public maxUses: number | null; - public memberCount: number; - public presenceCount: number; - public targetApplication: IntegrationApplication | null; - public targetUser: User | null; - public targetType: InviteTargetType | null; - public temporary: boolean | null; - public type: InviteType; + public readonly channelId: Snowflake | null; + public approximateMemberCount: WithCounts extends true ? number : null; public get url(): string; - public uses: number | null; - public delete(reason?: string): Promise; - public toJSON(): unknown; - public toString(): string; public static InvitesPattern: RegExp; - public guildScheduledEvent: GuildScheduledEvent | null; + public toString(): string; + public toJSON(): unknown; } +export class GuildInvite extends BaseInvite { + public readonly type: InviteType.Guild; + public guild: Guild | InviteGuild | null; + public readonly guildId: Snowflake; + public channel: NonThreadGuildBasedChannel | null; + public targetType: InviteTargetType | null; + public targetUser: User | null; + public targetApplication: IntegrationApplication | null; + public guildScheduledEvent: GuildScheduledEvent | null; + public uses: number | null; + public maxUses: number | null; + public temporary: boolean | null; + public approximatePresenceCount: WithCounts extends true ? number : null; + public get deletable(): boolean; + public delete(reason?: string): Promise; +} + +export class GroupDMInvite extends BaseInvite { + public readonly type: InviteType.GroupDM; + public channel: PartialGroupDMChannel | null; +} + +export type Invite = GroupDMInvite | GuildInvite; + export class InviteGuild extends AnonymousGuild { private constructor(client: Client, data: APIPartialGuild); public welcomeScreen: WelcomeScreen | null; @@ -2663,8 +2679,8 @@ export abstract class ThreadOnlyChannel extends GuildChannel { public setAvailableTags(tags: readonly GuildForumTagData[], reason?: string): Promise; public setDefaultReactionEmoji(emojiId: DefaultReactionEmoji | null, reason?: string): Promise; public setDefaultThreadRateLimitPerUser(rateLimit: number, reason?: string): Promise; - public createInvite(options?: InviteCreateOptions): Promise; - public fetchInvites(cache?: boolean): Promise>; + public createInvite(options?: InviteCreateOptions): Promise; + public fetchInvites(cache?: boolean): Promise>; public setDefaultAutoArchiveDuration( defaultAutoArchiveDuration: ThreadAutoArchiveDuration, reason?: string, @@ -4393,13 +4409,13 @@ export class GuildBanManager extends CachedManager; } -export class GuildInviteManager extends DataManager { +export class GuildInviteManager extends DataManager { private constructor(guild: Guild, iterable?: Iterable); public guild: Guild; - public create(channel: GuildInvitableChannelResolvable, options?: InviteCreateOptions): Promise; - public fetch(options: FetchInviteOptions | InviteResolvable): Promise; - public fetch(options?: FetchInvitesOptions): Promise>; - public delete(invite: InviteResolvable, reason?: string): Promise; + public create(channel: GuildInvitableChannelResolvable, options?: InviteCreateOptions): Promise; + public fetch(options: FetchInviteOptions | InviteResolvable): Promise; + public fetch(options?: FetchInvitesOptions): Promise>; + public delete(invite: InviteResolvable, reason?: string): Promise; } export class GuildScheduledEventManager extends CachedManager< @@ -5206,7 +5222,7 @@ export interface Caches { // TODO: GuildChannelManager: [manager: typeof GuildChannelManager, holds: typeof GuildChannel]; GuildEmojiManager: [manager: typeof GuildEmojiManager, holds: typeof GuildEmoji]; GuildForumThreadManager: [manager: typeof GuildForumThreadManager, holds: typeof ThreadChannel]; - GuildInviteManager: [manager: typeof GuildInviteManager, holds: typeof Invite]; + GuildInviteManager: [manager: typeof GuildInviteManager, holds: typeof GuildInvite]; // TODO: GuildManager: [manager: typeof GuildManager, holds: typeof Guild]; GuildMemberManager: [manager: typeof GuildMemberManager, holds: typeof GuildMember]; GuildMessageManager: [manager: typeof GuildMessageManager, holds: typeof Message]; @@ -5367,8 +5383,8 @@ export interface ClientEventTypes { guildUpdate: [oldGuild: Guild, newGuild: Guild]; interactionCreate: [interaction: Interaction]; invalidated: []; - inviteCreate: [invite: Invite]; - inviteDelete: [invite: Invite]; + inviteCreate: [invite: GuildInvite]; + inviteDelete: [invite: GuildInvite]; messageCreate: [message: OmitPartialGroupDMChannel]; messageDelete: [message: OmitPartialGroupDMChannel]; messageDeleteBulk: [ @@ -5430,6 +5446,7 @@ export interface ClientEventTypes { export interface ClientFetchInviteOptions { guildScheduledEventId?: Snowflake; + withCounts?: boolean; } export interface ClientOptions extends WebhookClientOptions { @@ -5989,7 +6006,7 @@ export interface GuildAuditLogsEntryTargetField { GuildOnboardingPrompt: GuildOnboardingPrompt | { [x: string]: unknown; id: Snowflake }; GuildScheduledEvent: GuildScheduledEvent; Integration: Integration; - Invite: Invite; + Invite: GuildInvite; Message: TAction extends AuditLogEvent.MessageBulkDelete ? GuildTextBasedChannel | { id: Snowflake } : User | null; Role: Role | { id: Snowflake }; SoundboardSound: SoundboardSound | { id: Snowflake }; @@ -6176,7 +6193,14 @@ export interface GuildMemberEditOptions { roles?: ReadonlyCollection | readonly RoleResolvable[]; } -export type GuildResolvable = Guild | GuildEmoji | GuildMember | Invite | NonThreadGuildBasedChannel | Role | Snowflake; +export type GuildResolvable = + | Guild + | GuildEmoji + | GuildInvite + | GuildMember + | NonThreadGuildBasedChannel + | Role + | Snowflake; export interface GuildPruneMembersOptions { count?: boolean; @@ -6411,14 +6435,9 @@ export interface InviteGenerationOptions { scopes: readonly OAuth2Scopes[]; } -export type GuildInvitableChannelResolvable = - | AnnouncementChannel - | ForumChannel - | MediaChannel - | Snowflake - | StageChannel - | TextChannel - | VoiceChannel; +export type GuildInvitableChannel = AnnouncementChannel | ForumChannel | MediaChannel | TextChannel | VoiceChannel; + +export type GuildInvitableChannelResolvable = GuildInvitableChannel | Snowflake; export interface InviteCreateOptions { maxAge?: number; @@ -6432,6 +6451,7 @@ export interface InviteCreateOptions { } export type InviteResolvable = string; +export type GuildInviteResolvable = string; export interface LifetimeFilterOptions { excludeFromSweep?(value: Value, key: Key, collection: LimitedCollection): boolean; @@ -7004,7 +7024,7 @@ export interface SweeperDefinitions { emojis: [Snowflake, GuildEmoji]; entitlements: [Snowflake, Entitlement]; guildMembers: [Snowflake, GuildMember]; - invites: [string, Invite, true]; + invites: [string, GuildInvite, true]; messages: [Snowflake, Message, true]; presences: [Snowflake, Presence]; reactions: [Snowflake | string, MessageReaction]; diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 7f2ea9e9d..58de5134e 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -199,6 +199,8 @@ import type { User, VoiceBasedChannel, VoiceChannel, + Invite, + GuildInvite, } from './index.js'; import { ActionRowBuilder, @@ -262,6 +264,10 @@ if (client.isReady()) { expectType(client); } +expectType>(client.fetchInvite('https://discord.gg/djs')); +expectType>>(client.fetchInvite('https://discord.gg/djs', { withCounts: true })); +expectNotType>>(client.fetchInvite('https://discord.gg/djs', { withCounts: false })); + const testGuildId = '222078108977594368'; // DJS const testUserId = '987654321098765432'; // example id const globalCommandId = '123456789012345678'; // example id @@ -407,8 +413,15 @@ client.on('interactionCreate', async interaction => { } }); -client.on('inviteCreate', ({ client }) => expectType>(client)); -client.on('inviteDelete', ({ client }) => expectType>(client)); +client.on('inviteCreate', invite => { + expectType(invite); + expectType>(invite.client); +}); + +client.on('inviteDelete', invite => { + expectType(invite); + expectType>(invite.client); +}); // This is to check that stuff is the right type declare const assertIsMessage: (m: Promise) => void;