diff --git a/src/managers/ChannelManager.js b/src/managers/ChannelManager.js index 128893ba48cb..1aeb292d162c 100644 --- a/src/managers/ChannelManager.js +++ b/src/managers/ChannelManager.js @@ -23,7 +23,7 @@ class ChannelManager extends BaseManager { const existing = this.cache.get(data.id); if (existing) { if (existing._patch && cache) existing._patch(data); - if (guild) guild.channels.add(existing); + if (guild) guild.channels?.add(existing); return existing; } diff --git a/src/structures/AnonymousGuild.js b/src/structures/AnonymousGuild.js new file mode 100644 index 000000000000..4b4bb5bf430b --- /dev/null +++ b/src/structures/AnonymousGuild.js @@ -0,0 +1,78 @@ +'use strict'; + +const BaseGuild = require('./BaseGuild'); +const { VerificationLevels, NSFWLevels } = require('../util/Constants'); + +/** + * Bundles common attributes and methods between {@link Guild} and {@link InviteGuild} + * @abstract + */ +class AnonymousGuild extends BaseGuild { + constructor(client, data) { + super(client, data); + this._patch(data); + } + + _patch(data) { + this.features = data.features; + /** + * The hash of the guild invite splash image + * @type {?string} + */ + this.splash = data.splash; + + /** + * The hash of the guild banner + * @type {?string} + */ + this.banner = data.banner; + + /** + * The description of the guild, if any + * @type {?string} + */ + this.description = data.description; + + /** + * The verification level of the guild + * @type {VerificationLevel} + */ + this.verificationLevel = VerificationLevels[data.verification_level]; + + /** + * The vanity invite code of the guild, if any + * @type {?string} + */ + this.vanityURLCode = data.vanity_url_code; + + if ('nsfw_level' in data) { + /** + * The NSFW level of this guild + * @type {NSFWLevel} + */ + this.nsfwLevel = NSFWLevels[data.nsfw_level]; + } + } + + /** + * The URL to this guild's banner. + * @param {ImageURLOptions} [options={}] Options for the Image URL + * @returns {?string} + */ + bannerURL({ format, size } = {}) { + if (!this.banner) return null; + return this.client.rest.cdn.Banner(this.id, this.banner, format, size); + } + + /** + * The URL to this guild's invite splash image. + * @param {ImageURLOptions} [options={}] Options for the Image URL + * @returns {?string} + */ + splashURL({ format, size } = {}) { + if (!this.splash) return null; + return this.client.rest.cdn.Splash(this.id, this.splash, format, size); + } +} + +module.exports = AnonymousGuild; diff --git a/src/structures/BaseGuild.js b/src/structures/BaseGuild.js index 561375f3c0d0..dfed553f087c 100644 --- a/src/structures/BaseGuild.js +++ b/src/structures/BaseGuild.js @@ -4,8 +4,9 @@ const Base = require('./Base'); const SnowflakeUtil = require('../util/SnowflakeUtil'); /** - * The base class for {@link Guild} and {@link OAuth2Guild}. + * The base class for {@link Guild}, {@link OAuth2Guild} and {@link InviteGuild}. * @extends {Base} + * @abstract */ class BaseGuild extends Base { constructor(client, data) { diff --git a/src/structures/Channel.js b/src/structures/Channel.js index f0c28eb3f53e..c86c9c8fffce 100644 --- a/src/structures/Channel.js +++ b/src/structures/Channel.js @@ -153,7 +153,7 @@ class Channel extends Base { break; } } - if (channel) guild.channels.cache.set(channel.id, channel); + if (channel) guild.channels?.cache.set(channel.id, channel); } } return channel; diff --git a/src/structures/Emoji.js b/src/structures/Emoji.js index e9f1306eec96..e89faf8b033e 100644 --- a/src/structures/Emoji.js +++ b/src/structures/Emoji.js @@ -20,9 +20,9 @@ class Emoji extends Base { super(client); /** * Whether this emoji is animated - * @type {boolean} + * @type {?boolean} */ - this.animated = emoji.animated; + this.animated = emoji.animated ?? null; /** * The name of this emoji diff --git a/src/structures/Guild.js b/src/structures/Guild.js index 945976bad0b5..36f09e7a478f 100644 --- a/src/structures/Guild.js +++ b/src/structures/Guild.js @@ -1,12 +1,13 @@ 'use strict'; -const BaseGuild = require('./BaseGuild'); +const AnonymousGuild = require('./AnonymousGuild'); const GuildAuditLogs = require('./GuildAuditLogs'); const GuildPreview = require('./GuildPreview'); const GuildTemplate = require('./GuildTemplate'); const Integration = require('./Integration'); const Invite = require('./Invite'); const Webhook = require('./Webhook'); +const WelcomeScreen = require('./WelcomeScreen'); const { Error, TypeError } = require('../errors'); const GuildApplicationCommandManager = require('../managers/GuildApplicationCommandManager'); const GuildBanManager = require('../managers/GuildBanManager'); @@ -24,7 +25,6 @@ const { PartialTypes, VerificationLevels, ExplicitContentFilterLevels, - NSFWLevels, Status, MFALevels, PremiumTiers, @@ -37,9 +37,9 @@ const Util = require('../util/Util'); * Represents a guild (or a server) on Discord. * It's recommended to see if a guild is available before performing operations or reading data from it. You can * check this with `guild.available`. - * @extends {BaseGuild} + * @extends {AnonymousGuild} */ -class Guild extends BaseGuild { +class Guild extends AnonymousGuild { constructor(client, data) { super(client, data); @@ -131,18 +131,12 @@ class Guild extends BaseGuild { * @private */ _patch(data) { + super._patch(data); this.id = data.id; this.name = data.name; this.icon = data.icon; - this.features = data.features; this.available = !data.unavailable; - /** - * The hash of the guild invite splash image - * @type {?string} - */ - this.splash = data.splash; - /** * The hash of the guild discovery splash image * @type {?string} @@ -155,14 +149,6 @@ class Guild extends BaseGuild { */ this.memberCount = data.member_count || this.memberCount; - if ('nsfw_level' in data) { - /** - * The NSFW level of this guild - * @type {NSFWLevel} - */ - this.nsfwLevel = NSFWLevels[data.nsfw_level]; - } - /** * Whether the guild is "large" (has more than large_threshold members, 50 by default) * @type {boolean} @@ -247,12 +233,6 @@ class Guild extends BaseGuild { this.widgetChannelID = data.widget_channel_id; } - /** - * The verification level of the guild - * @type {VerificationLevel} - */ - this.verificationLevel = VerificationLevels[data.verification_level]; - /** * The explicit content filter level of the guild * @type {ExplicitContentFilterLevel} @@ -326,12 +306,6 @@ class Guild extends BaseGuild { this.approximatePresenceCount = null; } - /** - * The vanity invite code of the guild, if any - * @type {?string} - */ - this.vanityURLCode = data.vanity_url_code; - /** * The use count of the vanity URL code of the guild, if any * You will need to fetch this parameter using {@link Guild#fetchVanityData} if you want to receive it @@ -339,18 +313,6 @@ class Guild extends BaseGuild { */ this.vanityURLUses = null; - /** - * The description of the guild, if any - * @type {?string} - */ - this.description = data.description; - - /** - * The hash of the guild banner - * @type {?string} - */ - this.banner = data.banner; - /** * The ID of the rules channel for the guild * @type {?Snowflake} @@ -576,6 +538,15 @@ class Guild extends BaseGuild { ); } + /** + * Fetches the welcome screen for this guild. + * @returns {Promise} + */ + async fetchWelcomeScreen() { + const data = await this.client.api.guilds(this.id, 'welcome-screen').get(); + return new WelcomeScreen(this, data); + } + /** * The data for creating an integration. * @typedef {Object} IntegrationData @@ -900,6 +871,60 @@ class Guild extends BaseGuild { .then(newData => this.client.actions.GuildUpdate.handle(newData).updated); } + /** + * Welcome channel data + * @typedef {Object} WelcomeChannelData + * @property {string} description The description to show for this welcome channel + * @property {GuildTextChannelResolvable} channel The channel to link for this welcome channel + * @property {EmojiIdentifierResolvable} [emoji] The emoji to display for this welcome channel + */ + + /** + * Welcome screen edit data + * @typedef {Object} WelcomeScreenEditData + * @property {boolean} [enabled] Whether the welcome screen is enabled + * @property {string} [description] The description for the welcome screen + * @property {WelcomeChannelData[]} [welcomeChannels] The welcome channel data for the welcome screen + */ + + /** + * Updates the guild's welcome screen + * @param {WelcomeScreenEditData} data Data to edit the welcome screen with + * @returns {Promise} + * @example + * guild.editWelcomeScreen({ + * description: 'Hello World', + * enabled: true, + * welcomeChannels: [ + * { + * description: 'foobar', + * channel: '222197033908436994', + * } + * ], + * }) + */ + async editWelcomeScreen(data) { + const { enabled, description, welcomeChannels } = data; + const welcome_channels = welcomeChannels?.map(welcomeChannelData => { + const emoji = this.emojis.resolve(welcomeChannelData.emoji); + return { + emoji_id: emoji?.id ?? null, + emoji_name: emoji?.name ?? welcomeChannelData.emoji, + channel_id: this.channels.resolveID(welcomeChannelData.channel), + description: welcomeChannelData.description, + }; + }); + + const patchData = await this.client.api.guilds(this.id, 'welcome-screen').patch({ + data: { + welcome_channels, + description, + enabled, + }, + }); + return new WelcomeScreen(this, patchData); + } + /** * Edits the level of the explicit content filter. * @param {ExplicitContentFilterLevel|number} explicitContentFilter The new level of the explicit content filter diff --git a/src/structures/Invite.js b/src/structures/Invite.js index c123e527a80e..e72082aaa7ab 100644 --- a/src/structures/Invite.js +++ b/src/structures/Invite.js @@ -19,11 +19,16 @@ class Invite extends Base { } _patch(data) { + const InviteGuild = require('./InviteGuild'); + const Guild = require('./Guild'); /** - * The guild the invite is for - * @type {?Guild} + * The guild the invite is for including welcome screen data if present + * @type {?(Guild|InviteGuild)} */ - this.guild = data.guild ? this.client.guilds.add(data.guild, false) : null; + this.guild = null; + if (data.guild) { + this.guild = data.guild instanceof Guild ? data.guild : new InviteGuild(this.client, data.guild); + } /** * The code for this invite diff --git a/src/structures/InviteGuild.js b/src/structures/InviteGuild.js new file mode 100644 index 000000000000..ab1aed492144 --- /dev/null +++ b/src/structures/InviteGuild.js @@ -0,0 +1,23 @@ +'use strict'; + +const AnonymousGuild = require('./AnonymousGuild'); +const WelcomeScreen = require('./WelcomeScreen'); + +/** + * Represents a guild received from an invite, includes welcome screen data if available. + * @extends {AnonymousGuild} + */ +class InviteGuild extends AnonymousGuild { + constructor(client, data) { + super(client, data); + + /** + * The welcome screen for this invite guild + * @type {?WelcomeScreen} + */ + this.welcomeScreen = + typeof data.welcome_screen !== 'undefined' ? new WelcomeScreen(this, data.welcome_screen) : null; + } +} + +module.exports = InviteGuild; diff --git a/src/structures/WelcomeChannel.js b/src/structures/WelcomeChannel.js new file mode 100644 index 000000000000..21b6b51179d5 --- /dev/null +++ b/src/structures/WelcomeChannel.js @@ -0,0 +1,60 @@ +'use strict'; + +const Base = require('./Base'); +const Emoji = require('./Emoji'); + +/** + * Represents a channel link in a guild's welcome screen. + * @extends {Base} + */ +class WelcomeChannel extends Base { + constructor(guild, data) { + super(guild.client); + + /** + * The guild for this welcome channel + * @type {Guild|WelcomeGuild} + */ + this.guild = guild; + + /** + * The description of this welcome channel + * @type {string} + */ + this.description = data.description; + + /** + * The raw emoji data + * @type {Object} + * @private + */ + this._emoji = { + name: data.emoji_name, + id: data.emoji_id, + }; + + /** + * The id of this welcome channel + * @type {Snowflake} + */ + this.channelID = data.channel_id; + } + + /** + * The channel of this welcome channel + * @type {?TextChannel|NewsChannel} + */ + get channel() { + return this.client.channels.resolve(this.channelID); + } + + /** + * The emoji of this welcome channel + * @type {GuildEmoji|Emoji} + */ + get emoji() { + return this.client.emojis.resolve(this._emoji.id) ?? new Emoji(this.client, this._emoji); + } +} + +module.exports = WelcomeChannel; diff --git a/src/structures/WelcomeScreen.js b/src/structures/WelcomeScreen.js new file mode 100644 index 000000000000..2b8d176b298c --- /dev/null +++ b/src/structures/WelcomeScreen.js @@ -0,0 +1,48 @@ +'use strict'; + +const Base = require('./Base'); +const WelcomeChannel = require('./WelcomeChannel'); +const Collection = require('../util/Collection'); + +/** + * Represents a welcome screen. + * @extends {Base} + */ +class WelcomeScreen extends Base { + constructor(guild, data) { + super(guild.client); + + /** + * The guild for this welcome screen + * @type {Guild} + */ + this.guild = guild; + + /** + * The description of this welcome screen + * @type {?string} + */ + this.description = data.description ?? null; + + /** + * Collection of welcome channels belonging to this welcome screen + * @type {Collection} + */ + this.welcomeChannels = new Collection(); + + for (const channel of data.welcome_channels) { + const welcomeChannel = new WelcomeChannel(this.guild, channel); + this.welcomeChannels.set(welcomeChannel.channelID, welcomeChannel); + } + } + + /** + * Whether the welcome screen is enabled on the guild or not + * @type {boolean} + */ + get enabled() { + return this.guild.features.includes('WELCOME_SCREEN_ENABLED'); + } +} + +module.exports = WelcomeScreen; diff --git a/typings/index.d.ts b/typings/index.d.ts index 694d6f8f433b..a7bfef6c343c 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -187,6 +187,17 @@ declare module 'discord.js' { public static resolve(bit?: BitFieldResolvable): number; } + export abstract class AnonymousGuild extends BaseGuild { + public banner: string | null; + public description: string | null; + public nsfwLevel: NSFWLevel; + public splash: string | null; + public vanityURLCode: string | null; + public verificationLevel: VerificationLevel; + public bannerURL(options?: StaticImageURLOptions): string | null; + public splashURL(options?: StaticImageURLOptions): string | null; + } + export class APIMessage { constructor(target: MessageTarget, options: MessageOptions | WebhookMessageOptions); public data: unknown | null; @@ -282,7 +293,7 @@ declare module 'discord.js' { public toJSON(...props: { [key: string]: boolean | string }[]): unknown; } - export class BaseGuild extends Base { + export abstract class BaseGuild extends Base { public readonly createdAt: Date; public readonly createdTimestamp: number; public features: GuildFeatures[]; @@ -759,7 +770,7 @@ declare module 'discord.js' { export class Emoji extends Base { constructor(client: Client, emoji: unknown); - public animated: boolean; + public animated: boolean | null; public readonly createdAt: Date | null; public readonly createdTimestamp: number | null; public deleted: boolean; @@ -771,7 +782,7 @@ declare module 'discord.js' { public toString(): string; } - export class Guild extends BaseGuild { + export class Guild extends AnonymousGuild { constructor(client: Client, data: unknown); private _sortedRoles(): Collection; private _sortedChannels(channel: Channel): Collection; @@ -783,13 +794,11 @@ declare module 'discord.js' { public approximateMemberCount: number | null; public approximatePresenceCount: number | null; public available: boolean; - public banner: string | null; public bans: GuildBanManager; public channels: GuildChannelManager; public commands: GuildApplicationCommandManager; public defaultMessageNotifications: DefaultMessageNotificationLevel | number; public deleted: boolean; - public description: string | null; public discoverySplash: string | null; public emojis: GuildEmojiManager; public explicitContentFilter: ExplicitContentFilterLevel; @@ -802,7 +811,6 @@ declare module 'discord.js' { public memberCount: number; public members: GuildMemberManager; public mfaLevel: MFALevel; - public nsfwLevel: NSFWLevel; public ownerID: Snowflake; public preferredLocale: string; public premiumSubscriptionCount: number | null; @@ -815,26 +823,23 @@ declare module 'discord.js' { public rulesChannelID: Snowflake | null; public readonly shard: WebSocketShard; public shardID: number; - public splash: string | null; public stageInstances: StageInstanceManager; public readonly systemChannel: TextChannel | null; public systemChannelFlags: Readonly; public systemChannelID: Snowflake | null; - public vanityURLCode: string | null; public vanityURLUses: number | null; - public verificationLevel: VerificationLevel; public readonly voiceAdapterCreator: DiscordGatewayAdapterCreator; public readonly voiceStates: VoiceStateManager; public readonly widgetChannel: TextChannel | null; public widgetChannelID: Snowflake | null; public widgetEnabled: boolean | null; public addMember(user: UserResolvable, options: AddGuildMemberOptions): Promise; - public bannerURL(options?: StaticImageURLOptions): string | null; public createIntegration(data: IntegrationData, reason?: string): Promise; public createTemplate(name: string, description?: string): Promise; public delete(): Promise; public discoverySplashURL(options?: StaticImageURLOptions): string | null; public edit(data: GuildEditData, reason?: string): Promise; + public editWelcomeScreen(data: WelcomeScreenEditData): Promise; public equals(guild: Guild): boolean; public fetchAuditLogs(options?: GuildAuditLogsFetchOptions): Promise; public fetchIntegrations(): Promise>; @@ -845,6 +850,7 @@ declare module 'discord.js' { public fetchVanityData(): Promise; public fetchVoiceRegions(): Promise>; public fetchWebhooks(): Promise>; + public fetchWelcomeScreen(): Promise; public fetchWidget(): Promise; public leave(): Promise; public setAFKChannel(afkChannel: ChannelResolvable | null, reason?: string): Promise; @@ -872,7 +878,6 @@ declare module 'discord.js' { public setSystemChannelFlags(systemChannelFlags: SystemChannelFlagsResolvable, reason?: string): Promise; public setVerificationLevel(verificationLevel: VerificationLevel | number, reason?: string): Promise; public setWidget(widget: GuildWidgetData, reason?: string): Promise; - public splashURL(options?: StaticImageURLOptions): string | null; public toJSON(): unknown; } @@ -1176,7 +1181,7 @@ declare module 'discord.js' { public createdTimestamp: number | null; public readonly expiresAt: Date | null; public readonly expiresTimestamp: number | null; - public guild: Guild | null; + public guild: InviteGuild | Guild | null; public inviter: User | null; public maxAge: number | null; public maxUses: number | null; @@ -1207,6 +1212,11 @@ declare module 'discord.js' { public readonly guild: Guild | null; } + export class InviteGuild extends AnonymousGuild { + constructor(client: Client, data: unknown); + public welcomeScreen: WelcomeScreen | null; + } + export class Message extends Base { constructor(client: Client, data: unknown, channel: TextChannel | DMChannel | NewsChannel); private patch(data: unknown): Message; @@ -2117,6 +2127,22 @@ declare module 'discord.js' { public activity?: WidgetActivity; } + export class WelcomeChannel extends Base { + private _emoji: unknown; + public channelID: Snowflake; + public guild: Guild | InviteGuild; + public description: string; + public readonly channel: TextChannel | NewsChannel | null; + public readonly emoji: GuildEmoji | Emoji; + } + + export class WelcomeScreen extends Base { + public readonly enabled: boolean; + public guild: Guild | InviteGuild; + public description: string | null; + public welcomeChannels: Collection; + } + //#endregion //#region Collections @@ -2714,6 +2740,7 @@ declare module 'discord.js' { position?: number; } + type GuildTextChannelResolvable = TextChannel | NewsChannel | Snowflake; type ChannelResolvable = Channel | Snowflake; interface ChannelWebhookCreateOptions { @@ -3967,6 +3994,18 @@ declare module 'discord.js' { position: number; } + interface WelcomeChannelData { + description: string; + channel: GuildChannelResolvable; + emoji?: EmojiIdentifierResolvable; + } + + interface WelcomeScreenEditData { + enabled?: boolean; + description?: string; + welcomeChannels?: WelcomeChannelData[]; + } + type WSEventType = | 'READY' | 'RESUMED'