feat: api v9 and threads (#5570)

Co-authored-by: Noel <icrawltogo@gmail.com>
Co-authored-by: Amish Shah <dev@shah.gg>
Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
Co-authored-by: SynthGhost <60333233+synthghost@users.noreply.github.com>
Co-authored-by: SpaceEEC <24881032+SpaceEEC@users.noreply.github.com>
Co-authored-by: Elliot <elliot@maisl.fr>
Co-authored-by: Antonio Román <kyradiscord@gmail.com>
Co-authored-by: Sugden <28943913+NotSugden@users.noreply.github.com>
This commit is contained in:
ckohen
2021-06-24 12:48:29 -07:00
committed by GitHub
parent ea49f7ca74
commit 7346621d15
34 changed files with 1461 additions and 24 deletions

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -368,6 +368,8 @@ class GuildChannel extends Channel {
* @property {OverwriteResolvable[]|Collection<Snowflake, OverwriteResolvable>} [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,

View File

@@ -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<ThreadChannel>}
*/
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

View File

@@ -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<TextChannel>}
*/
setDefaultAutoArchiveDuration(defaultAutoArchiveDuration, reason) {
return this.edit({ defaultAutoArchiveDuration }, reason);
}
/**
* Sets the rate limit per user for this channel.
* <warn>It is not currently possible to set the rate limit per user on a `NewsChannel`.</warn>

View File

@@ -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
* <info>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</info>
* @type {number}
*/
this.messageCount = data.message_count;
/**
* The approximate count of users in this thread
* <info>This value will not count above 50 even when there are more than 50 members</info>
* @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<Snowflake, GuildMember>}
* @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<ThreadChannel>}
*/
join() {
return this.members.add('@me').then(() => this);
}
/**
* Makes the client user leave the thread.
* @returns {Promise<ThreadChannel>}
*/
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<Permissions>}
*/
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<ThreadChannel>}
* @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<ThreadChannel>}
* @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<ThreadChannel>}
* @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<ThreadChannel>}
* @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<ThreadChannel>}
* @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<ThreadChannel>}
*/
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<ThreadChannel>}
* @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;

View File

@@ -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;

View File

@@ -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.
* <info>For interaction webhooks, this property is ignored</info>
*/
/**
@@ -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 => {