From 5c3f5d704840fca3888264e19eb38d88977304ea Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 13 Feb 2019 17:39:39 +0000 Subject: [PATCH] Partials (#3070) * Remove GroupDMChannels they sparked no joy * Start partials for message deletion * MessageUpdate partials * Add partials as an opt-in client option * Add fetch() to Message * Message.author should never be undefined * Fix channels being the wrong type * Allow fetching channels * Refactor and add reaction add partials * Reaction remove partials * Check for emoji first * fix message fetching janky * User partials in audit logs * refactor overwrite code * guild member partials * partials as a whitelist * document GuildMember#fetch * fix: check whether a structure is a partial, not whether cache is true * typings: Updated for latest commit (#3075) * partials: fix messageUpdate behaviour (now "old" message can be partial) * partials: add warnings and docs * partials: add partials to index.yml * partials: tighten "partial" definitions * partials: fix embed-only messages counting as partials --- docs/index.yml | 2 + docs/topics/partials.md | 61 +++++ src/client/Client.js | 3 + src/client/actions/Action.js | 23 ++ src/client/actions/ChannelCreate.js | 2 +- src/client/actions/ChannelDelete.js | 2 +- src/client/actions/MessageDelete.js | 5 +- src/client/actions/MessageReactionAdd.js | 10 +- src/client/actions/MessageReactionRemove.js | 10 +- .../actions/MessageReactionRemoveAll.js | 6 +- src/client/actions/MessageUpdate.js | 7 +- .../websocket/handlers/CHANNEL_PINS_UPDATE.js | 2 +- .../websocket/handlers/CHANNEL_UPDATE.js | 4 +- src/index.js | 1 - src/stores/ChannelStore.js | 10 +- src/stores/DataStore.js | 1 + src/stores/GuildMemberStore.js | 2 +- src/stores/MessageStore.js | 15 +- src/stores/UserStore.js | 8 +- src/structures/APIMessage.js | 2 +- src/structures/Channel.js | 14 +- src/structures/ClientUser.js | 36 --- src/structures/DMChannel.js | 22 +- src/structures/GroupDMChannel.js | 245 ------------------ src/structures/Guild.js | 6 +- src/structures/GuildAuditLogs.js | 15 +- src/structures/GuildMember.js | 18 +- src/structures/Message.js | 32 ++- src/structures/MessageCollector.js | 2 +- src/structures/User.js | 18 ++ src/util/Constants.js | 21 ++ src/util/Structures.js | 1 - src/util/Util.js | 4 +- test/voice.js | 11 +- typings/index.d.ts | 57 ++-- 35 files changed, 295 insertions(+), 383 deletions(-) create mode 100644 docs/topics/partials.md delete mode 100644 src/structures/GroupDMChannel.js diff --git a/docs/index.yml b/docs/index.yml index 7c5b3b280..175780574 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -12,6 +12,8 @@ path: voice.md - name: Web builds path: web.md + - name: Partials + path: partials.md - name: Examples files: - name: Ping diff --git a/docs/topics/partials.md b/docs/topics/partials.md new file mode 100644 index 000000000..2566f3def --- /dev/null +++ b/docs/topics/partials.md @@ -0,0 +1,61 @@ +# Partials + +Partials allow you to receive events that contain uncached instances, providing structures that contain very minimal +data. For example, if you were to receive a `messageDelete` event with an uncached message, normally Discord.js would +discard the event. With partials, you're able to receive the event, with a Message object that contains just an ID. + +## Opting in + +Partials are opt-in, and you can enable them in the Client options by specifying [PartialTypes](../typedef/PartialType): + +```js +// Accept partial messages and DM channels when emitting events +new Client({ partials: ['MESSAGE', 'CHANNEL'] }); +``` + +## Usage & warnings + +The only guaranteed data a partial structure can store is its ID. All other properties/methods should be +considered invalid/defunct while accessing a partial structure. + +After opting-in with the above, you begin to allow partial messages and channels in your caches, so it's important +to check whether they're safe to access whenever you encounter them, whether it be in events or through normal cache +usage. + +All instance of structures that you opted-in for will have a `partial` property. As you'd expect, this value is `true` +when the instance is partial. Partial structures are only guaranteed to contain an ID, any other properties and methods +no longer carry their normal type guarantees. + +This means you have to take time to consider possible parts of your program that might need checks put in place to +prevent accessing partial data: + +```js +client.on('messageDelete', message => { + console.log(`${message.id} was deleted!`); + // Partial messages do not contain any content so skip them + if (!message.partial) { + console.log(`It had content: "${message.content}"`); + } +}) + +// You can also try to upgrade partials to full instances: +client.on('messageReactionAdd', async (reaction, user) => { + // If a message gains a reaction and it is uncached, fetch and cache the message + // You should account for any errors while fetching, it could return API errors if the resource is missing + if (reaction.message.partial) await reaction.message.fetch(); + // Now the message has been cached and is fully available: + console.log(`${reaction.message.author}'s message "${reaction.message.content}" gained a reaction!`); +}); +``` + +If a message is deleted and both the message and channel are uncached, you must enable both 'MESSAGE' and +'CHANNEL' in the client options to receive the messageDelete event. + +## Why? + +This allows developers to listen to events that contain uncached data, which is useful if you're running a moderation +bot or any bot that relies on still receiving updates to resources you don't have cached -- message reactions are a +good example. + +Currently, the only type of channel that can be uncached is a DM channel, there is no reason why guild channels should +not be cached. \ No newline at end of file diff --git a/src/client/Client.js b/src/client/Client.js index 4e5f5e247..b1dc15573 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -434,6 +434,9 @@ class Client extends BaseClient { if (typeof options.disableEveryone !== 'boolean') { throw new TypeError('CLIENT_INVALID_OPTION', 'disableEveryone', 'a boolean'); } + if (!(options.partials instanceof Array)) { + throw new TypeError('CLIENT_INVALID_OPTION', 'partials', 'an Array'); + } if (typeof options.restWsBridgeTimeout !== 'number' || isNaN(options.restWsBridgeTimeout)) { throw new TypeError('CLIENT_INVALID_OPTION', 'restWsBridgeTimeout', 'a number'); } diff --git a/src/client/actions/Action.js b/src/client/actions/Action.js index 791eaa00c..09faae9e2 100644 --- a/src/client/actions/Action.js +++ b/src/client/actions/Action.js @@ -1,5 +1,7 @@ 'use strict'; +const { PartialTypes } = require('../../util/Constants'); + /* ABOUT ACTIONS @@ -20,6 +22,27 @@ class GenericAction { handle(data) { return data; } + + getChannel(data) { + const id = data.channel_id || data.id; + return data.channel || (this.client.options.partials.includes(PartialTypes.CHANNEL) ? + this.client.channels.add({ + id, + guild_id: data.guild_id, + }) : + this.client.channels.get(id)); + } + + getMessage(data, channel) { + const id = data.message_id || data.id; + return data.message || (this.client.options.partials.includes(PartialTypes.MESSAGE) ? + channel.messages.add({ + id, + channel_id: channel.id, + guild_id: data.guild_id || (channel.guild ? channel.guild.id : null), + }) : + channel.messages.get(id)); + } } module.exports = GenericAction; diff --git a/src/client/actions/ChannelCreate.js b/src/client/actions/ChannelCreate.js index 4a9d17d45..6830f2ab5 100644 --- a/src/client/actions/ChannelCreate.js +++ b/src/client/actions/ChannelCreate.js @@ -12,7 +12,7 @@ class ChannelCreateAction extends Action { /** * Emitted whenever a channel is created. * @event Client#channelCreate - * @param {DMChannel|GroupDMChannel|GuildChannel} channel The channel that was created + * @param {DMChannel|GuildChannel} channel The channel that was created */ client.emit(Events.CHANNEL_CREATE, channel); } diff --git a/src/client/actions/ChannelDelete.js b/src/client/actions/ChannelDelete.js index b9909f8ba..9fc0e6d99 100644 --- a/src/client/actions/ChannelDelete.js +++ b/src/client/actions/ChannelDelete.js @@ -19,7 +19,7 @@ class ChannelDeleteAction extends Action { /** * Emitted whenever a channel is deleted. * @event Client#channelDelete - * @param {DMChannel|GroupDMChannel|GuildChannel} channel The channel that was deleted + * @param {DMChannel|GuildChannel} channel The channel that was deleted */ client.emit(Events.CHANNEL_DELETE, channel); } diff --git a/src/client/actions/MessageDelete.js b/src/client/actions/MessageDelete.js index d66d10a28..feb118c10 100644 --- a/src/client/actions/MessageDelete.js +++ b/src/client/actions/MessageDelete.js @@ -6,11 +6,10 @@ const { Events } = require('../../util/Constants'); class MessageDeleteAction extends Action { handle(data) { const client = this.client; - const channel = client.channels.get(data.channel_id); + const channel = this.getChannel(data); let message; - if (channel) { - message = channel.messages.get(data.id); + message = this.getMessage(data, channel); if (message) { channel.messages.delete(message.id); message.deleted = true; diff --git a/src/client/actions/MessageReactionAdd.js b/src/client/actions/MessageReactionAdd.js index a800c3c7a..b7400cf77 100644 --- a/src/client/actions/MessageReactionAdd.js +++ b/src/client/actions/MessageReactionAdd.js @@ -11,15 +11,19 @@ const Action = require('./Action'); class MessageReactionAdd extends Action { handle(data) { + if (!data.emoji) return false; + const user = data.user || this.client.users.get(data.user_id); if (!user) return false; + // Verify channel - const channel = data.channel || this.client.channels.get(data.channel_id); + const channel = this.getChannel(data); if (!channel || channel.type === 'voice') return false; + // Verify message - const message = data.message || channel.messages.get(data.message_id); + const message = this.getMessage(data, channel); if (!message) return false; - if (!data.emoji) return false; + // Verify reaction const reaction = message.reactions.add({ emoji: data.emoji, diff --git a/src/client/actions/MessageReactionRemove.js b/src/client/actions/MessageReactionRemove.js index 7ab5be289..e5d4f8413 100644 --- a/src/client/actions/MessageReactionRemove.js +++ b/src/client/actions/MessageReactionRemove.js @@ -12,15 +12,19 @@ const { Events } = require('../../util/Constants'); class MessageReactionRemove extends Action { handle(data) { + if (!data.emoji) return false; + const user = this.client.users.get(data.user_id); if (!user) return false; + // Verify channel - const channel = this.client.channels.get(data.channel_id); + const channel = this.getChannel(data); if (!channel || channel.type === 'voice') return false; + // Verify message - const message = channel.messages.get(data.message_id); + const message = this.getMessage(data, channel); if (!message) return false; - if (!data.emoji) return false; + // Verify reaction const emojiID = data.emoji.id || decodeURIComponent(data.emoji.name); const reaction = message.reactions.get(emojiID); diff --git a/src/client/actions/MessageReactionRemoveAll.js b/src/client/actions/MessageReactionRemoveAll.js index f32730977..0921ce50e 100644 --- a/src/client/actions/MessageReactionRemoveAll.js +++ b/src/client/actions/MessageReactionRemoveAll.js @@ -5,10 +5,12 @@ const { Events } = require('../../util/Constants'); class MessageReactionRemoveAll extends Action { handle(data) { - const channel = this.client.channels.get(data.channel_id); + // Verify channel + const channel = this.getChannel(data); if (!channel || channel.type === 'voice') return false; - const message = channel.messages.get(data.message_id); + // Verify message + const message = this.getMessage(data, channel); if (!message) return false; message.reactions.clear(); diff --git a/src/client/actions/MessageUpdate.js b/src/client/actions/MessageUpdate.js index be26b2692..07e2aacb3 100644 --- a/src/client/actions/MessageUpdate.js +++ b/src/client/actions/MessageUpdate.js @@ -4,11 +4,10 @@ const Action = require('./Action'); class MessageUpdateAction extends Action { handle(data) { - const client = this.client; - - const channel = client.channels.get(data.channel_id); + const channel = this.getChannel(data); if (channel) { - const message = channel.messages.get(data.id); + const { id, channel_id, guild_id, author, timestamp, type } = data; + const message = this.getMessage({ id, channel_id, guild_id, author, timestamp, type }, channel); if (message) { message.patch(data); return { diff --git a/src/client/websocket/handlers/CHANNEL_PINS_UPDATE.js b/src/client/websocket/handlers/CHANNEL_PINS_UPDATE.js index dfc854e37..11154674a 100644 --- a/src/client/websocket/handlers/CHANNEL_PINS_UPDATE.js +++ b/src/client/websocket/handlers/CHANNEL_PINS_UPDATE.js @@ -14,7 +14,7 @@ module.exports = (client, { d: data }) => { * Emitted whenever the pins of a channel are updated. Due to the nature of the WebSocket event, * not much information can be provided easily here - you need to manually check the pins yourself. * @event Client#channelPinsUpdate - * @param {DMChannel|GroupDMChannel|TextChannel} channel The channel that the pins update occured in + * @param {DMChannel|TextChannel} channel The channel that the pins update occured in * @param {Date} time The time of the pins update */ client.emit(Events.CHANNEL_PINS_UPDATE, channel, time); diff --git a/src/client/websocket/handlers/CHANNEL_UPDATE.js b/src/client/websocket/handlers/CHANNEL_UPDATE.js index b437ebbde..7a0df486a 100644 --- a/src/client/websocket/handlers/CHANNEL_UPDATE.js +++ b/src/client/websocket/handlers/CHANNEL_UPDATE.js @@ -8,8 +8,8 @@ module.exports = (client, packet) => { /** * Emitted whenever a channel is updated - e.g. name change, topic change. * @event Client#channelUpdate - * @param {DMChannel|GroupDMChannel|GuildChannel} oldChannel The channel before the update - * @param {DMChannel|GroupDMChannel|GuildChannel} newChannel The channel after the update + * @param {DMChannel|GuildChannel} oldChannel The channel before the update + * @param {DMChannel|GuildChannel} newChannel The channel after the update */ client.emit(Events.CHANNEL_UPDATE, old, updated); } diff --git a/src/index.js b/src/index.js index d9b8cb451..b3a92f60c 100644 --- a/src/index.js +++ b/src/index.js @@ -65,7 +65,6 @@ module.exports = { Collector: require('./structures/interfaces/Collector'), DMChannel: require('./structures/DMChannel'), Emoji: require('./structures/Emoji'), - GroupDMChannel: require('./structures/GroupDMChannel'), Guild: require('./structures/Guild'), GuildAuditLogs: require('./structures/GuildAuditLogs'), GuildChannel: require('./structures/GuildChannel'), diff --git a/src/stores/ChannelStore.js b/src/stores/ChannelStore.js index 9ce6465cf..88c5a0bb3 100644 --- a/src/stores/ChannelStore.js +++ b/src/stores/ChannelStore.js @@ -5,7 +5,7 @@ const Channel = require('../structures/Channel'); const { Events } = require('../util/Constants'); const kLru = Symbol('LRU'); -const lruable = ['group', 'dm']; +const lruable = ['dm']; /** * Stores channels. @@ -54,6 +54,7 @@ class ChannelStore extends DataStore { add(data, guild, cache = true) { const existing = this.get(data.id); + if (existing && existing.partial && cache) existing._patch(data); if (existing) return existing; const channel = Channel.create(this.client, data, guild); @@ -85,11 +86,12 @@ class ChannelStore extends DataStore { * .then(channel => console.log(channel.name)) * .catch(console.error); */ - fetch(id, cache = true) { + async fetch(id, cache = true) { const existing = this.get(id); - if (existing) return Promise.resolve(existing); + if (existing && !existing.partial) return existing; - return this.client.api.channels(id).get().then(data => this.add(data, null, cache)); + const data = await this.client.api.channels(id).get(); + return this.add(data, null, cache); } /** diff --git a/src/stores/DataStore.js b/src/stores/DataStore.js index fc841899a..ade8c70f7 100644 --- a/src/stores/DataStore.js +++ b/src/stores/DataStore.js @@ -18,6 +18,7 @@ class DataStore extends Collection { add(data, cache = true, { id, extras = [] } = {}) { const existing = this.get(id || data.id); + if (existing && existing.partial && cache && existing._patch) existing._patch(data); if (existing) return existing; const entry = this.holds ? new this.holds(this.client, data, ...extras) : data; diff --git a/src/stores/GuildMemberStore.js b/src/stores/GuildMemberStore.js index 039fb4c7b..397c119b7 100644 --- a/src/stores/GuildMemberStore.js +++ b/src/stores/GuildMemberStore.js @@ -180,7 +180,7 @@ class GuildMemberStore extends DataStore { _fetchSingle({ user, cache }) { const existing = this.get(user); - if (existing && existing.joinedTimestamp) return Promise.resolve(existing); + if (existing && !existing.partial) return Promise.resolve(existing); return this.client.api.guilds(this.guild.id).members(user).get() .then(data => this.add(data, cache)); } diff --git a/src/stores/MessageStore.js b/src/stores/MessageStore.js index 1a8a7083a..52bf7fdd6 100644 --- a/src/stores/MessageStore.js +++ b/src/stores/MessageStore.js @@ -40,6 +40,7 @@ class MessageStore extends DataStore { * The returned Collection does not contain reaction users of the messages if they were not cached. * Those need to be fetched separately in such a case. * @param {Snowflake|ChannelLogsQueryOptions} [message] The ID of the message to fetch, or query parameters. + * @param {boolean} [cache=true] Whether to cache the message(s) * @returns {Promise|Promise>} * @example * // Get message @@ -57,8 +58,8 @@ class MessageStore extends DataStore { * .then(messages => console.log(`${messages.filter(m => m.author.id === '84484653687267328').size} messages`)) * .catch(console.error); */ - fetch(message) { - return typeof message === 'string' ? this._fetchId(message) : this._fetchMany(message); + fetch(message, cache = true) { + return typeof message === 'string' ? this._fetchId(message, cache) : this._fetchMany(message, cache); } /** @@ -80,15 +81,17 @@ class MessageStore extends DataStore { }); } - async _fetchId(messageID) { + async _fetchId(messageID, cache) { + const existing = this.get(messageID); + if (existing && !existing.partial) return existing; const data = await this.client.api.channels[this.channel.id].messages[messageID].get(); - return this.add(data); + return this.add(data, cache); } - async _fetchMany(options = {}) { + async _fetchMany(options = {}, cache) { const data = await this.client.api.channels[this.channel.id].messages.get({ query: options }); const messages = new Collection(); - for (const message of data) messages.set(message.id, this.add(message)); + for (const message of data) messages.set(message.id, this.add(message, cache)); return messages; } diff --git a/src/stores/UserStore.js b/src/stores/UserStore.js index 8fd2d9d5a..20c25d05b 100644 --- a/src/stores/UserStore.js +++ b/src/stores/UserStore.js @@ -51,11 +51,11 @@ class UserStore extends DataStore { * @param {boolean} [cache=true] Whether to cache the new user object if it isn't already * @returns {Promise} */ - fetch(id, cache = true) { + async fetch(id, cache = true) { const existing = this.get(id); - if (existing) return Promise.resolve(existing); - - return this.client.api.users(id).get().then(data => this.add(data, cache)); + if (existing && !existing.partial) return existing; + const data = await this.client.api.users(id).get(); + return this.add(data, cache); } } diff --git a/src/structures/APIMessage.js b/src/structures/APIMessage.js index deca5d26d..41f983839 100644 --- a/src/structures/APIMessage.js +++ b/src/structures/APIMessage.js @@ -330,7 +330,7 @@ module.exports = APIMessage; /** * A target for a message. - * @typedef {TextChannel|DMChannel|GroupDMChannel|User|GuildMember|Webhook|WebhookClient} MessageTarget + * @typedef {TextChannel|DMChannel|User|GuildMember|Webhook|WebhookClient} MessageTarget */ /** diff --git a/src/structures/Channel.js b/src/structures/Channel.js index 92914118d..b43411949 100644 --- a/src/structures/Channel.js +++ b/src/structures/Channel.js @@ -16,7 +16,6 @@ class Channel extends Base { /** * The type of the channel, either: * * `dm` - a DM channel - * * `group` - a Group DM channel * * `text` - a guild text channel * * `voice` - a guild voice channel * * `category` - a guild category channel @@ -84,15 +83,20 @@ class Channel extends Base { return this.client.api.channels(this.id).delete().then(() => this); } + /** + * Fetches this channel. + * @returns {Promise} + */ + fetch() { + return this.client.channels.fetch(this.id, true); + } + static create(client, data, guild) { const Structures = require('../util/Structures'); let channel; - if (data.type === ChannelTypes.DM) { + if (data.type === ChannelTypes.DM || (data.type !== ChannelTypes.GROUP && !data.guild_id && !guild)) { const DMChannel = Structures.get('DMChannel'); channel = new DMChannel(client, data); - } else if (data.type === ChannelTypes.GROUP) { - const GroupDMChannel = Structures.get('GroupDMChannel'); - channel = new GroupDMChannel(client, data); } else { guild = guild || client.guilds.get(data.guild_id); if (guild) { diff --git a/src/structures/ClientUser.js b/src/structures/ClientUser.js index 3ec32e9dd..a86a6cb56 100644 --- a/src/structures/ClientUser.js +++ b/src/structures/ClientUser.js @@ -164,42 +164,6 @@ class ClientUser extends Structures.get('User') { setAFK(afk) { return this.setPresence({ afk }); } - - /** - * An object containing either a user or access token, and an optional nickname. - * @typedef {Object} GroupDMRecipientOptions - * @property {UserResolvable} [user] User to add to the Group DM - * @property {string} [accessToken] Access token to use to add a user to the Group DM - * (only available if a bot is creating the DM) - * @property {string} [nick] Permanent nickname (only available if a bot is creating the DM) - * @property {string} [id] If no user resolvable is provided and you want to assign nicknames - * you must provide user ids instead - */ - - /** - * Creates a Group DM. - * @param {GroupDMRecipientOptions[]} recipients The recipients - * @returns {Promise} - * @example - * // Create a Group DM with a token provided from OAuth - * client.user.createGroupDM([{ - * user: '66564597481480192', - * accessToken: token - * }]) - * .then(console.log) - * .catch(console.error); - */ - createGroupDM(recipients) { - const data = this.bot ? { - access_tokens: recipients.map(u => u.accessToken), - nicks: recipients.reduce((o, r) => { - if (r.nick) o[r.user ? r.user.id : r.id] = r.nick; - return o; - }, {}), - } : { recipients: recipients.map(u => this.client.users.resolveID(u.user || u.id)) }; - return this.client.api.users('@me').channels.post({ data }) - .then(res => this.client.channels.add(res)); - } } module.exports = ClientUser; diff --git a/src/structures/DMChannel.js b/src/structures/DMChannel.js index 7c3db02e6..f1280e802 100644 --- a/src/structures/DMChannel.js +++ b/src/structures/DMChannel.js @@ -12,6 +12,8 @@ const MessageStore = require('../stores/MessageStore'); class DMChannel extends Channel { constructor(client, data) { super(client, data); + // Override the channel type so partials have a known type + this.type = 'dm'; /** * A collection containing the messages sent to this channel * @type {MessageStore} @@ -23,11 +25,13 @@ class DMChannel extends Channel { _patch(data) { super._patch(data); - /** - * The recipient on the other end of the DM - * @type {User} - */ - this.recipient = this.client.users.add(data.recipients[0]); + if (data.recipients) { + /** + * The recipient on the other end of the DM + * @type {User} + */ + this.recipient = this.client.users.add(data.recipients[0]); + } /** * The ID of the last message in the channel, if one was sent @@ -42,6 +46,14 @@ class DMChannel extends Channel { this.lastPinTimestamp = data.last_pin_timestamp ? new Date(data.last_pin_timestamp).getTime() : null; } + /** + * Whether this DMChannel is a partial + * @type {boolean} + */ + get partial() { + return !this.recipient; + } + /** * When concatenated with a string, this automatically returns the recipient's mention instead of the * DMChannel object. diff --git a/src/structures/GroupDMChannel.js b/src/structures/GroupDMChannel.js deleted file mode 100644 index 39fb56468..000000000 --- a/src/structures/GroupDMChannel.js +++ /dev/null @@ -1,245 +0,0 @@ -'use strict'; - -const Channel = require('./Channel'); -const TextBasedChannel = require('./interfaces/TextBasedChannel'); -const Collection = require('../util/Collection'); -const DataResolver = require('../util/DataResolver'); -const MessageStore = require('../stores/MessageStore'); - -/* -{ type: 3, - recipients: - [ { username: 'Charlie', - id: '123', - discriminator: '6631', - avatar: '123' }, - { username: 'Ben', - id: '123', - discriminator: '2055', - avatar: '123' }, - { username: 'Adam', - id: '123', - discriminator: '2406', - avatar: '123' } ], - owner_id: '123', - name: null, - last_message_id: '123', - id: '123', - icon: null } -*/ - -/** - * Represents a Group DM on Discord. - * @extends {Channel} - * @implements {TextBasedChannel} - */ -class GroupDMChannel extends Channel { - constructor(client, data) { - super(client, data); - /** - * A collection containing the messages sent to this channel - * @type {MessageStore} - */ - this.messages = new MessageStore(this); - this._typing = new Map(); - } - - _patch(data) { - super._patch(data); - - /** - * The name of this Group DM, can be null if one isn't set - * @type {string} - */ - this.name = data.name; - - /** - * A hash of this Group DM icon - * @type {?string} - */ - this.icon = data.icon; - - /** - * The user ID of this Group DM's owner - * @type {Snowflake} - */ - this.ownerID = data.owner_id; - - /** - * If the DM is managed by an application - * @type {boolean} - */ - this.managed = data.managed; - - /** - * Application ID of the application that made this Group DM, if applicable - * @type {?Snowflake} - */ - this.applicationID = data.application_id; - - if (data.nicks) { - /** - * Nicknames for group members - * @type {?Collection} - */ - this.nicks = new Collection(data.nicks.map(n => [n.id, n.nick])); - } - - if (!this.recipients) { - /** - * A collection of the recipients of this DM, mapped by their ID - * @type {Collection} - */ - this.recipients = new Collection(); - } - - if (data.recipients) { - for (const recipient of data.recipients) { - const user = this.client.users.add(recipient); - this.recipients.set(user.id, user); - } - } - - /** - * The ID of the last message in the channel, 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 owner of this Group DM - * @type {?User} - * @readonly - */ - get owner() { - return this.client.users.get(this.ownerID) || null; - } - - /** - * Gets the URL to this Group DM's icon. - * @param {ImageURLOptions} [options={}] Options for the Image URL - * @returns {?string} - */ - iconURL({ format, size } = {}) { - if (!this.icon) return null; - return this.client.rest.cdn.GDMIcon(this.id, this.icon, format, size); - } - - /** - * Whether this channel equals another channel. It compares all properties, so for most operations - * it is advisable to just compare `channel.id === channel2.id` as it is much faster and is often - * what most users need. - * @param {GroupDMChannel} channel Channel to compare with - * @returns {boolean} - */ - equals(channel) { - const equal = channel && - this.id === channel.id && - this.name === channel.name && - this.icon === channel.icon && - this.ownerID === channel.ownerID; - - if (equal) { - return this.recipients.equals(channel.recipients); - } - - return equal; - } - - /** - * Edits this Group DM. - * @param {Object} data New data for this Group DM - * @param {string} [reason] Reason for editing this Group DM - * @returns {Promise} - */ - edit(data, reason) { - return this.client.api.channels[this.id].patch({ - data: { - icon: data.icon, - name: data.name === null ? null : data.name || this.name, - }, - reason, - }).then(() => this); - } - - /** - * Sets a new icon for this Group DM. - * @param {Base64Resolvable|BufferResolvable} icon The new icon of this Group DM - * @returns {Promise} - */ - async setIcon(icon) { - return this.edit({ icon: await DataResolver.resolveImage(icon) }); - } - - /** - * Sets a new name for this Group DM. - * @param {string} name New name for this Group DM - * @returns {Promise} - */ - setName(name) { - return this.edit({ name }); - } - - /** - * Adds a user to this Group DM. - * @param {Object} options Options for this method - * @param {UserResolvable} options.user User to add to this Group DM - * @param {string} [options.accessToken] Access token to use to add the user to this Group DM - * @param {string} [options.nick] Permanent nickname to give the user - * @returns {Promise} - */ - addUser({ user, accessToken, nick }) { - const id = this.client.users.resolveID(user); - return this.client.api.channels[this.id].recipients[id].put({ nick, access_token: accessToken }) - .then(() => this); - } - - /** - * Removes a user from this Group DM. - * @param {UserResolvable} user User to remove - * @returns {Promise} - */ - removeUser(user) { - const id = this.client.users.resolveID(user); - return this.client.api.channels[this.id].recipients[id].delete() - .then(() => this); - } - - /** - * When concatenated with a string, this automatically returns the channel's name instead of the - * GroupDMChannel object. - * @returns {string} - * @example - * // Logs: Hello from My Group DM! - * console.log(`Hello from ${channel}!`); - */ - toString() { - return this.name; - } - - // 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() {} - // Doesn't work on Group DMs; bulkDelete() {} - acknowledge() {} - _cacheMessage() {} -} - -TextBasedChannel.applyToClass(GroupDMChannel, true, ['bulkDelete']); - -module.exports = GroupDMChannel; diff --git a/src/structures/Guild.js b/src/structures/Guild.js index 66cf98718..f99627a4c 100644 --- a/src/structures/Guild.js +++ b/src/structures/Guild.js @@ -5,7 +5,7 @@ const Integration = require('./Integration'); const GuildAuditLogs = require('./GuildAuditLogs'); const Webhook = require('./Webhook'); const VoiceRegion = require('./VoiceRegion'); -const { ChannelTypes, DefaultMessageNotifications, browser } = require('../util/Constants'); +const { ChannelTypes, DefaultMessageNotifications, PartialTypes, browser } = require('../util/Constants'); const Collection = require('../util/Collection'); const Util = require('../util/Util'); const DataResolver = require('../util/DataResolver'); @@ -341,7 +341,9 @@ class Guild extends Base { * @readonly */ get owner() { - return this.members.get(this.ownerID) || null; + return this.members.get(this.ownerID) || (this.client.options.partials.includes(PartialTypes.GUILD_MEMBER) ? + this.members.add({ user: { id: this.ownerID } }, true) : + null); } /** diff --git a/src/structures/GuildAuditLogs.js b/src/structures/GuildAuditLogs.js index f78078a7c..b466a3aa0 100644 --- a/src/structures/GuildAuditLogs.js +++ b/src/structures/GuildAuditLogs.js @@ -4,6 +4,7 @@ const Collection = require('../util/Collection'); const Snowflake = require('../util/Snowflake'); const Webhook = require('./Webhook'); const Util = require('../util/Util'); +const PartialTypes = require('../util/Constants'); /** * The target type of an entry, e.g. `GUILD`. Here are the available types: @@ -234,7 +235,7 @@ class GuildAuditLogs { * Audit logs entry. */ class GuildAuditLogsEntry { - constructor(logs, guild, data) { + constructor(logs, guild, data) { // eslint-disable-line complexity const targetType = GuildAuditLogs.targetType(data.action_type); /** * The target type of this entry @@ -264,7 +265,9 @@ class GuildAuditLogsEntry { * The user that executed this entry * @type {User} */ - this.executor = guild.client.users.get(data.user_id); + this.executor = guild.client.options.partials.includes(PartialTypes.USER) ? + guild.client.users.add({ id: data.user_id }) : + guild.client.users.get(data.user_id); /** * An entry in the audit log representing a specific change. @@ -329,8 +332,12 @@ class GuildAuditLogsEntry { return o; }, {}); this.target.id = data.target_id; - } else if ([Targets.USER, Targets.GUILD].includes(targetType)) { - this.target = guild.client[`${targetType.toLowerCase()}s`].get(data.target_id); + } else if (targetType === Targets.USER) { + this.target = guild.client.options.partials.includes(PartialTypes.USER) ? + guild.client.users.add({ id: data.target_id }) : + guild.client.users.get(data.target_id); + } else if (targetType === Targets.GUILD) { + this.target = guild.client.guilds.get(data.target_id); } else if (targetType === Targets.WEBHOOK) { this.target = logs.webhooks.get(data.target_id) || new Webhook(guild.client, diff --git a/src/structures/GuildMember.js b/src/structures/GuildMember.js index 73adb69ac..e2dea4322 100644 --- a/src/structures/GuildMember.js +++ b/src/structures/GuildMember.js @@ -28,7 +28,7 @@ class GuildMember extends Base { * The user that this guild member instance represents * @type {User} */ - this.user = {}; + if (data.user) this.user = client.users.add(data.user, true); /** * The timestamp the member joined the guild at @@ -79,6 +79,14 @@ class GuildMember extends Base { return clone; } + /** + * Whether this GuildMember is a partial + * @type {boolean} + */ + get partial() { + return !this.joinedTimestamp; + } + /** * A collection of roles that are applied to this member, mapped by the role ID * @type {GuildMemberRoleStore} @@ -355,6 +363,14 @@ class GuildMember extends Base { return this.guild.members.ban(this, options); } + /** + * Fetches this GuildMember. + * @returns {Promise} + */ + fetch() { + return this.guild.members.fetch(this.id, true); + } + /** * When concatenated with a string, this automatically returns the user's mention instead of the GuildMember object. * @returns {string} diff --git a/src/structures/Message.js b/src/structures/Message.js index 310706e15..4b81cf94a 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -24,7 +24,7 @@ class Message extends Base { /** * The channel that the message was sent in - * @type {TextChannel|DMChannel|GroupDMChannel} + * @type {TextChannel|DMChannel} */ this.channel = channel; @@ -60,7 +60,7 @@ class Message extends Base { * The author of the message * @type {User} */ - this.author = this.client.users.add(data.author, !data.webhook_id); + this.author = data.author ? this.client.users.add(data.author, !data.webhook_id) : null; /** * Whether or not this message is pinned @@ -90,17 +90,19 @@ class Message extends Base { * A list of embeds in the message - e.g. YouTube Player * @type {MessageEmbed[]} */ - this.embeds = data.embeds.map(e => new Embed(e)); + this.embeds = (data.embeds || []).map(e => new Embed(e)); /** * A collection of attachments in the message - e.g. Pictures - mapped by their ID * @type {Collection} */ this.attachments = new Collection(); - for (const attachment of data.attachments) { - this.attachments.set(attachment.id, new MessageAttachment( - attachment.url, attachment.filename, attachment - )); + if (data.attachments) { + for (const attachment of data.attachments) { + this.attachments.set(attachment.id, new MessageAttachment( + attachment.url, attachment.filename, attachment + )); + } } /** @@ -167,6 +169,14 @@ class Message extends Base { } } + /** + * Whether or not this message is a partial + * @type {boolean} + */ + get partial() { + return typeof this.content !== 'string' || !this.author; + } + /** * Updates the message. * @param {Object} data Raw Discord message update data @@ -472,6 +482,14 @@ class Message extends Base { ); } + /** + * Fetch this message. + * @returns {Promise} + */ + fetch() { + return this.channel.messages.fetch(this.id, true); + } + /** * Fetches the webhook used to create this message. * @returns {Promise} diff --git a/src/structures/MessageCollector.js b/src/structures/MessageCollector.js index 59120b9db..44156cc03 100644 --- a/src/structures/MessageCollector.js +++ b/src/structures/MessageCollector.js @@ -15,7 +15,7 @@ const { Events } = require('../util/Constants'); */ class MessageCollector extends Collector { /** - * @param {TextChannel|DMChannel|GroupDMChannel} channel The channel + * @param {TextChannel|DMChannel} channel The channel * @param {CollectorFilter} filter The filter to be applied to this collector * @param {MessageCollectorOptions} options The options to be applied to this collector * @emits MessageCollector#message diff --git a/src/structures/User.js b/src/structures/User.js index 22ea5c81a..24bce7937 100644 --- a/src/structures/User.js +++ b/src/structures/User.js @@ -53,6 +53,8 @@ class User extends Base { */ if (typeof data.avatar !== 'undefined') this.avatar = data.avatar; + if (typeof data.bot !== 'undefined') this.bot = Boolean(data.bot); + /** * The locale of the user's client (ISO 639-1) * @type {?string} @@ -73,6 +75,14 @@ class User extends Base { this.lastMessageChannelID = null; } + /** + * Whether this User is a partial + * @type {boolean} + */ + get partial() { + return typeof this.username !== 'string'; + } + /** * The timestamp the user was created at * @type {number} @@ -228,6 +238,14 @@ class User extends Base { return equal; } + /** + * Fetches this user. + * @returns {Promise} + */ + fetch() { + return this.client.users.fetch(this.id, true); + } + /** * When concatenated with a string, this automatically returns the user's mention instead of the User object. * @returns {string} diff --git a/src/util/Constants.js b/src/util/Constants.js index 508b5f97d..1e13852cf 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -21,6 +21,9 @@ const browser = exports.browser = typeof window !== 'undefined'; * @property {boolean} [fetchAllMembers=false] Whether to cache all guild members and users upon startup, as well as * upon joining a guild (should be avoided whenever possible) * @property {boolean} [disableEveryone=false] Default value for {@link MessageOptions#disableEveryone} + * @property {PartialType[]} [partials] Structures allowed to be partial. This means events can be emitted even when + * they're missing all the data for a particular structure. See the "Partials" topic listed in the sidebar for some + * important usage information, as partials require you to put checks in place when handling data. * @property {number} [restWsBridgeTimeout=5000] Maximum time permitted between REST responses and their * corresponding websocket events * @property {number} [restTimeOffset=500] Extra time in millseconds to wait before continuing to make REST @@ -44,6 +47,7 @@ exports.DefaultOptions = { messageSweepInterval: 0, fetchAllMembers: false, disableEveryone: false, + partials: [], restWsBridgeTimeout: 5000, disabledEvents: [], retryLimit: 1, @@ -261,6 +265,23 @@ exports.Events = { RAW: 'raw', }; +/** + * The type of Structure allowed to be a partial: + * * USER + * * CHANNEL (only affects DMChannels) + * * GUILD_MEMBER + * * MESSAGE + * Partials require you to put checks in place when handling data, read the Partials topic listed in the + * sidebar for more information. + * @typedef {string} PartialType + */ +exports.PartialTypes = keyMirror([ + 'USER', + 'CHANNEL', + 'GUILD_MEMBER', + 'MESSAGE', +]); + /** * The type of a websocket message event, e.g. `MESSAGE_CREATE`. Here are the available events: * * READY diff --git a/src/util/Structures.js b/src/util/Structures.js index a742e2920..4ac562606 100644 --- a/src/util/Structures.js +++ b/src/util/Structures.js @@ -67,7 +67,6 @@ class Structures { const structures = { GuildEmoji: require('../structures/GuildEmoji'), DMChannel: require('../structures/DMChannel'), - GroupDMChannel: require('../structures/GroupDMChannel'), TextChannel: require('../structures/TextChannel'), VoiceChannel: require('../structures/VoiceChannel'), CategoryChannel: require('../structures/CategoryChannel'), diff --git a/src/util/Util.js b/src/util/Util.js index 1d7b7aeeb..bbf838e46 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -395,7 +395,7 @@ class Util { .replace(/@(everyone|here)/g, '@\u200b$1') .replace(/<@!?[0-9]+>/g, input => { const id = input.replace(/<|!|>|@/g, ''); - if (message.channel.type === 'dm' || message.channel.type === 'group') { + if (message.channel.type === 'dm') { const user = message.client.users.get(id); return user ? `@${user.username}` : input; } @@ -413,7 +413,7 @@ class Util { return channel ? `#${channel.name}` : input; }) .replace(/<@&[0-9]+>/g, input => { - if (message.channel.type === 'dm' || message.channel.type === 'group') return input; + if (message.channel.type === 'dm') return input; const role = message.guild.roles.get(input.replace(/<|@|>|&/g, '')); return role ? `@${role.name}` : input; }); diff --git a/test/voice.js b/test/voice.js index 70b5c30f7..6d022a29c 100644 --- a/test/voice.js +++ b/test/voice.js @@ -6,7 +6,7 @@ const ytdl = require('ytdl-core'); const prism = require('prism-media'); const fs = require('fs'); -const client = new Discord.Client({ fetchAllMembers: false, apiRequestMethod: 'sequential' }); +const client = new Discord.Client({ fetchAllMembers: false, partials: true, apiRequestMethod: 'sequential' }); const auth = require('./auth.js'); @@ -34,6 +34,15 @@ client.on('presenceUpdate', (a, b) => { console.log(a ? a.status : null, b.status, b.user.username); }); +client.on('messageDelete', async (m) => { + if (m.channel.id != '80426989059575808') return; + console.log(m.channel.recipient); + console.log(m.channel.partial); + await m.channel.fetch(); + console.log('\n\n\n\n'); + console.log(m.channel); +}); + client.on('message', m => { if (!m.guild) return; if (m.author.id !== '66564597481480192') return; diff --git a/typings/index.d.ts b/typings/index.d.ts index 8c606400c..4283755d1 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -129,8 +129,9 @@ declare module 'discord.js' { public readonly createdTimestamp: number; public deleted: boolean; public id: Snowflake; - public type: 'dm' | 'group' | 'text' | 'voice' | 'category' | 'unknown'; + public type: 'dm' | 'text' | 'voice' | 'category' | 'unknown'; public delete(reason?: string): Promise; + public fetch(): Promise; public toString(): string; } @@ -264,7 +265,6 @@ declare module 'discord.js' { export class ClientUser extends User { public mfaEnabled: boolean; public verified: boolean; - public createGroupDM(recipients: GroupDMRecipientOptions[]): Promise; public setActivity(options?: ActivityOptions): Promise; public setActivity(name: string, options?: ActivityOptions): Promise; public setAFK(afk: boolean): Promise; @@ -360,6 +360,7 @@ declare module 'discord.js' { constructor(client: Client, data?: object); public messages: MessageStore; public recipient: User; + public readonly partial: boolean; } export class Emoji extends Base { @@ -376,26 +377,6 @@ declare module 'discord.js' { public toString(): string; } - export class GroupDMChannel extends TextBasedChannel(Channel) { - constructor(client: Client, data?: object); - public applicationID: Snowflake; - public icon: string; - public managed: boolean; - public messages: MessageStore; - public name: string; - public nicks: Collection; - public readonly owner: User; - public ownerID: Snowflake; - public recipients: Collection; - public addUser(options: { user: UserResolvable, accessToken?: string, nick?: string }): Promise; - public edit (data: { icon?: string, name?: string }): Promise; - public equals(channel: GroupDMChannel): boolean; - public iconURL(options?: AvatarOptions): string; - public removeUser(user: UserResolvable): Promise; - public setIcon(icon: Base64Resolvable | BufferResolvable): Promise; - public setName(name: string): Promise; - } - export class Guild extends Base { constructor(client: Client, data: object); private _sortedRoles(): Collection; @@ -570,12 +551,14 @@ declare module 'discord.js' { public readonly kickable: boolean; public readonly manageable: boolean; public nickname: string; + public readonly partial: boolean; public readonly permissions: Readonly; public readonly presence: Presence; public roles: GuildMemberRoleStore; public user: User; public readonly voice: VoiceState; public ban(options?: BanOptions): Promise; + public fetch(): Promise; public createDM(): Promise; public deleteDM(): Promise; public edit(data: GuildMemberEditData, reason?: string): Promise; @@ -619,7 +602,7 @@ declare module 'discord.js' { export class Invite extends Base { constructor(client: Client, data: object); - public channel: GuildChannel | GroupDMChannel; + public channel: GuildChannel; public code: string; public readonly createdAt: Date; public createdTimestamp: number; @@ -640,7 +623,7 @@ declare module 'discord.js' { } export class Message extends Base { - constructor(client: Client, data: object, channel: TextChannel | DMChannel | GroupDMChannel); + constructor(client: Client, data: object, channel: TextChannel | DMChannel); private _edits: Message[]; private patch(data: object): void; @@ -648,7 +631,7 @@ declare module 'discord.js' { public application: ClientApplication; public attachments: Collection; public author: User; - public channel: TextChannel | DMChannel | GroupDMChannel; + public channel: TextChannel | DMChannel; public readonly cleanContent: string; public content: string; public readonly createdAt: Date; @@ -665,6 +648,7 @@ declare module 'discord.js' { public readonly member: GuildMember; public mentions: MessageMentions; public nonce: string; + public readonly partial: boolean; public readonly pinnable: boolean; public pinned: boolean; public reactions: ReactionStore; @@ -680,6 +664,7 @@ declare module 'discord.js' { public edit(options: MessageEditOptions | MessageEmbed | APIMessage): Promise; public equals(message: Message, rawData: object): boolean; public fetchWebhook(): Promise; + public fetch(): Promise; public pin(): Promise; public react(emoji: EmojiIdentifierResolvable): Promise; public reply(content?: StringResolvable, options?: MessageOptions | MessageAdditions): Promise; @@ -706,7 +691,7 @@ declare module 'discord.js' { } export class MessageCollector extends Collector { - constructor(channel: TextChannel | DMChannel | GroupDMChannel, filter: CollectorFilter, options?: MessageCollectorOptions); + constructor(channel: TextChannel | DMChannel, filter: CollectorFilter, options?: MessageCollectorOptions); public channel: Channel; public options: MessageCollectorOptions; public received: number; @@ -1078,6 +1063,7 @@ declare module 'discord.js' { public readonly dmChannel: DMChannel; public id: Snowflake; public locale: string; + public readonly partial: boolean; public readonly presence: Presence; public readonly tag: string; public username: string; @@ -1086,6 +1072,7 @@ declare module 'discord.js' { public deleteDM(): Promise; public displayAvatarURL(options?: AvatarOptions): string; public equals(user: User): boolean; + public fetch(): Promise; public toString(): string; public typingDurationIn(channel: ChannelResolvable): number; public typingIn(channel: ChannelResolvable): boolean; @@ -1385,7 +1372,7 @@ declare module 'discord.js' { } export class MessageStore extends DataStore { - constructor(channel: TextChannel | DMChannel | GroupDMChannel, iterable?: Iterable); + constructor(channel: TextChannel | DMChannel, iterable?: Iterable); public fetch(message: Snowflake): Promise; public fetch(options?: ChannelLogsQueryOptions): Promise>; public fetchPinned(): Promise>; @@ -1607,6 +1594,7 @@ declare module 'discord.js' { messageSweepInterval?: number; fetchAllMembers?: boolean; disableEveryone?: boolean; + partials?: PartialTypes[]; restWsBridgeTimeout?: number; restTimeOffset?: number; restSweepInterval?: number; @@ -1676,7 +1664,6 @@ declare module 'discord.js' { type Extendable = { GuildEmoji: typeof GuildEmoji; DMChannel: typeof DMChannel; - GroupDMChannel: typeof GroupDMChannel; TextChannel: typeof TextChannel; VoiceChannel: typeof VoiceChannel; CategoryChannel: typeof CategoryChannel; @@ -1711,13 +1698,6 @@ declare module 'discord.js' { type: number; }; - type GroupDMRecipientOptions = { - user?: UserResolvable | Snowflake; - accessToken?: string; - nick?: string; - id?: Snowflake; - }; - type GuildAuditLogsAction = keyof GuildAuditLogsActions; type GuildAuditLogsActions = { @@ -1934,7 +1914,7 @@ declare module 'discord.js' { type MessageResolvable = Message | Snowflake; - type MessageTarget = TextChannel | DMChannel | GroupDMChannel | User | GuildMember | Webhook | WebhookClient; + type MessageTarget = TextChannel | DMChannel | User | GuildMember | Webhook | WebhookClient; type MessageType = 'DEFAULT' | 'RECIPIENT_ADD' @@ -2023,6 +2003,11 @@ declare module 'discord.js' { desktop?: ClientPresenceStatus }; + type PartialTypes = 'USER' + | 'CHANNEL' + | 'GUILD_MEMBER' + | 'MESSAGE'; + type PresenceStatus = ClientPresenceStatus | 'offline'; type PresenceStatusData = ClientPresenceStatus | 'invisible';