From 7346621d15c96906d5b848c483669750ff9c6e12 Mon Sep 17 00:00:00 2001 From: ckohen Date: Thu, 24 Jun 2021 12:48:29 -0700 Subject: [PATCH] feat: api v9 and threads (#5570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Noel Co-authored-by: Amish Shah Co-authored-by: Vlad Frangu Co-authored-by: SynthGhost <60333233+synthghost@users.noreply.github.com> Co-authored-by: SpaceEEC <24881032+SpaceEEC@users.noreply.github.com> Co-authored-by: Elliot Co-authored-by: Antonio Román Co-authored-by: Sugden <28943913+NotSugden@users.noreply.github.com> --- src/client/actions/ActionsManager.js | 5 + src/client/actions/ThreadCreate.js | 23 ++ src/client/actions/ThreadDelete.js | 30 ++ src/client/actions/ThreadListSync.js | 59 +++ src/client/actions/ThreadMemberUpdate.js | 30 ++ src/client/actions/ThreadMembersUpdate.js | 34 ++ src/client/actions/TypingStart.js | 2 +- .../websocket/handlers/THREAD_CREATE.js | 5 + .../websocket/handlers/THREAD_DELETE.js | 5 + .../websocket/handlers/THREAD_LIST_SYNC.js | 5 + .../handlers/THREAD_MEMBERS_UPDATE.js | 5 + .../handlers/THREAD_MEMBER_UPDATE.js | 5 + .../websocket/handlers/THREAD_UPDATE.js | 16 + src/errors/Messages.js | 2 + src/index.js | 5 + src/managers/ChannelManager.js | 6 +- src/managers/GuildChannelManager.js | 15 +- src/managers/ThreadManager.js | 244 +++++++++++ src/managers/ThreadMemberManager.js | 112 ++++++ src/managers/UserManager.js | 7 +- src/structures/Channel.js | 15 +- src/structures/Guild.js | 6 + src/structures/GuildChannel.js | 3 + src/structures/Message.js | 28 +- src/structures/TextChannel.js | 25 ++ src/structures/ThreadChannel.js | 379 ++++++++++++++++++ src/structures/ThreadMember.js | 95 +++++ src/structures/Webhook.js | 9 +- src/util/Constants.js | 63 ++- src/util/MessageFlags.js | 2 + src/util/Permissions.js | 6 + src/util/Structures.js | 4 + src/util/ThreadMemberFlags.js | 30 ++ typings/index.d.ts | 205 +++++++++- 34 files changed, 1461 insertions(+), 24 deletions(-) create mode 100644 src/client/actions/ThreadCreate.js create mode 100644 src/client/actions/ThreadDelete.js create mode 100644 src/client/actions/ThreadListSync.js create mode 100644 src/client/actions/ThreadMemberUpdate.js create mode 100644 src/client/actions/ThreadMembersUpdate.js create mode 100644 src/client/websocket/handlers/THREAD_CREATE.js create mode 100644 src/client/websocket/handlers/THREAD_DELETE.js create mode 100644 src/client/websocket/handlers/THREAD_LIST_SYNC.js create mode 100644 src/client/websocket/handlers/THREAD_MEMBERS_UPDATE.js create mode 100644 src/client/websocket/handlers/THREAD_MEMBER_UPDATE.js create mode 100644 src/client/websocket/handlers/THREAD_UPDATE.js create mode 100644 src/managers/ThreadManager.js create mode 100644 src/managers/ThreadMemberManager.js create mode 100644 src/structures/ThreadChannel.js create mode 100644 src/structures/ThreadMember.js create mode 100644 src/util/ThreadMemberFlags.js diff --git a/src/client/actions/ActionsManager.js b/src/client/actions/ActionsManager.js index 856594b07..3851e9ff2 100644 --- a/src/client/actions/ActionsManager.js +++ b/src/client/actions/ActionsManager.js @@ -34,6 +34,11 @@ class ActionsManager { this.register(require('./GuildEmojiDelete')); this.register(require('./GuildEmojiUpdate')); this.register(require('./GuildEmojisUpdate')); + this.register(require('./ThreadCreate')); + this.register(require('./ThreadDelete')); + this.register(require('./ThreadListSync')); + this.register(require('./ThreadMemberUpdate')); + this.register(require('./ThreadMembersUpdate')); this.register(require('./GuildRolesPositionUpdate')); this.register(require('./GuildChannelsPositionUpdate')); this.register(require('./GuildIntegrationsUpdate')); diff --git a/src/client/actions/ThreadCreate.js b/src/client/actions/ThreadCreate.js new file mode 100644 index 000000000..a9a62c04d --- /dev/null +++ b/src/client/actions/ThreadCreate.js @@ -0,0 +1,23 @@ +'use strict'; + +const Action = require('./Action'); +const { Events } = require('../../util/Constants'); + +class ThreadCreateAction extends Action { + handle(data) { + const client = this.client; + const existing = client.channels.cache.has(data.id); + const thread = client.channels.add(data); + if (!existing && thread) { + /** + * Emitted whenever a thread is created or when the client user is added to a thread. + * @event Client#threadCreate + * @param {ThreadChannel} thread The thread that was created + */ + client.emit(Events.THREAD_CREATE, thread); + } + return { thread }; + } +} + +module.exports = ThreadCreateAction; diff --git a/src/client/actions/ThreadDelete.js b/src/client/actions/ThreadDelete.js new file mode 100644 index 000000000..67761d4bf --- /dev/null +++ b/src/client/actions/ThreadDelete.js @@ -0,0 +1,30 @@ +'use strict'; + +const Action = require('./Action'); +const { Events } = require('../../util/Constants'); + +class ThreadDeleteAction extends Action { + handle(data) { + const client = this.client; + const thread = client.channels.cache.get(data.id); + + if (thread) { + client.channels.remove(thread.id); + thread.deleted = true; + for (const message of thread.messages.cache.values()) { + message.deleted = true; + } + + /** + * Emitted whenever a thread is deleted. + * @event Client#threadDelete + * @param {ThreadChannel} thread The thread that was deleted + */ + client.emit(Events.THREAD_DELETE, thread); + } + + return { thread }; + } +} + +module.exports = ThreadDeleteAction; diff --git a/src/client/actions/ThreadListSync.js b/src/client/actions/ThreadListSync.js new file mode 100644 index 000000000..cc5c92ac6 --- /dev/null +++ b/src/client/actions/ThreadListSync.js @@ -0,0 +1,59 @@ +'use strict'; + +const Action = require('./Action'); +const Collection = require('../../util/Collection'); +const { Events } = require('../../util/Constants'); + +class ThreadListSyncAction extends Action { + handle(data) { + const client = this.client; + + const guild = client.guilds.cache.get(data.guild_id); + if (!guild) return {}; + + if (data.channels_ids) { + for (const id of data.channel_ids) { + const channel = client.channels.resolve(id); + if (channel) this.removeStale(channel); + } + } else { + for (const channel of guild.channels.cache.values()) { + this.removeStale(channel); + } + } + + const syncedThreads = data.threads.reduce((coll, rawThread) => { + const thread = client.channels.add(rawThread); + return coll.set(thread.id, thread); + }, new Collection()); + + for (const rawMember of Object.values(data.members)) { + // Discord sends the thread id as id in this object + const thread = client.channels.cache.get(rawMember.id); + if (thread) { + thread.members._add(rawMember); + } + } + + /** + * Emitted whenever the client user gains access to a text or news channel that contains threads + * @event Client#threadListSync + * @param {Collection} threads The threads that were synced + */ + client.emit(Events.THREAD_LIST_SYNC, syncedThreads); + + return { + syncedThreads, + }; + } + + removeStale(channel) { + channel.threads?.cache.forEach(thread => { + if (!thread.archived) { + this.client.channels.remove(thread.id); + } + }); + } +} + +module.exports = ThreadListSyncAction; diff --git a/src/client/actions/ThreadMemberUpdate.js b/src/client/actions/ThreadMemberUpdate.js new file mode 100644 index 000000000..b84bcac81 --- /dev/null +++ b/src/client/actions/ThreadMemberUpdate.js @@ -0,0 +1,30 @@ +'use strict'; + +const Action = require('./Action'); +const { Events } = require('../../util/Constants'); + +class ThreadMemberUpdateAction extends Action { + handle(data) { + const client = this.client; + // Discord sends the thread id as id in this object + const thread = client.channels.cache.get(data.id); + if (thread) { + const member = thread.members.cache.get(data.user_id); + if (!member) { + const newMember = thread.members._add(data); + return { newMember }; + } + const old = member._update(data); + /** + * Emitted whenever the client user's thread member is updated. + * @event Client#threadMemberUpdate + * @param {ThreadMember} oldMember The member before the update + * @param {ThreadMember} newMember The member after the update + */ + client.emit(Events.THREAD_MEMBER_UPDATE, old, member); + } + return {}; + } +} + +module.exports = ThreadMemberUpdateAction; diff --git a/src/client/actions/ThreadMembersUpdate.js b/src/client/actions/ThreadMembersUpdate.js new file mode 100644 index 000000000..c9833d65a --- /dev/null +++ b/src/client/actions/ThreadMembersUpdate.js @@ -0,0 +1,34 @@ +'use strict'; + +const Action = require('./Action'); +const { Events } = require('../../util/Constants'); + +class ThreadMembersUpdateAction extends Action { + handle(data) { + const client = this.client; + const thread = client.channels.cache.get(data.id); + if (thread) { + const old = thread.members.cache.clone(); + thread.memberCount = data.member_count; + + data.added_members?.forEach(rawMember => { + thread.members._add(rawMember); + }); + + data.removed_member_ids?.forEach(memberId => { + thread.members.cache.delete(memberId); + }); + + /** + * Emitted whenever members are added or removed from a thread. Requires `GUILD_MEMBERS` privileged intent + * @event Client#threadMembersUpdate + * @param {Collection} oldMembers The members before the update + * @param {Collection} newMembers The members after the update + */ + client.emit(Events.THREAD_MEMBERS_UPDATE, old, thread.members.cache); + } + return {}; + } +} + +module.exports = ThreadMembersUpdateAction; diff --git a/src/client/actions/TypingStart.js b/src/client/actions/TypingStart.js index ebb1bb67d..07c540fd5 100644 --- a/src/client/actions/TypingStart.js +++ b/src/client/actions/TypingStart.js @@ -2,7 +2,7 @@ const Action = require('./Action'); const { Events } = require('../../util/Constants'); -const textBasedChannelTypes = ['dm', 'text', 'news']; +const textBasedChannelTypes = ['dm', 'text', 'news', 'news_thread', 'public_thread', 'private_thread']; class TypingStart extends Action { handle(data) { diff --git a/src/client/websocket/handlers/THREAD_CREATE.js b/src/client/websocket/handlers/THREAD_CREATE.js new file mode 100644 index 000000000..d92cab057 --- /dev/null +++ b/src/client/websocket/handlers/THREAD_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadCreate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_DELETE.js b/src/client/websocket/handlers/THREAD_DELETE.js new file mode 100644 index 000000000..1140a08e8 --- /dev/null +++ b/src/client/websocket/handlers/THREAD_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadDelete.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_LIST_SYNC.js b/src/client/websocket/handlers/THREAD_LIST_SYNC.js new file mode 100644 index 000000000..17b173adf --- /dev/null +++ b/src/client/websocket/handlers/THREAD_LIST_SYNC.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadListSync.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_MEMBERS_UPDATE.js b/src/client/websocket/handlers/THREAD_MEMBERS_UPDATE.js new file mode 100644 index 000000000..f3c7a738c --- /dev/null +++ b/src/client/websocket/handlers/THREAD_MEMBERS_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadMembersUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_MEMBER_UPDATE.js b/src/client/websocket/handlers/THREAD_MEMBER_UPDATE.js new file mode 100644 index 000000000..a111b0ac6 --- /dev/null +++ b/src/client/websocket/handlers/THREAD_MEMBER_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.ThreadMemberUpdate.handle(packet.d); +}; diff --git a/src/client/websocket/handlers/THREAD_UPDATE.js b/src/client/websocket/handlers/THREAD_UPDATE.js new file mode 100644 index 000000000..795cb2944 --- /dev/null +++ b/src/client/websocket/handlers/THREAD_UPDATE.js @@ -0,0 +1,16 @@ +'use strict'; + +const { Events } = require('../../../util/Constants'); + +module.exports = (client, packet) => { + const { old, updated } = client.actions.ChannelUpdate.handle(packet.d); + if (old && updated) { + /** + * Emitted whenever a thread is updated - e.g. name change, archive state change, locked state change. + * @event Client#threadUpdate + * @param {ThreadChannel} oldThread The thread before the update + * @param {ThreadChannel} newThread The thread after the update + */ + client.emit(Events.THREAD_UPDATE, old, updated); + } +}; diff --git a/src/errors/Messages.js b/src/errors/Messages.js index 2b5beaf5c..36b3a123f 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -92,6 +92,8 @@ const Messages = { INVALID_TYPE: (name, expected, an = false) => `Supplied ${name} is not a${an ? 'n' : ''} ${expected}.`, INVALID_ELEMENT: (type, name, elem) => `Supplied ${type} ${name} includes an invalid element: ${elem}`, + MESSAGE_THREAD_PARENT: 'The message was not sent in a guild text or news channel', + WEBHOOK_MESSAGE: 'The message was not sent by a webhook.', WEBHOOK_TOKEN_UNAVAILABLE: 'This action requires a webhook token, but none is available.', MESSAGE_REFERENCE_MISSING: 'The message does not reference another message', diff --git a/src/index.js b/src/index.js index 8846d2a28..7fd16b9b6 100644 --- a/src/index.js +++ b/src/index.js @@ -26,6 +26,7 @@ module.exports = { SnowflakeUtil: require('./util/SnowflakeUtil'), Structures: require('./util/Structures'), SystemChannelFlags: require('./util/SystemChannelFlags'), + ThreadMemberFlags: require('./util/ThreadMemberFlags'), UserFlags: require('./util/UserFlags'), Util: require('./util/Util'), version: require('../package.json').version, @@ -47,6 +48,8 @@ module.exports = { MessageManager: require('./managers/MessageManager'), PresenceManager: require('./managers/PresenceManager'), RoleManager: require('./managers/RoleManager'), + ThreadManager: require('./managers/ThreadManager'), + ThreadMemberManager: require('./managers/ThreadMemberManager'), UserManager: require('./managers/UserManager'), // Structures @@ -109,6 +112,8 @@ module.exports = { Team: require('./structures/Team'), TeamMember: require('./structures/TeamMember'), TextChannel: require('./structures/TextChannel'), + ThreadChannel: require('./structures/ThreadChannel'), + ThreadMember: require('./structures/ThreadMember'), User: require('./structures/User'), VoiceChannel: require('./structures/VoiceChannel'), VoiceRegion: require('./structures/VoiceRegion'), diff --git a/src/managers/ChannelManager.js b/src/managers/ChannelManager.js index 1aeb292d1..84475a995 100644 --- a/src/managers/ChannelManager.js +++ b/src/managers/ChannelManager.js @@ -2,7 +2,7 @@ const BaseManager = require('./BaseManager'); const Channel = require('../structures/Channel'); -const { Events } = require('../util/Constants'); +const { Events, ThreadChannelTypes } = require('../util/Constants'); /** * A manager of channels belonging to a client @@ -24,6 +24,9 @@ class ChannelManager extends BaseManager { if (existing) { if (existing._patch && cache) existing._patch(data); if (guild) guild.channels?.add(existing); + if (ThreadChannelTypes.includes(existing.type) && typeof existing.parent?.threads !== 'undefined') { + existing.parent.threads.add(existing); + } return existing; } @@ -42,6 +45,7 @@ class ChannelManager extends BaseManager { remove(id) { const channel = this.cache.get(id); channel?.guild?.channels.cache.delete(id); + channel?.parent?.threads?.cache.delete(id); this.cache.delete(id); } diff --git a/src/managers/GuildChannelManager.js b/src/managers/GuildChannelManager.js index ba5ddd8ac..9b794ccda 100644 --- a/src/managers/GuildChannelManager.js +++ b/src/managers/GuildChannelManager.js @@ -4,7 +4,7 @@ const BaseManager = require('./BaseManager'); const GuildChannel = require('../structures/GuildChannel'); const PermissionOverwrites = require('../structures/PermissionOverwrites'); const Collection = require('../util/Collection'); -const { ChannelTypes } = require('../util/Constants'); +const { ChannelTypes, ThreadChannelTypes } = require('../util/Constants'); /** * Manages API methods for GuildChannels and stores their cache. @@ -21,6 +21,19 @@ class GuildChannelManager extends BaseManager { this.guild = guild; } + /** + * The number of channels in this managers cache excluding thread channels + * that do not count towards a guild's maximum channels restriction. + * @type {number} + * @readonly + */ + get channelCountWithoutThreads() { + return this.cache.reduce((acc, channel) => { + if (ThreadChannelTypes.includes(channel.type)) return acc; + return ++acc; + }, 0); + } + /** * The cache of this Manager * @type {Collection} diff --git a/src/managers/ThreadManager.js b/src/managers/ThreadManager.js new file mode 100644 index 000000000..f8112d87c --- /dev/null +++ b/src/managers/ThreadManager.js @@ -0,0 +1,244 @@ +'use strict'; + +const BaseManager = require('./BaseManager'); +const { TypeError } = require('../errors'); +const ThreadChannel = require('../structures/ThreadChannel'); +const Collection = require('../util/Collection'); +const { ChannelTypes } = require('../util/Constants'); + +/** + * Manages API methods for ThreadChannels and stores their cache. + * @extends {BaseManager} + */ +class ThreadManager extends BaseManager { + constructor(channel, iterable) { + super(channel.client, iterable, ThreadChannel); + + /** + * The channel this Manager belongs to + * @type {NewsChannel|TextChannel} + */ + this.channel = channel; + } + + /** + * The cache of this Manager + * @type {Collection} + * @name ThreadManager#cache + */ + + add(thread) { + const existing = this.cache.get(thread.id); + if (existing) return existing; + this.cache.set(thread.id, thread); + return thread; + } + + /** + * Data that can be resolved to give a Thread Channel object. This can be: + * * A ThreadChannel object + * * A Snowflake + * @typedef {ThreadChannel|Snowflake} ThreadChannelResolvable + */ + + /** + * Resolves a ThreadChannelResolvable to a Thread Channel object. + * @method resolve + * @memberof ThreadManager + * @instance + * @param {ThreadChannelResolvable} thread The ThreadChannel resolvable to resolve + * @returns {?ThreadChannel} + */ + + /** + * Resolves a ThreadChannelResolvable to a thread channel ID string. + * @method resolveID + * @memberof ThreadManager + * @instance + * @param {ThreadChannelResolvable} thread The ThreadChannel resolvable to resolve + * @returns {?Snowflake} + */ + + /** + * A number that is allowed to be the duration in minutes before a thread is automatically archived. This can be: + * * `60` (1 hour) + * * `1440` (1 day) + * * `4320` (3 days) + * * `10080` (7 days) + * @typedef {number} ThreadAutoArchiveDuration + */ + + /** + * Options for creating a thread Only one of `startMessage` or `type` can be defined. + * If `startMessage` is defined, `type` is automatically defined and cannot be changed. + * @typedef {Object} ThreadCreateOptions + * @property {string} name The name of the new Thread + * @property {ThreadAutoArchiveDuration} autoArchiveDuration How long before the thread is automatically archived + * @property {MessageResolvable} [startMessage] The message to start a public or news thread from, + * creates a private thread if not provided + * @property {ThreadChannelType|number} [type] The type of thread to create + * When creating threads in a `news` channel this is always `news_thread` + * @param {string} [reason] Reason for creating the thread + */ + + /** + * Creates a new thread in the channel. + * @param {ThreadCreateOptions} [options] Options + * @returns {Promise} + * @example + * // Create a new public thread + * channel.threads + * .create({ + * name: 'food-talk' + * autoArchiveDuration: 60, + * startMessage: channel.lastMessageID, + * reason: 'Needed a separate thread for food', + * }) + * .then(console.log) + * .catch(console.error); + * // Create a new private thread + * channel.threads + * .create({ name: 'mod-talk', autoArchiveDuration: 60, reason: 'Needed a separate thread for moderation' }) + * .then(console.log) + * .catch(console.error); + */ + async create({ name, autoArchiveDuration, startMessage, type, reason } = {}) { + let path = this.client.api.channels(this.channel.id); + if (type && typeof type !== 'string' && typeof type !== 'number') { + throw new TypeError('INVALID_TYPE', 'type', 'ThreadChannelType or Number'); + } + let resolvedType = this.channel.type === 'news' ? ChannelTypes.NEWS_THREAD : ChannelTypes.PUBLIC_THREAD; + if (startMessage) { + const startMessageID = this.channel.messages.resolveID(startMessage); + if (!startMessageID) throw new TypeError('INVALID_TYPE', 'startMessage', 'MessageResolvable'); + path = path.messages(startMessageID); + } else if (this.channel.type !== 'news') { + resolvedType = typeof type === 'string' ? ChannelTypes[type.toUpperCase()] : type; + } + + const data = await path.threads.post({ + data: { + name, + auto_archive_duration: autoArchiveDuration, + type: resolvedType, + }, + reason, + }); + + return this.client.actions.ThreadCreate.handle(data).thread; + } + + /** + * The options for fetching multiple threads, the properties are mutually exclusive + * @typedef {Object} FetchThreadsOptions + * @property {FetchArchivedThreadOptions} [archived] The options used to fetch archived threads + * @property {boolean} [active] When true, fetches active threads. If `archived` is set, this is ignored! + */ + + /** + * Obtains a thread from Discord, or the channel cache if it's already available. + * @param {ThreadChannelResolvable|FetchThreadsOptions} [options] If a ThreadChannelResolvable, the thread to fetch. + * If undefined, fetches all active threads. + * If an Object, fetches the specified threads. + * @param {BaseFetchOptions} [cacheOptions] Additional options for this fetch + * only applies when fetching a single thread + * @returns {Promise} + * @example + * // Fetch a thread by its id + * channel.threads.fetch('831955138126104859') + * .then(channel => console.log(channel.name)) + * .catch(console.error); + */ + fetch(options, { cache = true, force = false } = {}) { + if (!options) return this.fetchActive(cache); + const channel = this.client.channels.resolveID(options); + if (channel) return this.client.channels.fetch(channel, cache, force); + if (options.archived) { + return this.fetchArchived(options.archived, cache); + } + return this.fetchActive(cache); + } + + /** + * Data that can be resolved to give a Date object. This can be: + * * A Date object + * * A number representing a timestamp + * * An ISO8601 string + * @typedef {Date|number|string} DateResolvable + */ + + /** + * The options used to fetch archived threads. + * @typedef {Object} FetchArchivedThreadOptions + * @property {string} [type='public'] The type of threads to fetch, either `public` or `private` + * @property {boolean} [fetchAll=false] When type is `private` whether to fetch **all** archived threads, + * requires `MANAGE_THREADS` if true + * @property {DateResolvable|ThreadChannelResolvable} [before] Identifier for a Date or Snowflake + * to get threads that were created before it, + * must be a ThreadChannelResolvable when type is `private` and fetchAll is `false` + * @property {number} [limit] Maximum number of threads to return + */ + + /** + * The data returned from a thread fetch that returns multiple threads. + * @typedef {FetchedThreads} + * @property {Collection} threads The threads fetched, with any members returned + * @property {?boolean} hasMore Whether there are potentially additional threads that require a subsequent call + */ + + /** + * Obtains a set of archived threads from Discord, requires `READ_MESSAGE_HISTORY` in the parent channel. + * @param {FetchArchivedThreadOptions} [options] The options to use when fetch archived threads + * @param {boolean} [cache=true] Whether to cache the new thread objects if they aren't already + * @returns {Promise} + */ + async fetchArchived({ type = 'public', fetchAll = false, before, limit } = {}, cache = true) { + let path = this.client.api.channels(this.channel.id); + if (type === 'private' && fetchAll) { + path = path.users('@me'); + } + let timestamp; + let id; + if (typeof before !== 'undefined') { + if (before instanceof ThreadChannel || /^\d{16,19}$/.test(String(before))) { + id = this.resolveID(before); + timestamp = this.resolve(before)?.archivedAt?.toISOString(); + } else { + try { + timestamp = new Date(before).toISOString(); + } catch { + throw new TypeError('INVALID_TYPE', 'before', 'DateResolvable or ThreadChannelResolvable'); + } + } + } + const raw = await path.threads + .archived(type) + .get({ query: { before: type === 'private' && !fetchAll ? id : timestamp, limit } }); + return this._mapThreads(raw, cache); + } + + /** + * Obtains the accessible active threads from Discord, requires `READ_MESSAGE_HISTORY` in the parent channel. + * @param {boolean} [cache=true] Whether to cache the new thread objects if they aren't already + * @returns {Promise} + */ + async fetchActive(cache = true) { + const raw = await this.client.api.channels(this.channel.id).threads.active.get(); + return this._mapThreads(raw, cache); + } + + _mapThreads(rawThreads, cache) { + const threads = rawThreads.threads.reduce((coll, raw) => { + const thread = this.client.channels.add(raw, null, cache); + return coll.set(thread.id, thread); + }, new Collection()); + // Discord sends the thread id as id in this object + for (const rawMember of rawThreads.members) threads.get(rawMember.id)?.members._add(rawMember); + return { + threads, + hasMore: rawThreads.has_more, + }; + } +} + +module.exports = ThreadManager; diff --git a/src/managers/ThreadMemberManager.js b/src/managers/ThreadMemberManager.js new file mode 100644 index 000000000..4eac0b11b --- /dev/null +++ b/src/managers/ThreadMemberManager.js @@ -0,0 +1,112 @@ +'use strict'; + +const BaseManager = require('./BaseManager'); +const { TypeError } = require('../errors'); +const ThreadMember = require('../structures/ThreadMember'); +const Collection = require('../util/Collection'); + +/** + * Manages API methods for GuildMembers and stores their cache. + * @extends {BaseManager} + */ +class ThreadMemberManager extends BaseManager { + constructor(thread, iterable) { + super(thread.client, iterable, ThreadMember); + /** + * The thread this manager belongs to + * @type {ThreadChannel} + */ + this.thread = thread; + } + + /** + * The cache of this Manager + * @type {Collection} + * @name ThreadMemberManager#cache + */ + + _add(data, cache = true) { + const existing = this.cache.get(data.user_id); + if (cache) existing?._patch(data); + if (existing) return existing; + + const member = new ThreadMember(this.thread, data); + if (cache) this.cache.set(data.user_id, member); + return member; + } + + /** + * Data that resolves to give a ThreadMember object. This can be: + * * A ThreadMember object + * * A User resolvable + * @typedef {ThreadMember|UserResolvable} ThreadMemberResolvable + */ + + /** + * Resolves a ThreadMemberResolvable to a ThreadMember object. + * @param {ThreadMemberResolvable} member The user that is part of the thread + * @returns {?GuildMember} + */ + resolve(member) { + const memberResolvable = super.resolve(member); + if (memberResolvable) return memberResolvable; + const userResolvable = this.client.users.resolveID(member); + if (userResolvable) return super.resolve(userResolvable); + return null; + } + + /** + * Resolves a ThreadMemberResolvable to a thread member ID string. + * @param {ThreadMemberResolvable} member The user that is part of the guild + * @returns {?Snowflake} + */ + resolveID(member) { + const memberResolvable = super.resolveID(member); + if (memberResolvable) return memberResolvable; + const userResolvable = this.client.users.resolveID(member); + return this.cache.has(userResolvable) ? userResolvable : null; + } + + /** + * Adds a member to the thread. + * @param {UserResolvable|'@me'} member The member to add + * @param {string} [reason] The reason for adding this member + * @returns {Promise} + */ + add(member, reason) { + const id = member === '@me' ? member : this.client.users.resolveID(member); + if (!id) return Promise.reject(new TypeError('INVALID_TYPE', 'member', 'UserResolvable')); + return this.client.api + .channels(this.id, 'thread-members', id) + .put({ reason }) + .then(() => id); + } + + /** + * Remove a user from the thread. + * @param {Snowflake|'@me'} id The id of the member to remove + * @param {string} [reason] The reason for removing this member from the thread + * @returns {Promise} + */ + remove(id, reason) { + return this.client.api + .channels(this.thread.id, 'thread-members', id) + .delete({ reason }) + .then(() => id); + } + + /** + * Fetches member(s) for the thread from Discord, requires access to the `GUILD_MEMBERS` gateway intent. + * @param {boolean} [cache=true] Whether or not to cache the fetched members + * @returns {Promise>} + */ + async fetch(cache = true) { + const raw = await this.client.api.channels(this.thread.id, 'thread-members').get(); + return raw.reduce((col, rawMember) => { + const member = this.add(rawMember, cache); + return col.set(member.id, member); + }, new Collection()); + } +} + +module.exports = ThreadMemberManager; diff --git a/src/managers/UserManager.js b/src/managers/UserManager.js index 96c51773f..e39105dd8 100644 --- a/src/managers/UserManager.js +++ b/src/managers/UserManager.js @@ -3,6 +3,7 @@ const BaseManager = require('./BaseManager'); const GuildMember = require('../structures/GuildMember'); const Message = require('../structures/Message'); +const ThreadMember = require('../structures/ThreadMember'); const User = require('../structures/User'); /** @@ -26,7 +27,8 @@ class UserManager extends BaseManager { * * A Snowflake * * A Message object (resolves to the message author) * * A GuildMember object - * @typedef {User|Snowflake|Message|GuildMember} UserResolvable + * * A ThreadMember object + * @typedef {User|Snowflake|Message|GuildMember|ThreadMember} UserResolvable */ /** @@ -35,7 +37,7 @@ class UserManager extends BaseManager { * @returns {?User} */ resolve(user) { - if (user instanceof GuildMember) return user.user; + if (user instanceof GuildMember || user instanceof ThreadMember) return user.user; if (user instanceof Message) return user.author; return super.resolve(user); } @@ -46,6 +48,7 @@ class UserManager extends BaseManager { * @returns {?Snowflake} */ resolveID(user) { + if (user instanceof ThreadMember) return user.id; if (user instanceof GuildMember) return user.user.id; if (user instanceof Message) return user.author.id; return super.resolveID(user); diff --git a/src/structures/Channel.js b/src/structures/Channel.js index ff12ed949..c8e08c96a 100644 --- a/src/structures/Channel.js +++ b/src/structures/Channel.js @@ -10,7 +10,7 @@ const SnowflakeUtil = require('../util/SnowflakeUtil'); * @abstract */ class Channel extends Base { - constructor(client, data) { + constructor(client, data, immediatePatch = true) { super(client); const type = ChannelTypes[data.type]; @@ -22,6 +22,9 @@ class Channel extends Base { * * `category` - a guild category channel * * `news` - a guild news channel * * `store` - a guild store channel + * * 'news_thread` - a guild news channels' public thread channel + * * `public_thread` - a guild text channels' public thread channel + * * `private_thread` - a guild text channels' private thread channel * * `stage` - a guild stage channel * * `unknown` - a generic channel of unknown type, could be Channel or GuildChannel * @type {string} @@ -34,7 +37,7 @@ class Channel extends Base { */ this.deleted = false; - if (data) this._patch(data); + if (data && immediatePatch) this._patch(data); } _patch(data) { @@ -152,6 +155,14 @@ class Channel extends Base { channel = new StageChannel(guild, data); break; } + case ChannelTypes.NEWS_THREAD: + case ChannelTypes.PUBLIC_THREAD: + case ChannelTypes.PRIVATE_THREAD: { + const ThreadChannel = Structures.get('ThreadChannel'); + channel = new ThreadChannel(guild, data); + channel.parent?.threads.cache.set(channel.id, channel); + break; + } } if (channel) guild.channels?.cache.set(channel.id, channel); } diff --git a/src/structures/Guild.js b/src/structures/Guild.js index edde38f27..ba5a444e1 100644 --- a/src/structures/Guild.js +++ b/src/structures/Guild.js @@ -338,6 +338,12 @@ class Guild extends AnonymousGuild { } } + if (data.threads) { + for (const rawThread of data.threads) { + this.client.channels.add(rawThread, this); + } + } + if (data.roles) { this.roles.cache.clear(); for (const role of data.roles) this.roles.add(role); diff --git a/src/structures/GuildChannel.js b/src/structures/GuildChannel.js index af686f7b5..34f129e15 100644 --- a/src/structures/GuildChannel.js +++ b/src/structures/GuildChannel.js @@ -368,6 +368,8 @@ class GuildChannel extends Channel { * @property {OverwriteResolvable[]|Collection} [permissionOverwrites] * Permission overwrites for the channel * @property {number} [rateLimitPerUser] The ratelimit per user for the channel in seconds + * @property {ThreadAutoArchiveDuration} [defaultAutoArchiveDuration] + * The default auto archive duration for all new threads in this channel * @property {?string} [rtcRegion] The RTC region of the channel */ @@ -428,6 +430,7 @@ class GuildChannel extends Channel { parent_id: data.parentID, lock_permissions: data.lockPermissions, rate_limit_per_user: data.rateLimitPerUser, + default_auto_archive_duration: data.defaultAutoArchiveDuration, permission_overwrites, }, reason, diff --git a/src/structures/Message.js b/src/structures/Message.js index ddb47698e..ad85fec29 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -101,6 +101,16 @@ class Message extends Base { this.pinned = null; } + if ('thread' in data) { + /** + * The thread started by this message + * @type {?ThreadChannel} + */ + this.thread = this.client.channels.add(data.thread); + } else if (!this.thread) { + this.thread = null; + } + if ('tts' in data) { /** * Whether or not the message was Text-To-Speech @@ -224,7 +234,7 @@ class Message extends Base { this.flags = new MessageFlags(data.flags).freeze(); /** - * Reference data sent in a crossposted message or inline reply. + * Reference data sent in a message that contains IDs identifying the referenced message * @typedef {Object} MessageReference * @property {string} channelID ID of the channel the message was referenced * @property {?string} guildID ID of the guild the message was referenced @@ -294,6 +304,7 @@ class Message extends Base { if ('content' in data) this.content = data.content; if ('pinned' in data) this.pinned = data.pinned; if ('tts' in data) this.tts = data.tts; + if ('thread' in data) this.thread = this.client.channels.add(data.thread); if ('embeds' in data) this.embeds = data.embeds.map(e => new Embed(e, true)); else this.embeds = this.embeds.slice(); if ('components' in data) this.components = data.components.map(c => BaseMessageComponent.create(c, this.client)); @@ -676,6 +687,21 @@ class Message extends Base { return this.channel.send(data); } + /** + * Create a new public thread from this message + * @see ThreadManager#create + * @param {string} name The name of the new Thread + * @param {ThreadAutoArchiveDuration} autoArchiveDuration How long before the thread is automatically archived + * @param {string} [reason] Reason for creating the thread + * @returns {Promise} + */ + startThread(name, autoArchiveDuration, reason) { + if (!['text', 'news'].includes(this.channel.type)) { + return Promise.reject(new Error('MESSAGE_THREAD_PARENT')); + } + return this.channel.threads.create({ name, autoArchiveDuration, startMessage: this, reason }); + } + /** * Fetch this message. * @param {boolean} [force=false] Whether to skip the cache check and request the API diff --git a/src/structures/TextChannel.js b/src/structures/TextChannel.js index d10b4f1d5..8d9fa7760 100644 --- a/src/structures/TextChannel.js +++ b/src/structures/TextChannel.js @@ -4,6 +4,7 @@ const GuildChannel = require('./GuildChannel'); const Webhook = require('./Webhook'); const TextBasedChannel = require('./interfaces/TextBasedChannel'); const MessageManager = require('../managers/MessageManager'); +const ThreadManager = require('../managers/ThreadManager'); const Collection = require('../util/Collection'); const DataResolver = require('../util/DataResolver'); @@ -25,6 +26,12 @@ class TextChannel extends GuildChannel { */ this.messages = new MessageManager(this); + /** + * A manager of the threads belonging to this channel + * @type {ThreadManager} + */ + this.threads = new ThreadManager(this); + /** * If the guild considers this channel NSFW * @type {boolean} @@ -73,11 +80,29 @@ class TextChannel extends GuildChannel { this.lastPinTimestamp = data.last_pin_timestamp ? new Date(data.last_pin_timestamp).getTime() : 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; + } + if ('messages' in data) { for (const message of data.messages) this.messages.add(message); } } + /** + * 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} + */ + setDefaultAutoArchiveDuration(defaultAutoArchiveDuration, reason) { + return this.edit({ defaultAutoArchiveDuration }, reason); + } + /** * Sets the rate limit per user for this channel. * It is not currently possible to set the rate limit per user on a `NewsChannel`. diff --git a/src/structures/ThreadChannel.js b/src/structures/ThreadChannel.js new file mode 100644 index 000000000..cacca9140 --- /dev/null +++ b/src/structures/ThreadChannel.js @@ -0,0 +1,379 @@ +'use strict'; + +const Channel = require('./Channel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); +const MessageManager = require('../managers/MessageManager'); +const ThreadMemberManager = require('../managers/ThreadMemberManager'); +const Permissions = require('../util/Permissions'); + +/** + * Represents a thread channel on Discord. + * @extends {Channel} + * @implements {TextBasedChannel} + */ +class ThreadChannel extends Channel { + /** + * @param {Guild} guild The guild the thread channel is part of + * @param {Object} data The data for the thread channel + */ + constructor(guild, data) { + super(guild.client, data, false); + + /** + * The guild the thread is in + * @type {Guild} + */ + this.guild = guild; + + /** + * A manager of the messages set to this thread + * @type {MessageManager} + */ + this.messages = new MessageManager(this); + + /** + * A manager of the members that are part of this thread + * @type {ThreadMemberManager} + */ + this.members = new ThreadMemberManager(this); + + this._typing = new Map(); + if (data) this._patch(data); + } + + _patch(data) { + super._patch(data); + + /** + * The name of the thread + * @type {string} + */ + this.name = data.name; + + /** + * The ID of the parent channel to this thread + * @type {Snowflake} + */ + this.parentID = data.parent_id; + + /** + * Whether the thread is locked + * @type {boolean} + */ + this.locked = data.thread_metadata.locked ?? false; + + /** + * Whether the thread is active (false) or archived (true) + * @type {boolean} + */ + this.archived = data.thread_metadata.archived; + + /** + * The id of the member that created this thread + * @type {?Snowflake} + */ + this.ownerID = data.owner_id; + + /** + * How long in minutes after recent activity before the thread is automatically archived + * @type {number} + */ + this.autoArchiveDuration = data.thread_metadata.auto_archive_duration; + + /** + * The ID of the last message sent in this thread, if one was sent + * @type {?Snowflake} + */ + this.lastMessageID = data.last_message_id; + + /** + * The timestamp when the last pinned message was pinned, if there was one + * @type {?number} + */ + this.lastPinTimestamp = data.last_pin_timestamp ? new Date(data.last_pin_timestamp).getTime() : null; + + /** + * The ratelimit per user for this thread in seconds + * @type {number} + */ + this.rateLimitPerUser = data.rate_limit_per_user ?? 0; + + /** + * The timestamp the thread was last archived or unarchived at + * @type {?number} + */ + this.archiveTimestamp = data.thread_metadata.archive_timestamp + ? new Date(data.thread_metadata.archive_timestamp).getTime() + : null; + + /** + * The approximate count of messages in this thread + * This value will not count above 50 even when there are more than 50 messages + * If you need an approximate value higher than this, use ThreadChannel#messages.cache.size + * @type {number} + */ + this.messageCount = data.message_count; + + /** + * The approximate count of users in this thread + * This value will not count above 50 even when there are more than 50 members + * @type {number} + */ + this.memberCount = data.member_count; + + 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); + } + + /** + * A collection of the guild member objects for each of this thread's members + * @type {Collection} + * @readonly + */ + get guildMembers() { + return this.members.cache.mapValues(member => member.guildMember); + } + + /** + * The time the thread was last archived or unarchived at + * @type {?Date} + * @readonly + */ + get archivedAt() { + return this.archiveTimestamp ? new Date(this.archiveTimestamp) : null; + } + + /** + * The parent channel of this thread + * @type {?(NewsChannel|TextChannel)} + * @readonly + */ + get parent() { + return this.guild.channels.resolve(this.parentID); + } + + /** + * Makes the client user join the thread. + * @returns {Promise} + */ + join() { + return this.members.add('@me').then(() => this); + } + + /** + * Makes the client user leave the thread. + * @returns {Promise} + */ + leave() { + return this.members.remove('@me').then(() => this); + } + + /** + * Gets the overall set of permissions for a member or role in this threads' parent, taking into account overwrites. + * @param {GuildMemberResolvable|RoleResolvable} memberOrRole The member or role to obtain the overall permissions for + * @returns {?Readonly} + */ + permissionsFor(memberOrRole) { + return this.parent?.permissionsFor(memberOrRole) ?? null; + } + + /** + * Edits the thread. + * @param {Object} data The new data for the thread + * @param {string} [data.name] The new name for the trhead + * @param {boolean} [data.archived] Whether the thread is archived + * @param {number} [data.autoArchiveDuration] How long in minutes before the thread is automatically archived, + * one of `60`, `1440`, `4320`, or `10080` + * @param {number} [data.rateLimitPerUser] The ratelimit per user for the thread in seconds + * @param {boolean} [data.locked] Whether the thread is locked + * @param {string} [reason] Reason for editing this thread + * @returns {Promise} + * @example + * // Edit a thread + * thread.edit({ name: 'new-thread' }) + * .then(console.log) + * .catch(console.error); + */ + async edit(data, reason) { + const newData = await this.client.api.channels(this.id).patch({ + data: { + name: (data.name || this.name).trim(), + archived: data.archived, + auto_archive_duration: data.autoArchiveDuration, + rate_limit_per_user: data.rateLimitPerUser, + locked: data.locked, + }, + reason, + }); + + return this.client.actions.ChannelUpdate.handle(newData).updated; + } + + /** + * Sets whether the thread is archived. + * @param {boolean} [archived=true] Whether the thread is archived + * @param {string} [reason] Reason for archiving or unarchiving + * @returns {Promise} + * @example + * // Set the thread to archived + * thread.setArchived(true) + * .then(newThread => console.log(`Thread is now ${newThread.archived ? 'archived' : 'active'}`)) + * .catch(console.error); + */ + setArchived(archived = true, reason) { + return this.edit({ archived }, reason); + } + + /** + * Sets the duration before the channel is automatically archived. + * @param {ThreadAutoArchiveDuration} autoArchiveDuration How long before the thread is automatically archived + * @param {string} [reason] Reason for changing the archive time + * @returns {Promise} + * @example + * // Set the thread auto archive time to 1 hour + * thread.setAutoArchiveDuration(60) + * .then(newThread => { + * console.log(`Thread will now archive after ${newThread.autoArchiveDuration}`); + * }); + * .catch(console.error); + */ + setAutoArchiveDuration(autoArchiveDuration, reason) { + return this.edit({ autoArchiveDuration }, reason); + } + + /** + * Sets whether the thread can be archived by anyone or just mods. + * @param {boolean} [locked=true] Whether the thread is locked + * @param {string} [reason] Reason for archiving or unarchiving + * @returns {Promise} + * @example + * // Set the thread to locked + * thread.setLocked(true) + * .then(newThread => console.log(`Thread is now ${newThread.locked ? 'locked' : 'unlocked'}`)) + * .catch(console.error); + */ + setLocked(locked = true, reason) { + return this.edit({ locked }, reason); + } + + /** + * Sets a new name for the thread. + * @param {string} name The new name for the thread + * @param {string} [reason] Reason for changing the thread's name + * @returns {Promise} + * @example + * // Set a new thread name + * thread.setName('not_general') + * .then(newThread => console.log(`Thread's new name is ${newThread.name}`)) + * .catch(console.error); + */ + setName(name, reason) { + return this.edit({ name }, reason); + } + + /** + * Sets the rate limit per user for this thread. + * @param {number} rateLimitPerUser The new ratelimit in seconds + * @param {string} [reason] Reason for changing the thread's ratelimits + * @returns {Promise} + */ + setRateLimitPerUser(rateLimitPerUser, reason) { + return this.edit({ rateLimitPerUser }, reason); + } + + /** + * Whether the thread is editable by the client user (name, archived, autoArchiveDuration) + * @type {boolean} + * @readonly + */ + get editable() { + return this.ownerID === this.client.user.id || this.manageable; + } + + /** + * Whether the thread is joinable by the client user + * @type {boolean} + * @readonly + */ + get joinable() { + return ( + !this.archived && + this.permissionsFor(this.client.user)?.has( + this.type === 'private_thread' ? Permissions.FLAGS.MANAGE_THREADS : Permissions.FLAGS.VIEW_CHANNEL, + false, + ) + ); + } + + /** + * Whether the thread is manageable by the client user, for deleting or editing rateLimitPerUser or locked. + * @type {boolean} + * @readonly + */ + get manageable() { + return this.permissionsFor(this.client.user)?.has(Permissions.FLAGS.MANAGE_THREADS, false); + } + + /** + * Whether the client user can send messages in this thread + * @type {boolean} + * @readonly + */ + get sendable() { + return ( + !this.archived && + this.permissionsFor(this.client.user)?.any( + [ + Permissions.FLAGS.SEND_MESSAGES, + this.type === 'private_thread' ? Permissions.FLAGS.USE_PRIVATE_THREADS : Permissions.FLAGS.USE_PUBLIC_THREADS, + ], + false, + ) + ); + } + + /** + * Whether the thread is unarchivable by the client user + * @type {boolean} + * @readonly + */ + get unarchivable() { + return this.archived && (this.locked ? this.manageable : this.sendable); + } + + /** + * Deletes this thread. + * @param {string} [reason] Reason for deleting this thread + * @returns {Promise} + * @example + * // Delete the thread + * thread.delete('cleaning out old threads') + * .then(console.log) + * .catch(console.error); + */ + delete(reason) { + return this.client.api + .channels(this.id) + .delete({ reason }) + .then(() => this); + } + + // These are here only for documentation purposes - they are implemented by TextBasedChannel + /* eslint-disable no-empty-function */ + get lastMessage() {} + get lastPinAt() {} + send() {} + startTyping() {} + stopTyping() {} + get typing() {} + get typingCount() {} + createMessageCollector() {} + awaitMessages() {} + createMessageComponentInteractionCollector() {} + awaitMessageComponentInteractions() {} + bulkDelete() {} +} + +TextBasedChannel.applyToClass(ThreadChannel, true); + +module.exports = ThreadChannel; diff --git a/src/structures/ThreadMember.js b/src/structures/ThreadMember.js new file mode 100644 index 000000000..195b199c9 --- /dev/null +++ b/src/structures/ThreadMember.js @@ -0,0 +1,95 @@ +'use strict'; + +const Base = require('./Base'); +const ThreadMemberFlags = require('../util/ThreadMemberFlags'); + +/** + * Represents a Member for a Thread. + * @extends {Base} + */ +class ThreadMember extends Base { + /** + * @param {ThreadChannel} thread The thread that this member is associated with + * @param {Object} data The data for the thread member + */ + constructor(thread, data) { + super(thread.client); + + /** + * The thread that this member is a part of + * @type {ThreadChannel} + */ + this.thread = thread; + + /** + * The timestamp the member last joined the thread at + * @type {?number} + */ + this.joinedTimestamp = null; + + /** + * The id of the thread member + * @type {Snowflake} + */ + this.id = data.user_id; + + this._patch(data); + } + + _patch(data) { + this.joinedTimestamp = new Date(data.join_timestamp).getTime(); + + /** + * The flags for this thread member + * @type {ThreadMemberFlags} + */ + this.flags = new ThreadMemberFlags(data.flags).freeze(); + } + + /** + * The guild member that this thread member instance represents + * @type {?GuildMember} + * @readonly + */ + get guildMember() { + return this.thread.guild.members.resolve(this.id); + } + + /** + * The last time this member joined the thread + * @type {?Date} + * @readonly + */ + get joinedAt() { + return this.joinedTimestamp ? new Date(this.joinedTimestamp) : null; + } + + /** + * The user that this thread member instance represents + * @type {?User} + * @readonly + */ + get user() { + return this.client.users.resolve(this.id); + } + + /** + * Whether the client user can manage this thread member + * @type {boolean} + * @readonly + */ + get manageable() { + return !this.thread.archived && this.thread.editable; + } + + /** + * Remove this member from the thread. + * @param {string} [reason] Reason for removing the member + * @returns {ThreadMember} + */ + remove(reason) { + return this.thread.members.remove(this.id, reason).then(() => this); + } +} + +module.exports = ThreadMember; diff --git a/src/structures/Webhook.js b/src/structures/Webhook.js index 8bd354259..3f2db5cac 100644 --- a/src/structures/Webhook.js +++ b/src/structures/Webhook.js @@ -92,6 +92,8 @@ class Webhook { * @typedef {BaseMessageOptions} WebhookMessageOptions * @property {string} [username=this.name] Username override for the message * @property {string} [avatarURL] Avatar URL override for the message + * @property {Snowflake} [threadID] The id of the thread in the channel to send to. + * For interaction webhooks, this property is ignored */ /** @@ -115,6 +117,11 @@ class Webhook { * .then(message => console.log(`Sent message: ${message.content}`)) * .catch(console.error); * @example + * // Send a basic message in a thread + * webhook.send('hello!', { threadID: '836856309672348295' }) + * .then(message => console.log(`Sent message: ${message.content}`)) + * .catch(console.error); + * @example * // Send a remote file * webhook.send({ * files: ['https://cdn.discordapp.com/icons/222078108977594368/6e1019b3179d71046e463a75915e7244.png?size=2048'] @@ -169,7 +176,7 @@ class Webhook { .post({ data, files, - query: { wait: true }, + query: { thread_id: apiMessage.options.threadID, wait: true }, auth: false, }) .then(d => { diff --git a/src/util/Constants.js b/src/util/Constants.js index 14fcc28e8..27b977050 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -91,13 +91,13 @@ exports.DefaultOptions = { $browser: 'discord.js', $device: 'discord.js', }, - version: 8, + version: 9, }, /** * HTTP options * @typedef {Object} HTTPOptions - * @property {number} [version=8] API version to use + * @property {number} [version=9] API version to use * @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 @@ -105,7 +105,7 @@ exports.DefaultOptions = { * @property {Object} [headers] Additional headers to send for all API requests */ http: { - version: 8, + version: 9, api: 'https://discord.com/api', cdn: 'https://cdn.discordapp.com', invite: 'https://discord.gg', @@ -262,6 +262,12 @@ exports.Events = { MESSAGE_REACTION_REMOVE: 'messageReactionRemove', MESSAGE_REACTION_REMOVE_ALL: 'messageReactionRemoveAll', MESSAGE_REACTION_REMOVE_EMOJI: 'messageReactionRemoveEmoji', + THREAD_CREATE: 'threadCreate', + THREAD_DELETE: 'threadDelete', + THREAD_UPDATE: 'threadUpdate', + THREAD_LIST_SYNC: 'threadListSync', + THREAD_MEMBER_UPDATE: 'threadMemberUpdate', + THREAD_MEMBERS_UPDATE: 'threadMembersUpdate', USER_UPDATE: 'userUpdate', PRESENCE_UPDATE: 'presenceUpdate', VOICE_SERVER_UPDATE: 'voiceServerUpdate', @@ -341,6 +347,12 @@ exports.PartialTypes = keyMirror(['USER', 'CHANNEL', 'GUILD_MEMBER', 'MESSAGE', * * MESSAGE_REACTION_REMOVE * * MESSAGE_REACTION_REMOVE_ALL * * MESSAGE_REACTION_REMOVE_EMOJI + * * THREAD_CREATE + * * THREAD_UPDATE + * * THREAD_DELETE + * * THREAD_LIST_SYNC + * * THREAD_MEMBER_UPDATE + * * THREAD_MEMBERS_UPDATE * * USER_UPDATE * * PRESENCE_UPDATE * * TYPING_START @@ -387,6 +399,12 @@ exports.WSEvents = keyMirror([ 'MESSAGE_REACTION_REMOVE', 'MESSAGE_REACTION_REMOVE_ALL', 'MESSAGE_REACTION_REMOVE_EMOJI', + 'THREAD_CREATE', + 'THREAD_UPDATE', + 'THREAD_DELETE', + 'THREAD_LIST_SYNC', + 'THREAD_MEMBER_UPDATE', + 'THREAD_MEMBERS_UPDATE', 'USER_UPDATE', 'PRESENCE_UPDATE', 'TYPING_START', @@ -448,8 +466,11 @@ exports.InviteScopes = [ * * GUILD_DISCOVERY_REQUALIFIED * * GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING * * GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING + * * THREAD_CREATED * * REPLY * * APPLICATION_COMMAND + * * THREAD_STARTER_MESSAGE + * * GUILD_INVITE_REMINDER * @typedef {string} MessageType */ exports.MessageTypes = [ @@ -471,9 +492,11 @@ exports.MessageTypes = [ 'GUILD_DISCOVERY_REQUALIFIED', 'GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING', 'GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING', - null, + 'THREAD_CREATED', 'REPLY', 'APPLICATION_COMMAND', + 'THREAD_STARTER_MESSAGE', + 'GUILD_INVITE_REMINDER', ]; /** @@ -509,11 +532,23 @@ exports.ChannelTypes = createEnum([ 'NEWS', // 6 'STORE', - ...Array(6).fill(null), - // 13 + ...Array(3).fill(null), + // 10 + 'NEWS_THREAD', + 'PUBLIC_THREAD', + 'PRIVATE_THREAD', 'STAGE', ]); +/** + * The types of channels that are threads. The available types are: + * * news_thread + * * public_thread + * * private_thread + * @typedef {string} ThreadChannelType + */ +exports.ThreadChannelTypes = ['news_thread', 'public_thread', 'private_thread']; + exports.ClientApplicationAssetTypes = { SMALL: 1, BIG: 2, @@ -629,6 +664,7 @@ exports.VerificationLevels = createEnum(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_ * * MAXIMUM_ANIMATED_EMOJIS * * MAXIMUM_SERVER_MEMBERS * * GUILD_ALREADY_HAS_TEMPLATE + * * MAXIMUM_THREAD_PARTICIPANTS * * MAXIMUM_NON_GUILD_MEMBERS_BANS * * MAXIMUM_BAN_FETCHES * * UNAUTHORIZED @@ -672,11 +708,18 @@ exports.VerificationLevels = createEnum(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_ * * PAYMENT_SOURCE_REQUIRED * * CANNOT_DELETE_COMMUNITY_REQUIRED_CHANNEL * * INVALID_STICKER_SENT + * * INVALID_OPERATION_ON_ARCHIVED_THREAD + * * INVALID_THREAD_NOTIFICATION_SETTINGS + * * PARAMETER_EARLIER_THAN_CREATION * * TWO_FACTOR_REQUIRED * * NO_USERS_WITH_DISCORDTAG_EXIST * * REACTION_BLOCKED * * RESOURCE_OVERLOADED * * STAGE_ALREADY_OPEN + * * MESSAGE_ALREADY_HAS_THREAD + * * THREAD_LOCKED + * * MAXIMUM_ACTIVE_THREADS + * * MAXIMUM_ACTIVE_ANNOUCEMENT_THREAD * @typedef {string} APIError */ exports.APIErrors = { @@ -735,6 +778,7 @@ exports.APIErrors = { MAXIMUM_ANIMATED_EMOJIS: 30018, MAXIMUM_SERVER_MEMBERS: 30019, GUILD_ALREADY_HAS_TEMPLATE: 30031, + MAXIMUM_THREAD_PARTICIPANTS: 30033, MAXIMUM_NON_GUILD_MEMBERS_BANS: 30035, MAXIMUM_BAN_FETCHES: 30037, UNAUTHORIZED: 40001, @@ -778,11 +822,18 @@ exports.APIErrors = { PAYMENT_SOURCE_REQUIRED: 50070, CANNOT_DELETE_COMMUNITY_REQUIRED_CHANNEL: 50074, INVALID_STICKER_SENT: 50081, + INVALID_OPERATION_ON_ARCHIVED_THREAD: 50083, + INVALID_THREAD_NOTIFICATION_SETTINGS: 50084, + PARAMETER_EARLIER_THAN_CREATION: 50085, TWO_FACTOR_REQUIRED: 60003, NO_USERS_WITH_DISCORDTAG_EXIST: 80004, REACTION_BLOCKED: 90001, RESOURCE_OVERLOADED: 130000, STAGE_ALREADY_OPEN: 150006, + MESSAGE_ALREADY_HAS_THREAD: 160004, + THREAD_LOCKED: 160005, + MAXIMUM_ACTIVE_THREADS: 160006, + MAXIMUM_ACTIVE_ANNOUCEMENT_THREAD: 160007, }; /** diff --git a/src/util/MessageFlags.js b/src/util/MessageFlags.js index 36c11ab60..b91a1fc2a 100644 --- a/src/util/MessageFlags.js +++ b/src/util/MessageFlags.js @@ -28,6 +28,7 @@ class MessageFlags extends BitField {} * * `SUPPRESS_EMBEDS` * * `SOURCE_MESSAGE_DELETED` * * `URGENT` + * * `HAS_THREAD` * * `EPHEMERAL` * * `LOADING` * @type {Object} @@ -39,6 +40,7 @@ MessageFlags.FLAGS = { SUPPRESS_EMBEDS: 1 << 2, SOURCE_MESSAGE_DELETED: 1 << 3, URGENT: 1 << 4, + HAS_THREAD: 1 << 5, EPHEMERAL: 1 << 6, LOADING: 1 << 7, }; diff --git a/src/util/Permissions.js b/src/util/Permissions.js index 29aa43fc3..c7809844d 100644 --- a/src/util/Permissions.js +++ b/src/util/Permissions.js @@ -90,6 +90,9 @@ class Permissions extends BitField { * * `MANAGE_EMOJIS` * * `USE_APPLICATION_COMMANDS` * * `REQUEST_TO_SPEAK` + * * `MANAGE_THREADS` + * * `USE_PUBLIC_THREADS` + * * `USE_PRIVATE_THREADS` * @type {Object} * @see {@link https://discord.com/developers/docs/topics/permissions} */ @@ -127,6 +130,9 @@ Permissions.FLAGS = { MANAGE_EMOJIS: 1n << 30n, USE_APPLICATION_COMMANDS: 1n << 31n, REQUEST_TO_SPEAK: 1n << 32n, + MANAGE_THREADS: 1n << 34n, + USE_PUBLIC_THREADS: 1n << 35n, + USE_PRIVATE_THREADS: 1n << 36n, }; /** diff --git a/src/util/Structures.js b/src/util/Structures.js index a8db67180..039a9ca51 100644 --- a/src/util/Structures.js +++ b/src/util/Structures.js @@ -10,7 +10,9 @@ * * **`NewsChannel`** * * **`StoreChannel`** * * **`StageChannel`** + * * **`ThreadChannel`** * * **`GuildMember`** + * * **`ThreadMember`** * * **`Guild`** * * **`Message`** * * **`MessageReaction`** @@ -103,7 +105,9 @@ const structures = { NewsChannel: require('../structures/NewsChannel'), StoreChannel: require('../structures/StoreChannel'), StageChannel: require('../structures/StageChannel'), + ThreadChannel: require('../structures/ThreadChannel'), GuildMember: require('../structures/GuildMember'), + ThreadMember: require('../structures/ThreadMember'), Guild: require('../structures/Guild'), Message: require('../structures/Message'), MessageReaction: require('../structures/MessageReaction'), diff --git a/src/util/ThreadMemberFlags.js b/src/util/ThreadMemberFlags.js new file mode 100644 index 000000000..309ea5929 --- /dev/null +++ b/src/util/ThreadMemberFlags.js @@ -0,0 +1,30 @@ +'use strict'; + +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to interact with a {@link ThreadMember#flags} bitfield. + * @extends {BitField} + */ +class ThreadMemberFlags extends BitField {} + +/** + * @name ThreadMemberFlags + * @kind constructor + * @memberof ThreadMemberFlags + * @param {BitFieldResolvable} [bits=0] Bit(s) to read from + */ + +/** + * Bitfield of the packed bits + * @type {number} + * @name ThreadMemberFlags#bitfield + */ + +/** + * Numeric thread member flags. There are currently no bitflags relevant to bots for this. + * @type {Object} + */ +ThreadMemberFlags.FLAGS = {}; + +module.exports = ThreadMemberFlags; diff --git a/typings/index.d.ts b/typings/index.d.ts index 6015ea917..e9665f50d 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -16,6 +16,9 @@ declare enum ChannelType { news = 5, store = 6, unknown = 7, + news_thread = 10, + public_thread = 11, + private_thread = 12, stage = 13, } @@ -27,6 +30,9 @@ declare enum ChannelTypes { CATEGORY = 4, NEWS = 5, STORE = 6, + NEWS_THREAD = 10, + PUBLIC_THREAD = 11, + PRIVATE_THREAD = 12, STAGE = 13, } @@ -371,7 +377,7 @@ declare module 'discord.js' { type CategoryChannelResolvable = Snowflake | CategoryChannel; export class Channel extends Base { - constructor(client: Client, data?: unknown); + constructor(client: Client, data?: unknown, immediatePatch?: boolean); public readonly createdAt: Date; public readonly createdTimestamp: number; public deleted: boolean; @@ -379,7 +385,7 @@ declare module 'discord.js' { public type: keyof typeof ChannelType; public delete(reason?: string): Promise; public fetch(force?: boolean): Promise; - public isText(): this is TextChannel | DMChannel | NewsChannel; + public isText(): this is TextChannel | DMChannel | NewsChannel | ThreadChannel; public toString(): string; } @@ -625,6 +631,12 @@ declare module 'discord.js' { MESSAGE_REACTION_REMOVE: 'messageReactionRemove'; MESSAGE_REACTION_REMOVE_ALL: 'messageReactionRemoveAll'; MESSAGE_REACTION_REMOVE_EMOJI: 'messageReactionRemoveEmoji'; + THREAD_CREATE: 'threadCreate'; + THREAD_DELETE: 'threadDelete'; + THREAD_UPDATE: 'threadUpdate'; + THREAD_LIST_SYNC: 'threadListSync'; + THREAD_MEMBER_UPDATE: 'threadMemberUpdate'; + THREAD_MEMBERS_UPDATE: 'threadMembersUpdate'; USER_UPDATE: 'userUpdate'; PRESENCE_UPDATE: 'presenceUpdate'; VOICE_SERVER_UPDATE: 'voiceServerUpdate'; @@ -715,6 +727,7 @@ declare module 'discord.js' { }; APIErrors: APIErrors; ChannelTypes: typeof ChannelTypes; + ThreadChannelTypes: ThreadChannelType[]; ClientApplicationAssetTypes: { SMALL: 1; BIG: 2; @@ -1222,14 +1235,14 @@ declare module 'discord.js' { } export class Message extends Base { - constructor(client: Client, data: unknown, channel: TextChannel | DMChannel | NewsChannel); + constructor(client: Client, data: unknown, channel: TextChannel | DMChannel | NewsChannel | ThreadChannel); private patch(data: unknown): Message; public activity: MessageActivity | null; public applicationID: Snowflake | null; public attachments: Collection; public author: User; - public channel: TextChannel | DMChannel | NewsChannel; + public channel: TextChannel | DMChannel | NewsChannel | ThreadChannel; public readonly cleanContent: string; public components: MessageActionRow[]; public content: string; @@ -1255,6 +1268,7 @@ declare module 'discord.js' { public reactions: ReactionManager; public stickers: Collection; public system: boolean; + public thread: ThreadChannel; public tts: boolean; public type: MessageType; public readonly url: string; @@ -1289,6 +1303,11 @@ declare module 'discord.js' { public removeAttachments(): Promise; public reply(options: string | APIMessage | (ReplyMessageOptions & { split?: false })): Promise; public reply(options: APIMessage | (ReplyMessageOptions & { split: true | SplitOptions })): Promise; + public startThread( + name: string, + autoArchiveDuration: ThreadAutoArchiveDuration, + reason?: string, + ): Promise; public suppressEmbeds(suppress?: boolean): Promise; public toJSON(): unknown; public toString(): string; @@ -1514,11 +1533,17 @@ declare module 'discord.js' { export class NewsChannel extends TextBasedChannel(GuildChannel) { constructor(guild: Guild, data?: unknown); + public defaultAutoArchiveDuration?: ThreadAutoArchiveDuration; public messages: MessageManager; public nsfw: boolean; + public threads: ThreadManager; public topic: string | null; public type: 'news'; public createWebhook(name: string, options?: ChannelWebhookCreateOptions): Promise; + public setDefaultAutoArchiveDuration( + defaultAutoArchiveDuration: ThreadAutoArchiveDuration, + reason?: string, + ): Promise; public setNSFW(nsfw: boolean, reason?: string): Promise; public setType(type: Pick, reason?: string): Promise; public fetchWebhooks(): Promise>; @@ -1846,18 +1871,82 @@ declare module 'discord.js' { export class TextChannel extends TextBasedChannel(GuildChannel) { constructor(guild: Guild, data?: unknown); + public defaultAutoArchiveDuration?: ThreadAutoArchiveDuration; public messages: MessageManager; public nsfw: boolean; public type: 'text'; public rateLimitPerUser: number; + public threads: ThreadManager; public topic: string | null; public createWebhook(name: string, options?: ChannelWebhookCreateOptions): Promise; + public setDefaultAutoArchiveDuration( + defaultAutoArchiveDuration: ThreadAutoArchiveDuration, + reason?: string, + ): Promise; public setNSFW(nsfw: boolean, reason?: string): Promise; public setRateLimitPerUser(rateLimitPerUser: number, reason?: string): Promise; public setType(type: Pick, reason?: string): Promise; public fetchWebhooks(): Promise>; } + export class ThreadChannel extends TextBasedChannel(Channel) { + constructor(guild: Guild, data?: object); + public archived: boolean; + public readonly archivedAt: Date | null; + public archiveTimestamp: number | null; + public autoArchiveDuration: ThreadAutoArchiveDuration; + public readonly editable: boolean; + public guild: Guild; + public readonly guildMembers: Collection; + public readonly joinable: boolean; + public locked: boolean; + public readonly manageable: boolean; + public readonly sendable: boolean; + public memberCount: number | null; + public messageCount: number | null; + public messages: MessageManager; + public members: ThreadMemberManager; + public name: string; + public ownerID: Snowflake; + public readonly parent: TextChannel | NewsChannel | null; + public parentID: Snowflake; + public rateLimitPerUser: number; + public type: ThreadChannelType; + public readonly unarchivable: boolean; + public delete(reason?: string): Promise; + public edit(data: ThreadEditData, reason?: string): Promise; + public join(): Promise; + public leave(): Promise; + public permissionsFor(memberOrRole: GuildMember | Role): Readonly; + public permissionsFor(memberOrRole: GuildMemberResolvable | RoleResolvable): Readonly | null; + public setArchived(archived: boolean, reason?: string): Promise; + public setAutoArchiveDuration( + autoArchiveDuration: ThreadAutoArchiveDuration, + reason?: string, + ): Promise; + public setLocked(locked: boolean, reason?: string): Promise; + public setName(name: string, reason?: string): Promise; + public setRateLimitPerUser(rateLimitPerUser: number, reason?: string): Promise; + } + + export class ThreadMember extends Base { + constructor(thread: ThreadChannel, data?: object); + public flags: ThreadMemberFlags; + public readonly guildMember: GuildMember | null; + public id: Snowflake; + public readonly joinedAt: Date | null; + public joinedTimestamp: number | null; + public readonly manageable: boolean; + public thread: ThreadChannel; + public readonly user: User | null; + public remove(reason?: string): Promise; + } + + export class ThreadMemberFlags extends BitField { + public static FLAGS: Record; + public static resolve(bit?: BitFieldResolvable): number; + } + export class User extends PartialTextBasedChannel(Base) { constructor(client: Client, data: unknown); public avatar: string | null; @@ -2264,6 +2353,7 @@ declare module 'discord.js' { export class GuildChannelManager extends BaseManager { constructor(guild: Guild, iterable?: Iterable); + public readonly channelCountWithoutThreads: number; public guild: Guild; public create(name: string, options: GuildChannelCreateOptions & { type: 'voice' }): Promise; public create(name: string, options: GuildChannelCreateOptions & { type: 'category' }): Promise; @@ -2425,6 +2515,33 @@ declare module 'discord.js' { public delete(channel: StageChannel | Snowflake): Promise; } + export class ThreadManager extends BaseManager { + constructor(channel: TextChannel | NewsChannel, iterable?: Iterable); + public channel: TextChannel | NewsChannel; + public create(options: { + name: string; + autoArchiveDuration: ThreadAutoArchiveDuration; + startMessage?: MessageResolvable; + reason?: string; + }): Promise; + public fetch(options: ThreadChannelResolvable, cacheOptions?: BaseFetchOptions): Promise; + public fetch( + options?: { archived?: FetchArchivedThreadOptions; active?: boolean }, + cacheOptions?: { cache?: boolean }, + ): Promise; + public fetchArchived(options?: FetchArchivedThreadOptions, cache?: boolean): Promise; + public fetchActive(cache?: boolean): Promise; + } + + export class ThreadMemberManager extends Omit, 'add'> { + constructor(thread: ThreadChannel, iterable?: Iterable); + public thread: ThreadChannel; + public _add(data: any, cache?: boolean): ThreadMember; + public add(member: UserResolvable | '@me', reason?: string): Promise; + public fetch(cache?: boolean): Promise>; + public remove(id: Snowflake | '@me', reason?: string): Promise; + } + export class UserManager extends BaseManager { constructor(client: Client, iterable?: Iterable); public fetch(id: Snowflake, options?: BaseFetchOptions): Promise; @@ -2595,6 +2712,7 @@ declare module 'discord.js' { MAXIMUM_ANIMATED_EMOJIS: 30018; MAXIMUM_SERVER_MEMBERS: 30019; GUILD_ALREADY_HAS_TEMPLATE: 30031; + MAXIMUM_THREAD_PARICIPANTS: 30033; MAXIMUM_NON_GUILD_MEMBERS_BANS: 30035; MAXIMUM_BAN_FETCHES: 30037; UNAUTHORIZED: 40001; @@ -2638,11 +2756,18 @@ declare module 'discord.js' { PAYMENT_SOURCE_REQUIRED: 50070; CANNOT_DELETE_COMMUNITY_REQUIRED_CHANNEL: 50074; INVALID_STICKER_SENT: 50081; + INVALID_THREAD_ARCHIVE_STATE: 50083; + INVALID_THREAD_NOTIFICATION_SETTINGS: 50084; + PARAMETER_EARLIER_THAN_CREATION: 50085; TWO_FACTOR_REQUIRED: 60003; NO_USERS_WITH_DISCORDTAG_EXIST: 80004; REACTION_BLOCKED: 90001; RESOURCE_OVERLOADED: 130000; STAGE_ALREADY_OPEN: 150006; + MESSAGE_ALREADY_HAS_THREAD: 160004; + THREAD_LOCKED: 160005; + MAXIMUM_ACTIVE_THREADS: 160006; + MAXIMUM_ACTIVE_ANNOUCEMENT_THREAD: 160007; } interface ApplicationAsset { @@ -2765,6 +2890,7 @@ declare module 'discord.js' { rateLimitPerUser?: number; lockPermissions?: boolean; permissionOverwrites?: readonly OverwriteResolvable[] | Collection; + defaultAutoArchiveDuration?: ThreadAutoArchiveDuration; rtcRegion?: string | null; } @@ -2838,7 +2964,16 @@ declare module 'discord.js' { roleCreate: [role: Role]; roleDelete: [role: Role]; roleUpdate: [oldRole: Role, newRole: Role]; - typingStart: [channel: TextChannel | NewsChannel | DMChannel | PartialDMChannel, user: User | PartialUser]; + threadCreate: [thread: ThreadChannel]; + threadDelete: [thread: ThreadChannel]; + threadListSync: [threads: Collection]; + threadMemberUpdate: [oldMember: ThreadMember, newMember: ThreadMember]; + threadMembersUpdate: [ + oldMembers: Collection, + mewMembers: Collection, + ]; + threadUpdate: [oldThread: ThreadChannel, newThread: ThreadChannel]; + typingStart: [channel: Channel | PartialDMChannel, user: User | PartialUser]; userUpdate: [oldUser: User | PartialUser, newUser: User]; voiceStateUpdate: [oldState: VoiceState, newState: VoiceState]; webhookUpdate: [channel: TextChannel]; @@ -2972,6 +3107,8 @@ declare module 'discord.js' { name: string; } + type DateResolvable = Date | number | string; + interface DeconstructedSnowflake { timestamp: number; readonly date: Date; @@ -3033,7 +3170,9 @@ declare module 'discord.js' { CategoryChannel: typeof CategoryChannel; NewsChannel: typeof NewsChannel; StoreChannel: typeof StoreChannel; + ThreadChannel: typeof ThreadChannel; GuildMember: typeof GuildMember; + ThreadMember: typeof ThreadMember; Guild: typeof Guild; Message: typeof Message; MessageReaction: typeof MessageReaction; @@ -3067,6 +3206,18 @@ declare module 'discord.js' { limit?: number; } + interface FetchArchivedThreadOptions { + type?: 'public' | 'private'; + fetchAll?: boolean; + before?: ThreadChannelResolvable | DateResolvable; + limit?: number; + } + + interface FetchedThreads { + threads: Collection; + hasMore?: boolean; + } + interface FetchMemberOptions extends BaseFetchOptions { user: UserResolvable; } @@ -3182,7 +3333,16 @@ declare module 'discord.js' { topic?: string; type?: Exclude< keyof typeof ChannelType | ChannelType, - 'dm' | 'group' | 'unknown' | ChannelType.dm | ChannelType.group | ChannelType.unknown + | 'dm' + | 'group' + | 'unknown' + | 'public_thread' + | 'private_thread' + | ChannelType.dm + | ChannelType.group + | ChannelType.unknown + | ChannelType.public_thread + | ChannelType.private_thread >; nsfw?: boolean; parent?: ChannelResolvable; @@ -3535,6 +3695,7 @@ declare module 'discord.js' { | 'SUPPRESS_EMBEDS' | 'SOURCE_MESSAGE_DELETED' | 'URGENT' + | 'HAS_THREAD' | 'EPHEMERAL' | 'LOADING'; @@ -3594,6 +3755,7 @@ declare module 'discord.js' { | InteractionWebhook | TextChannel | NewsChannel + | ThreadChannel | DMChannel | User | GuildMember @@ -3620,8 +3782,11 @@ declare module 'discord.js' { | 'GUILD_DISCOVERY_REQUALIFIED' | 'GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING' | 'GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING' + | 'THREAD_CREATED' | 'REPLY' - | 'APPLICATION_COMMAND'; + | 'APPLICATION_COMMAND' + | 'THREAD_STARTER_MESSAGE' + | 'GUILD_INVITE_REMINDER'; type MFALevel = keyof typeof MFALevels; @@ -3691,7 +3856,10 @@ declare module 'discord.js' { | 'MANAGE_WEBHOOKS' | 'MANAGE_EMOJIS' | 'USE_APPLICATION_COMMANDS' - | 'REQUEST_TO_SPEAK'; + | 'REQUEST_TO_SPEAK' + | 'MANAGE_THREADS' + | 'USE_PUBLIC_THREADS' + | 'USE_PRIVATE_THREADS'; interface RecursiveArray extends ReadonlyArray> {} @@ -3969,6 +4137,24 @@ declare module 'discord.js' { privacyLevel?: PrivacyLevel | number; } + type ThreadAutoArchiveDuration = 60 | 1440 | 4320 | 10080; + + type ThreadChannelResolvable = ThreadChannel | Snowflake; + + type ThreadChannelType = 'news_thread' | 'public_thread' | 'private_thread'; + + interface ThreadEditData { + name?: string; + archived?: boolean; + autoArchiveDuration?: ThreadAutoArchiveDuration; + rateLimitPeruser?: number; + locked?: boolean; + } + + type ThreadMemberFlagsString = ''; + + type ThreadMemberResolvable = ThreadMember | UserResolvable; + type UserFlagsString = | 'DISCORD_EMPLOYEE' | 'PARTNERED_SERVER_OWNER' @@ -3984,7 +4170,7 @@ declare module 'discord.js' { | 'EARLY_VERIFIED_BOT_DEVELOPER' | 'DISCORD_CERTIFIED_MODERATOR'; - type UserResolvable = User | Snowflake | Message | GuildMember; + type UserResolvable = User | Snowflake | Message | GuildMember | ThreadMember; interface Vanity { code: string | null; @@ -4012,6 +4198,7 @@ declare module 'discord.js' { interface WebhookMessageOptions extends Omit { username?: string; avatarURL?: string; + threadID?: Snowflake; } type WebhookType = keyof typeof WebhookTypes;