feat: backport guild forum support to v13 (#8651)

Co-authored-by: Jaworek <jaworekwiadomosci@gmail.com>
Co-authored-by: Sugden <28943913+NotSugden@users.noreply.github.com>
This commit is contained in:
Elysia
2023-01-02 22:21:15 +07:00
committed by GitHub
parent 56e67185fc
commit 546ac43911
24 changed files with 903 additions and 127 deletions

View File

@@ -2,8 +2,8 @@
const GuildChannel = require('./GuildChannel');
const TextBasedChannel = require('./interfaces/TextBasedChannel');
const GuildTextThreadManager = require('../managers/GuildTextThreadManager');
const MessageManager = require('../managers/MessageManager');
const ThreadManager = require('../managers/ThreadManager');
/**
* Represents a text-based guild channel on Discord.
@@ -22,9 +22,9 @@ class BaseGuildTextChannel extends GuildChannel {
/**
* A manager of the threads belonging to this channel
* @type {ThreadManager}
* @type {GuildTextThreadManager}
*/
this.threads = new ThreadManager(this);
this.threads = new GuildTextThreadManager(this);
/**
* If the guild considers this channel NSFW

View File

@@ -30,16 +30,25 @@ class CategoryChannel extends GuildChannel {
/**
* Options for creating a channel using {@link CategoryChannel#createChannel}.
* @typedef {Object} CategoryCreateChannelOptions
* @property {string} [name] The name of the new channel
* @property {ChannelType|number} [type='GUILD_TEXT'] The type of the new channel.
* @property {number} [position] Position of the new channel
* @property {string} [topic] The topic for the new channel
* @property {boolean} [nsfw] Whether the new channel is NSFW
* @property {number} [bitrate] Bitrate of the new channel in bits (only voice)
* @property {number} [userLimit] Maximum amount of users allowed in the new channel (only voice)
* @property {OverwriteResolvable[]|Collection<Snowflake, OverwriteResolvable>} [permissionOverwrites]
* Permission overwrites of the new channel
* @property {number} [position] Position of the new channel
* @property {number} [rateLimitPerUser] The rate limit per user (slowmode) for the new channel in seconds
* @property {string} [rtcRegion] The specific region of the new channel.
* @property {ThreadAutoArchiveDuration} [defaultAutoArchiveDuration]
* The default auto archive duration for all new threads in this channel
* @property {?string} [rtcRegion] The specific region of the new channel
* @property {?VideoQualityMode|number} [videoQualityMode] The camera video quality mode of the new channel
* @property {ChannelFlagsResolvable} [flags] The flags to set on the new channel
* @property {GuildForumTagData[]} [availableTags] The tags to set as available in a forum channel
* @property {?DefaultReactionEmoji} [defaultReactionEmoji] The emoji to set as the default reaction emoji
* @property {number} [defaultThreadRateLimitPerUser] The rate limit per user (slowmode) to set on forum posts
* @property {?SortOrderType} [defaultSortOrder] The default sort order mode to set on the new channel
* @property {string} [reason] Reason for creating the new channel
*/

View File

@@ -11,6 +11,8 @@ let TextChannel;
let ThreadChannel;
let VoiceChannel;
let DirectoryChannel;
let ForumChannel;
const ChannelFlags = require('../util/ChannelFlags');
const { ChannelTypes, ThreadChannelTypes, VoiceBasedChannelTypes } = require('../util/Constants');
const SnowflakeUtil = require('../util/SnowflakeUtil');
@@ -47,6 +49,17 @@ class Channel extends Base {
* @type {Snowflake}
*/
this.id = data.id;
if ('flags' in data) {
/**
* The flags that are applied to the channel.
* <info>This is only `null` in a {@link PartialGroupDMChannel}. In all other cases, it is not `null`.</info>
* @type {?Readonly<ChannelFlags>}
*/
this.flags = new ChannelFlags(data.flags).freeze();
} else {
this.flags ??= new ChannelFlags().freeze();
}
}
/**
@@ -183,6 +196,7 @@ class Channel extends Base {
ThreadChannel ??= require('./ThreadChannel');
VoiceChannel ??= require('./VoiceChannel');
DirectoryChannel ??= require('./DirectoryChannel');
ForumChannel ??= require('./ForumChannel');
let channel;
if (!data.guild_id && !guild) {
@@ -232,6 +246,10 @@ class Channel extends Base {
case ChannelTypes.GUILD_DIRECTORY:
channel = new DirectoryChannel(client, data);
break;
case ChannelTypes.GUILD_FORUM:
channel = new ForumChannel(guild, data, client);
break;
}
if (channel && !allowUnknownGuild) guild.channels?.cache.set(channel.id, channel);
}

View File

@@ -0,0 +1,264 @@
'use strict';
const GuildChannel = require('./GuildChannel');
const TextBasedChannel = require('./interfaces/TextBasedChannel');
const GuildForumThreadManager = require('../managers/GuildForumThreadManager');
const { SortOrderTypes, ForumLayoutTypes } = require('../util/Constants');
const { transformAPIGuildForumTag, transformAPIGuildDefaultReaction } = require('../util/Util');
/**
* @typedef {Object} GuildForumTagEmoji
* @property {?Snowflake} id The id of a guild's custom emoji
* @property {?string} name The unicode character of the emoji
*/
/**
* @typedef {Object} GuildForumTag
* @property {Snowflake} id The id of the tag
* @property {string} name The name of the tag
* @property {boolean} moderated Whether this tag can only be added to or removed from threads
* by a member with the `ManageThreads` permission
* @property {?GuildForumTagEmoji} emoji The emoji of this tag
*/
/**
* @typedef {Object} GuildForumTagData
* @property {Snowflake} [id] The id of the tag
* @property {string} name The name of the tag
* @property {boolean} [moderated] Whether this tag can only be added to or removed from threads
* by a member with the `ManageThreads` permission
* @property {?GuildForumTagEmoji} [emoji] The emoji of this tag
*/
/**
* @typedef {Object} DefaultReactionEmoji
* @property {?Snowflake} id The id of a guild's custom emoji
* @property {?string} name The unicode character of the emoji
*/
/**
* Represents a channel that only contains threads
* @extends {GuildChannel}
* @implements {TextBasedChannel}
*/
class ForumChannel extends GuildChannel {
constructor(guild, data, client) {
super(guild, data, client, false);
/**
* A manager of the threads belonging to this channel
* @type {GuildForumThreadManager}
*/
this.threads = new GuildForumThreadManager(this);
this._patch(data);
}
_patch(data) {
super._patch(data);
if ('available_tags' in data) {
/**
* The set of tags that can be used in this channel.
* @type {GuildForumTag[]}
*/
this.availableTags = data.available_tags.map(tag => transformAPIGuildForumTag(tag));
} else {
this.availableTags ??= [];
}
if ('default_reaction_emoji' in data) {
/**
* The emoji to show in the add reaction button on a thread in a guild forum channel
* @type {?DefaultReactionEmoji}
*/
this.defaultReactionEmoji =
data.default_reaction_emoji && transformAPIGuildDefaultReaction(data.default_reaction_emoji);
} else {
this.defaultReactionEmoji ??= null;
}
if ('default_thread_rate_limit_per_user' in data) {
/**
* The initial rate limit per user (slowmode) to set on newly created threads in a channel.
* @type {?number}
*/
this.defaultThreadRateLimitPerUser = data.default_thread_rate_limit_per_user;
} else {
this.defaultThreadRateLimitPerUser ??= null;
}
if ('rate_limit_per_user' in data) {
/**
* The rate limit per user (slowmode) for this channel.
* @type {?number}
*/
this.rateLimitPerUser = data.rate_limit_per_user;
} else {
this.rateLimitPerUser ??= null;
}
if ('default_auto_archive_duration' in data) {
/**
* The default auto archive duration for newly created threads in this channel.
* @type {?ThreadAutoArchiveDuration}
*/
this.defaultAutoArchiveDuration = data.default_auto_archive_duration;
} else {
this.defaultAutoArchiveDuration ??= null;
}
if ('nsfw' in data) {
/**
* If this channel is considered NSFW.
* @type {boolean}
*/
this.nsfw = data.nsfw;
} else {
this.nsfw ??= false;
}
if ('topic' in data) {
/**
* The topic of this channel.
* @type {?string}
*/
this.topic = data.topic;
}
if ('default_sort_order' in data) {
/**
* The default sort order mode used to order posts
* @type {?SortOrderType}
*/
this.defaultSortOrder = SortOrderTypes[data.default_sort_order];
} else {
this.defaultSortOrder ??= null;
}
/**
* The default layout type used to display posts
* @type {ForumLayoutType}
*/
this.defaultForumLayout = ForumLayoutTypes[data.default_forum_layout];
}
/**
* Sets the available tags for this forum channel
* @param {GuildForumTagData[]} availableTags The tags to set as available in this channel
* @param {string} [reason] Reason for changing the available tags
* @returns {Promise<ForumChannel>}
*/
setAvailableTags(availableTags, reason) {
return this.edit({ availableTags }, reason);
}
/**
* Sets the default reaction emoji for this channel
* @param {?DefaultReactionEmoji} defaultReactionEmoji The emoji to set as the default reaction emoji
* @param {string} [reason] Reason for changing the default reaction emoji
* @returns {Promise<ForumChannel>}
*/
setDefaultReactionEmoji(defaultReactionEmoji, reason) {
return this.edit({ defaultReactionEmoji }, reason);
}
/**
* Sets the default rate limit per user (slowmode) for new threads in this channel
* @param {number} defaultThreadRateLimitPerUser The rate limit to set on newly created threads in this channel
* @param {string} [reason] Reason for changing the default rate limit
* @returns {Promise<ForumChannel>}
*/
setDefaultThreadRateLimitPerUser(defaultThreadRateLimitPerUser, reason) {
return this.edit({ defaultThreadRateLimitPerUser }, reason);
}
/**
* Sets the default sort order mode used to order posts
* @param {?SortOrderType} defaultSortOrder The default sort order mode to set on this channel
* @param {string} [reason] Reason for changing the default sort order
* @returns {Promise<ForumChannel>}
*/
setDefaultSortOrder(defaultSortOrder, reason) {
return this.edit({ defaultSortOrder }, reason);
}
/**
* Sets the default forum layout type used to display posts
* @param {ForumLayoutType} defaultForumLayout The default forum layout type to set on this channel
* @param {string} [reason] Reason for changing the default forum layout
* @returns {Promise<ForumChannel>}
*/
setDefaultForumLayout(defaultForumLayout, reason) {
return this.edit({ defaultForumLayout }, reason);
}
/**
* Creates an invite to this guild channel.
* @param {CreateInviteOptions} [options={}] The options for creating the invite
* @returns {Promise<Invite>}
* @example
* // Create an invite to a channel
* channel.createInvite()
* .then(invite => console.log(`Created an invite with a code of ${invite.code}`))
* .catch(console.error);
*/
createInvite(options) {
return this.guild.invites.create(this.id, options);
}
/**
* Fetches a collection of invites to this guild channel.
* Resolves with a collection mapping invites by their codes.
* @param {boolean} [cache=true] Whether or not to cache the fetched invites
* @returns {Promise<Collection<string, Invite>>}
*/
fetchInvites(cache = true) {
return this.guild.invites.fetch({ channelId: this.id, cache });
}
/**
* Sets the default auto archive duration for all newly created threads in this channel.
* @param {ThreadAutoArchiveDuration} defaultAutoArchiveDuration The new default auto archive duration
* @param {string} [reason] Reason for changing the channel's default auto archive duration
* @returns {Promise<ForumChannel>}
*/
setDefaultAutoArchiveDuration(defaultAutoArchiveDuration, reason) {
return this.edit({ defaultAutoArchiveDuration }, reason);
}
/**
* Sets a new topic for the guild channel.
* @param {?string} topic The new topic for the guild channel
* @param {string} [reason] Reason for changing the guild channel's topic
* @returns {Promise<ForumChannel>}
* @example
* // Set a new channel topic
* channel.setTopic('needs more rate limiting')
* .then(newChannel => console.log(`Channel's new topic is ${newChannel.topic}`))
* .catch(console.error);
*/
setTopic(topic, reason) {
return this.edit({ topic }, reason);
}
// These are here only for documentation purposes - they are implemented by TextBasedChannel
/* eslint-disable no-empty-function */
createWebhook() {}
fetchWebhooks() {}
setNSFW() {}
setRateLimitPerUser() {}
}
TextBasedChannel.applyToClass(ForumChannel, true, [
'send',
'lastMessage',
'lastPinAt',
'bulkDelete',
'sendTyping',
'createMessageCollector',
'awaitMessages',
'createMessageComponentCollector',
'awaitMessageComponent',
]);
module.exports = ForumChannel;

View File

@@ -562,7 +562,7 @@ class Guild extends AnonymousGuild {
/**
* Widget channel for this guild
* @type {?TextChannel}
* @type {?(TextChannel|NewsChannel|VoiceChannel|StageChannel|ForumChannel)}
* @readonly
*/
get widgetChannel() {

View File

@@ -14,6 +14,7 @@ const Permissions = require('../util/Permissions');
* - {@link NewsChannel}
* - {@link StoreChannel}
* - {@link StageChannel}
* - {@link ForumChannel}
* @extends {Channel}
* @abstract
*/

View File

@@ -58,6 +58,17 @@ class Message extends Base {
*/
this.id = data.id;
if ('position' in data) {
/**
* A generally increasing integer (there may be gaps or duplicates) that represents
* the approximate position of the message in a thread.
* @type {?number}
*/
this.position = data.position;
} else {
this.position ??= null;
}
/**
* The timestamp the message was sent at
* @type {number}
@@ -855,9 +866,10 @@ class Message extends Base {
* archived. This can be:
* * `60` (1 hour)
* * `1440` (1 day)
* * `4320` (3 days) <warn>This is only available when the guild has the `THREE_DAY_THREAD_ARCHIVE` feature.</warn>
* * `10080` (7 days) <warn>This is only available when the guild has the `SEVEN_DAY_THREAD_ARCHIVE` feature.</warn>
* * `'MAX'` Based on the guild's features
* * `4320` (3 days)
* * `10080` (7 days)
* * `'MAX'` (7 days)
* <warn>This option is deprecated and will be removed in the next major version.</warn>
* @typedef {number|string} ThreadAutoArchiveDuration
*/
@@ -873,7 +885,7 @@ class Message extends Base {
/**
* Create a new public thread from this message
* @see ThreadManager#create
* @see GuildTextThreadManager#create
* @param {StartThreadOptions} [options] Options for starting a thread on this message
* @returns {Promise<ThreadChannel>}
*/

View File

@@ -142,9 +142,11 @@ class MessagePayload {
let username;
let avatarURL;
let threadName;
if (isWebhook) {
username = this.options.username ?? this.target.name;
if (this.options.avatarURL) avatarURL = this.options.avatarURL;
if (this.options.threadName) threadName = this.options.threadName;
}
let flags;
@@ -208,6 +210,7 @@ class MessagePayload {
message_reference,
attachments: this.options.attachments,
sticker_ids: this.options.stickers?.map(sticker => sticker.id ?? sticker),
thread_name: threadName,
};
return this;
}

View File

@@ -11,6 +11,9 @@ class PartialGroupDMChannel extends Channel {
constructor(client, data) {
super(client, data);
// No flags are present when fetching partial group DM channels.
this.flags = null;
/**
* The name of this Group DM Channel
* @type {?string}

View File

@@ -5,6 +5,7 @@ const TextBasedChannel = require('./interfaces/TextBasedChannel');
const { RangeError } = require('../errors');
const MessageManager = require('../managers/MessageManager');
const ThreadMemberManager = require('../managers/ThreadMemberManager');
const ChannelFlags = require('../util/ChannelFlags');
const Permissions = require('../util/Permissions');
const { resolveAutoArchiveMaxLimit } = require('../util/Util');
@@ -158,9 +159,8 @@ class ThreadChannel extends Channel {
if ('message_count' in data) {
/**
* The approximate count of messages in this thread
* <info>This stops counting at 50. If you need an approximate value higher than that, use
* `ThreadChannel#messages.cache.size`</info>
* <info>Threads created before July 1, 2022 may have an inaccurate count.
* If you need an approximate value higher than that, use `ThreadChannel#messages.cache.size`</info>
* @type {?number}
*/
this.messageCount = data.message_count;
@@ -180,6 +180,27 @@ class ThreadChannel extends Channel {
this.memberCount ??= null;
}
if ('total_message_sent' in data) {
/**
* The number of messages ever sent in a thread, similar to {@link ThreadChannel#messageCount} except it
* will not decrement whenever a message is deleted
* @type {?number}
*/
this.totalMessageSent = data.total_message_sent;
} else {
this.totalMessageSent ??= null;
}
if ('applied_tags' in data) {
/**
* The tags applied to this thread
* @type {Snowflake[]}
*/
this.appliedTags = data.applied_tags;
} else {
this.appliedTags ??= [];
}
if (data.member && this.client.user) this.members._add({ user_id: this.client.user.id, ...data.member });
if (data.messages) for (const message of data.messages) this.messages._add(message);
}
@@ -225,7 +246,7 @@ class ThreadChannel extends Channel {
/**
* The parent channel of this thread
* @type {?(NewsChannel|TextChannel)}
* @type {?(NewsChannel|TextChannel|ForumChannel)}
* @readonly
*/
get parent() {
@@ -280,14 +301,16 @@ class ThreadChannel extends Channel {
/**
* Fetches the message that started this thread, if any.
* <info>This only works when the thread started from a message in the parent channel, otherwise the promise will
* reject. If you just need the id of that message, use {@link ThreadChannel#id} instead.</info>
* <info>The `Promise` will reject if the original message in a forum post is deleted
* or when the original message in the parent channel is deleted.
* If you just need the id of that message, use {@link ThreadChannel#id} instead.</info>
* @param {BaseFetchOptions} [options] Additional options for this fetch
* @returns {Promise<Message|null>}
*/
// eslint-disable-next-line require-await
async fetchStarterMessage(options) {
return this.parent?.messages.fetch(this.id, options) ?? null;
const channel = this.parent?.type === 'GUILD_FORUM' ? this : this.parent;
return channel?.messages.fetch({ message: this.id, ...options }) ?? null;
}
/**
@@ -300,6 +323,7 @@ class ThreadChannel extends Channel {
* @property {number} [rateLimitPerUser] The rate limit per user (slowmode) for the thread in seconds
* @property {boolean} [locked] Whether the thread is locked
* @property {boolean} [invitable] Whether non-moderators can add other non-moderators to a thread
* @property {ChannelFlagsResolvable} [flags] The flags to set on the channel
* <info>Can only be edited on `GUILD_PRIVATE_THREAD`</info>
*/
@@ -326,6 +350,8 @@ class ThreadChannel extends Channel {
rate_limit_per_user: data.rateLimitPerUser,
locked: data.locked,
invitable: this.type === 'GUILD_PRIVATE_THREAD' ? data.invitable : undefined,
applied_tags: data.appliedTags,
flags: 'flags' in data ? ChannelFlags.resolve(data.flags) : undefined,
},
reason,
});
@@ -419,6 +445,34 @@ class ThreadChannel extends Channel {
return this.edit({ rateLimitPerUser }, reason);
}
/**
* Pins this thread from the forum channel.
* @param {string} [reason] Reason for pinning
* @returns {Promise<ThreadChannel>}
*/
pin(reason) {
return this.edit({ flags: this.flags.add(ChannelFlags.FLAGS.PINNED), reason });
}
/**
* Unpins this thread from the forum channel.
* @param {string} [reason] Reason for unpinning
* @returns {Promise<ThreadChannel>}
*/
unpin(reason) {
return this.edit({ flags: this.flags.remove(ChannelFlags.FLAGS.PINNED), reason });
}
/**
* Set the applied tags for this channel (only applicable to forum threads)
* @param {Snowflake[]} appliedTags The tags to set for this channel
* @param {string} [reason] Reason for changing the thread's applied tags
* @returns {Promise<GuildForumThreadChannel>}
*/
setAppliedTags(appliedTags, reason) {
return this.edit({ appliedTags, reason });
}
/**
* Whether the client user is a member of the thread.
* @type {boolean}

View File

@@ -131,6 +131,7 @@ class Webhook {
* Action rows containing interactive components for the message (buttons, select menus)
* @property {Snowflake} [threadId] The id of the thread this message belongs to
* <info>For interaction webhooks, this property is ignored</info>
* @property {string} [threadName] Name of the thread to create (only available if webhook is in a forum channel)
*/
/**

View File

@@ -42,7 +42,7 @@ class WelcomeChannel extends Base {
/**
* The channel of this welcome channel
* @type {?(TextChannel|NewsChannel|StoreChannel)}
* @type {?(TextChannel|NewsChannel|StoreChannel|ForumChannel)}
*/
get channel() {
return this.client.channels.resolve(this.channelId);