mirror of
https://github.com/discordjs/discord.js.git
synced 2026-05-30 15:40:08 +00:00
* fix(MessageEmbed): Add skipValidation flag to MessageEmbed
* fix(MessageEmbed): Use skipValidation flag in Message
* fix(MessageEmbed): Restore static normalizeField(s) methods
* fix(MessageEmbed): Update typings for constructor
* fix(MessageEmbed): Remove private docstrings/typings
* fix(MessageEmbed): Use skipValidation without storing in instance
* fix(MessageEmbed): skipValidation without modifying normalizeFields
* fix(MessageEmbed): Revert indentation change in typings
* fix(MessageEmbed): Clone logic from normalizeFields (duplicated code ftw)
* revert(MessageEmbed): remove dead code / breaking change
- dead code
discord.js does not use those methods interally and won't in the future, as Discord
does not emit any partial embed updates and doing so in the future seems unlikely.
- a breaking change (an incompatible api change)
Although it's not recommended to do, users can modify
received embeds without cloning them, e.g.:
const embed = message.embeds[0].addField('some title', '');
(replace '' with some function call; this is just an example)
This would no longer throw a synchronous error (breaking change),
but at a later point when actually sending it. (poorer to debug)
Co-authored-by: SpaceEEC <spaceeec@yahoo.com>
623 lines
18 KiB
JavaScript
623 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
const APIMessage = require('./APIMessage');
|
|
const Base = require('./Base');
|
|
const ClientApplication = require('./ClientApplication');
|
|
const MessageAttachment = require('./MessageAttachment');
|
|
const Embed = require('./MessageEmbed');
|
|
const Mentions = require('./MessageMentions');
|
|
const ReactionCollector = require('./ReactionCollector');
|
|
const { Error, TypeError } = require('../errors');
|
|
const ReactionManager = require('../managers/ReactionManager');
|
|
const Collection = require('../util/Collection');
|
|
const { MessageTypes } = require('../util/Constants');
|
|
const MessageFlags = require('../util/MessageFlags');
|
|
const Permissions = require('../util/Permissions');
|
|
const Util = require('../util/Util');
|
|
|
|
/**
|
|
* Represents a message on Discord.
|
|
* @extends {Base}
|
|
*/
|
|
class Message extends Base {
|
|
/**
|
|
* @param {Client} client The instantiating client
|
|
* @param {Object} data The data for the message
|
|
* @param {TextChannel|DMChannel} channel The channel the message was sent in
|
|
*/
|
|
constructor(client, data, channel) {
|
|
super(client);
|
|
|
|
/**
|
|
* The channel that the message was sent in
|
|
* @type {TextChannel|DMChannel}
|
|
*/
|
|
this.channel = channel;
|
|
|
|
/**
|
|
* Whether this message has been deleted
|
|
* @type {boolean}
|
|
*/
|
|
this.deleted = false;
|
|
|
|
if (data) this._patch(data);
|
|
}
|
|
|
|
_patch(data) {
|
|
/**
|
|
* The ID of the message
|
|
* @type {Snowflake}
|
|
*/
|
|
this.id = data.id;
|
|
|
|
/**
|
|
* The type of the message
|
|
* @type {MessageType}
|
|
*/
|
|
this.type = MessageTypes[data.type];
|
|
|
|
/**
|
|
* The content of the message
|
|
* @type {string}
|
|
*/
|
|
this.content = data.content;
|
|
|
|
/**
|
|
* The author of the message
|
|
* @type {?User}
|
|
*/
|
|
this.author = data.author ? this.client.users.add(data.author, !data.webhook_id) : null;
|
|
|
|
/**
|
|
* Whether or not this message is pinned
|
|
* @type {boolean}
|
|
*/
|
|
this.pinned = data.pinned;
|
|
|
|
/**
|
|
* Whether or not the message was Text-To-Speech
|
|
* @type {boolean}
|
|
*/
|
|
this.tts = data.tts;
|
|
|
|
/**
|
|
* A random number or string used for checking message delivery
|
|
* <warn>This is only received after the message was sent successfully, and
|
|
* lost if re-fetched</warn>
|
|
* @type {?string}
|
|
*/
|
|
this.nonce = data.nonce;
|
|
|
|
/**
|
|
* Whether or not this message was sent by Discord, not actually a user (e.g. pin notifications)
|
|
* @type {boolean}
|
|
*/
|
|
this.system = data.type !== 0;
|
|
|
|
/**
|
|
* A list of embeds in the message - e.g. YouTube Player
|
|
* @type {MessageEmbed[]}
|
|
*/
|
|
this.embeds = (data.embeds || []).map(e => new Embed(e, true));
|
|
|
|
/**
|
|
* A collection of attachments in the message - e.g. Pictures - mapped by their ID
|
|
* @type {Collection<Snowflake, MessageAttachment>}
|
|
*/
|
|
this.attachments = new Collection();
|
|
if (data.attachments) {
|
|
for (const attachment of data.attachments) {
|
|
this.attachments.set(attachment.id, new MessageAttachment(attachment.url, attachment.filename, attachment));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The timestamp the message was sent at
|
|
* @type {number}
|
|
*/
|
|
this.createdTimestamp = new Date(data.timestamp).getTime();
|
|
|
|
/**
|
|
* The timestamp the message was last edited at (if applicable)
|
|
* @type {?number}
|
|
*/
|
|
this.editedTimestamp = data.edited_timestamp ? new Date(data.edited_timestamp).getTime() : null;
|
|
|
|
/**
|
|
* A manager of the reactions belonging to this message
|
|
* @type {ReactionManager}
|
|
*/
|
|
this.reactions = new ReactionManager(this);
|
|
if (data.reactions && data.reactions.length > 0) {
|
|
for (const reaction of data.reactions) {
|
|
this.reactions.add(reaction);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* All valid mentions that the message contains
|
|
* @type {MessageMentions}
|
|
*/
|
|
this.mentions = new Mentions(this, data.mentions, data.mention_roles, data.mention_everyone, data.mention_channels);
|
|
|
|
/**
|
|
* ID of the webhook that sent the message, if applicable
|
|
* @type {?Snowflake}
|
|
*/
|
|
this.webhookID = data.webhook_id || null;
|
|
|
|
/**
|
|
* Supplemental application information for group activities
|
|
* @type {?ClientApplication}
|
|
*/
|
|
this.application = data.application ? new ClientApplication(this.client, data.application) : null;
|
|
|
|
/**
|
|
* Group activity
|
|
* @type {?MessageActivity}
|
|
*/
|
|
this.activity = data.activity
|
|
? {
|
|
partyID: data.activity.party_id,
|
|
type: data.activity.type,
|
|
}
|
|
: null;
|
|
|
|
/**
|
|
* The previous versions of the message, sorted with the most recent first
|
|
* @type {Message[]}
|
|
* @private
|
|
*/
|
|
this._edits = [];
|
|
|
|
if (this.member && data.member) {
|
|
this.member._patch(data.member);
|
|
} else if (data.member && this.guild && this.author) {
|
|
this.guild.members.add(Object.assign(data.member, { user: this.author }));
|
|
}
|
|
|
|
/**
|
|
* Flags that are applied to the message
|
|
* @type {Readonly<MessageFlags>}
|
|
*/
|
|
this.flags = new MessageFlags(data.flags).freeze();
|
|
|
|
/**
|
|
* Reference data sent in a crossposted message.
|
|
* @typedef {Object} MessageReference
|
|
* @property {string} channelID ID of the channel the message was crossposted from
|
|
* @property {?string} guildID ID of the guild the message was crossposted from
|
|
* @property {?string} messageID ID of the message that was crossposted
|
|
*/
|
|
|
|
/**
|
|
* Message reference data
|
|
* @type {?MessageReference}
|
|
*/
|
|
this.reference = data.message_reference
|
|
? {
|
|
channelID: data.message_reference.channel_id,
|
|
guildID: data.message_reference.guild_id,
|
|
messageID: data.message_reference.message_id,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Whether or not this message is a partial
|
|
* @type {boolean}
|
|
* @readonly
|
|
*/
|
|
get partial() {
|
|
return typeof this.content !== 'string' || !this.author;
|
|
}
|
|
|
|
/**
|
|
* Updates the message.
|
|
* @param {Object} data Raw Discord message update data
|
|
* @private
|
|
*/
|
|
patch(data) {
|
|
const clone = this._clone();
|
|
this._edits.unshift(clone);
|
|
|
|
if ('edited_timestamp' in data) this.editedTimestamp = new Date(data.edited_timestamp).getTime();
|
|
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 ('embeds' in data) this.embeds = data.embeds.map(e => new Embed(e, true));
|
|
else this.embeds = this.embeds.slice();
|
|
|
|
if ('attachments' in data) {
|
|
this.attachments = new Collection();
|
|
for (const attachment of data.attachments) {
|
|
this.attachments.set(attachment.id, new MessageAttachment(attachment.url, attachment.filename, attachment));
|
|
}
|
|
} else {
|
|
this.attachments = new Collection(this.attachments);
|
|
}
|
|
|
|
this.mentions = new Mentions(
|
|
this,
|
|
'mentions' in data ? data.mentions : this.mentions.users,
|
|
'mentions_roles' in data ? data.mentions_roles : this.mentions.roles,
|
|
'mention_everyone' in data ? data.mention_everyone : this.mentions.everyone,
|
|
'mention_channels' in data ? data.mention_channels : this.mentions.crosspostedChannels,
|
|
);
|
|
|
|
this.flags = new MessageFlags('flags' in data ? data.flags : 0).freeze();
|
|
}
|
|
|
|
/**
|
|
* Represents the author of the message as a guild member.
|
|
* Only available if the message comes from a guild where the author is still a member
|
|
* @type {?GuildMember}
|
|
* @readonly
|
|
*/
|
|
get member() {
|
|
return this.guild ? this.guild.member(this.author) || null : null;
|
|
}
|
|
|
|
/**
|
|
* The time the message was sent at
|
|
* @type {Date}
|
|
* @readonly
|
|
*/
|
|
get createdAt() {
|
|
return new Date(this.createdTimestamp);
|
|
}
|
|
|
|
/**
|
|
* The time the message was last edited at (if applicable)
|
|
* @type {?Date}
|
|
* @readonly
|
|
*/
|
|
get editedAt() {
|
|
return this.editedTimestamp ? new Date(this.editedTimestamp) : null;
|
|
}
|
|
|
|
/**
|
|
* The guild the message was sent in (if in a guild channel)
|
|
* @type {?Guild}
|
|
* @readonly
|
|
*/
|
|
get guild() {
|
|
return this.channel.guild || null;
|
|
}
|
|
|
|
/**
|
|
* The url to jump to this message
|
|
* @type {string}
|
|
* @readonly
|
|
*/
|
|
get url() {
|
|
return `https://discordapp.com/channels/${this.guild ? this.guild.id : '@me'}/${this.channel.id}/${this.id}`;
|
|
}
|
|
|
|
/**
|
|
* The message contents with all mentions replaced by the equivalent text.
|
|
* If mentions cannot be resolved to a name, the relevant mention in the message content will not be converted.
|
|
* @type {string}
|
|
* @readonly
|
|
*/
|
|
get cleanContent() {
|
|
// eslint-disable-next-line eqeqeq
|
|
return this.content != null ? Util.cleanContent(this.content, this) : null;
|
|
}
|
|
|
|
/**
|
|
* Creates a reaction collector.
|
|
* @param {CollectorFilter} filter The filter to apply
|
|
* @param {ReactionCollectorOptions} [options={}] Options to send to the collector
|
|
* @returns {ReactionCollector}
|
|
* @example
|
|
* // Create a reaction collector
|
|
* const filter = (reaction, user) => reaction.emoji.name === '👌' && user.id === 'someID';
|
|
* const collector = message.createReactionCollector(filter, { time: 15000 });
|
|
* collector.on('collect', r => console.log(`Collected ${r.emoji.name}`));
|
|
* collector.on('end', collected => console.log(`Collected ${collected.size} items`));
|
|
*/
|
|
createReactionCollector(filter, options = {}) {
|
|
return new ReactionCollector(this, filter, options);
|
|
}
|
|
|
|
/**
|
|
* An object containing the same properties as CollectorOptions, but a few more:
|
|
* @typedef {ReactionCollectorOptions} AwaitReactionsOptions
|
|
* @property {string[]} [errors] Stop/end reasons that cause the promise to reject
|
|
*/
|
|
|
|
/**
|
|
* Similar to createReactionCollector but in promise form.
|
|
* Resolves with a collection of reactions that pass the specified filter.
|
|
* @param {CollectorFilter} filter The filter function to use
|
|
* @param {AwaitReactionsOptions} [options={}] Optional options to pass to the internal collector
|
|
* @returns {Promise<Collection<string, MessageReaction>>}
|
|
* @example
|
|
* // Create a reaction collector
|
|
* const filter = (reaction, user) => reaction.emoji.name === '👌' && user.id === 'someID'
|
|
* message.awaitReactions(filter, { time: 15000 })
|
|
* .then(collected => console.log(`Collected ${collected.size} reactions`))
|
|
* .catch(console.error);
|
|
*/
|
|
awaitReactions(filter, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const collector = this.createReactionCollector(filter, options);
|
|
collector.once('end', (reactions, reason) => {
|
|
if (options.errors && options.errors.includes(reason)) reject(reactions);
|
|
else resolve(reactions);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* An array of cached versions of the message, including the current version
|
|
* Sorted from latest (first) to oldest (last)
|
|
* @type {Message[]}
|
|
* @readonly
|
|
*/
|
|
get edits() {
|
|
const copy = this._edits.slice();
|
|
copy.unshift(this);
|
|
return copy;
|
|
}
|
|
|
|
/**
|
|
* Whether the message is editable by the client user
|
|
* @type {boolean}
|
|
* @readonly
|
|
*/
|
|
get editable() {
|
|
return this.author.id === this.client.user.id;
|
|
}
|
|
|
|
/**
|
|
* Whether the message is deletable by the client user
|
|
* @type {boolean}
|
|
* @readonly
|
|
*/
|
|
get deletable() {
|
|
return (
|
|
!this.deleted &&
|
|
(this.author.id === this.client.user.id ||
|
|
(this.guild && this.channel.permissionsFor(this.client.user).has(Permissions.FLAGS.MANAGE_MESSAGES, false)))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether the message is pinnable by the client user
|
|
* @type {boolean}
|
|
* @readonly
|
|
*/
|
|
get pinnable() {
|
|
return (
|
|
this.type === 'DEFAULT' &&
|
|
(!this.guild || this.channel.permissionsFor(this.client.user).has(Permissions.FLAGS.MANAGE_MESSAGES, false))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Options that can be passed into editMessage.
|
|
* @typedef {Object} MessageEditOptions
|
|
* @property {string} [content] Content to be edited
|
|
* @property {Object} [embed] An embed to be added/edited
|
|
* @property {string|boolean} [code] Language for optional codeblock formatting to apply
|
|
*/
|
|
|
|
/**
|
|
* Edits the content of the message.
|
|
* @param {StringResolvable|APIMessage} [content] The new content for the message
|
|
* @param {MessageEditOptions|MessageEmbed} [options] The options to provide
|
|
* @returns {Promise<Message>}
|
|
* @example
|
|
* // Update the content of a message
|
|
* message.edit('This is my new content!')
|
|
* .then(msg => console.log(`Updated the content of a message to ${msg.content}`))
|
|
* .catch(console.error);
|
|
*/
|
|
edit(content, options) {
|
|
const { data } =
|
|
content instanceof APIMessage ? content.resolveData() : APIMessage.create(this, content, options).resolveData();
|
|
return this.client.api.channels[this.channel.id].messages[this.id].patch({ data }).then(d => {
|
|
const clone = this._clone();
|
|
clone._patch(d);
|
|
return clone;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Pins this message to the channel's pinned messages.
|
|
* @returns {Promise<Message>}
|
|
*/
|
|
pin() {
|
|
return this.client.api
|
|
.channels(this.channel.id)
|
|
.pins(this.id)
|
|
.put()
|
|
.then(() => this);
|
|
}
|
|
|
|
/**
|
|
* Unpins this message from the channel's pinned messages.
|
|
* @returns {Promise<Message>}
|
|
*/
|
|
unpin() {
|
|
return this.client.api
|
|
.channels(this.channel.id)
|
|
.pins(this.id)
|
|
.delete()
|
|
.then(() => this);
|
|
}
|
|
|
|
/**
|
|
* Adds a reaction to the message.
|
|
* @param {EmojiIdentifierResolvable} emoji The emoji to react with
|
|
* @returns {Promise<MessageReaction>}
|
|
* @example
|
|
* // React to a message with a unicode emoji
|
|
* message.react('🤔')
|
|
* .then(console.log)
|
|
* .catch(console.error);
|
|
* @example
|
|
* // React to a message with a custom emoji
|
|
* message.react(message.guild.emojis.cache.get('123456789012345678'))
|
|
* .then(console.log)
|
|
* .catch(console.error);
|
|
*/
|
|
react(emoji) {
|
|
emoji = this.client.emojis.resolveIdentifier(emoji);
|
|
if (!emoji) throw new TypeError('EMOJI_TYPE');
|
|
|
|
return this.client.api
|
|
.channels(this.channel.id)
|
|
.messages(this.id)
|
|
.reactions(emoji, '@me')
|
|
.put()
|
|
.then(
|
|
() =>
|
|
this.client.actions.MessageReactionAdd.handle({
|
|
user: this.client.user,
|
|
channel: this.channel,
|
|
message: this,
|
|
emoji: Util.parseEmoji(emoji),
|
|
}).reaction,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Deletes the message.
|
|
* @param {Object} [options] Options
|
|
* @param {number} [options.timeout=0] How long to wait to delete the message in milliseconds
|
|
* @param {string} [options.reason] Reason for deleting this message, if it does not belong to the client user
|
|
* @returns {Promise<Message>}
|
|
* @example
|
|
* // Delete a message
|
|
* message.delete()
|
|
* .then(msg => console.log(`Deleted message from ${msg.author.username}`))
|
|
* .catch(console.error);
|
|
*/
|
|
delete(options = {}) {
|
|
if (typeof options !== 'object') throw new TypeError('INVALID_TYPE', 'options', 'object', true);
|
|
const { timeout = 0, reason } = options;
|
|
if (timeout <= 0) {
|
|
return this.channel.messages.delete(this.id, reason).then(() => this);
|
|
} else {
|
|
return new Promise(resolve => {
|
|
this.client.setTimeout(() => {
|
|
resolve(this.delete({ reason }));
|
|
}, timeout);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replies to the message.
|
|
* @param {StringResolvable|APIMessage} [content=''] The content for the message
|
|
* @param {MessageOptions|MessageAdditions} [options={}] The options to provide
|
|
* @returns {Promise<Message|Message[]>}
|
|
* @example
|
|
* // Reply to a message
|
|
* message.reply('Hey, I\'m a reply!')
|
|
* .then(() => console.log(`Sent a reply to ${message.author.username}`))
|
|
* .catch(console.error);
|
|
*/
|
|
reply(content, options) {
|
|
return this.channel.send(
|
|
content instanceof APIMessage
|
|
? content
|
|
: APIMessage.transformOptions(content, options, { reply: this.member || this.author }),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch this message.
|
|
* @returns {Promise<Message>}
|
|
*/
|
|
fetch() {
|
|
return this.channel.messages.fetch(this.id, true);
|
|
}
|
|
|
|
/**
|
|
* Fetches the webhook used to create this message.
|
|
* @returns {Promise<?Webhook>}
|
|
*/
|
|
fetchWebhook() {
|
|
if (!this.webhookID) return Promise.reject(new Error('WEBHOOK_MESSAGE'));
|
|
return this.client.fetchWebhook(this.webhookID);
|
|
}
|
|
|
|
/**
|
|
* Suppresses or unsuppresses embeds on a message
|
|
* @param {boolean} [suppress=true] If the embeds should be suppressed or not
|
|
* @returns {Promise<Message>}
|
|
*/
|
|
suppressEmbeds(suppress = true) {
|
|
const flags = new MessageFlags(this.flags.bitfield);
|
|
|
|
if (suppress) {
|
|
flags.add(MessageFlags.FLAGS.SUPPRESS_EMBEDS);
|
|
} else {
|
|
flags.remove(MessageFlags.FLAGS.SUPPRESS_EMBEDS);
|
|
}
|
|
|
|
return this.edit({ flags });
|
|
}
|
|
|
|
/**
|
|
* Used mainly internally. Whether two messages are identical in properties. If you want to compare messages
|
|
* without checking all the properties, use `message.id === message2.id`, which is much more efficient. This
|
|
* method allows you to see if there are differences in content, embeds, attachments, nonce and tts properties.
|
|
* @param {Message} message The message to compare it to
|
|
* @param {Object} rawData Raw data passed through the WebSocket about this message
|
|
* @returns {boolean}
|
|
*/
|
|
equals(message, rawData) {
|
|
if (!message) return false;
|
|
const embedUpdate = !message.author && !message.attachments;
|
|
if (embedUpdate) return this.id === message.id && this.embeds.length === message.embeds.length;
|
|
|
|
let equal =
|
|
this.id === message.id &&
|
|
this.author.id === message.author.id &&
|
|
this.content === message.content &&
|
|
this.tts === message.tts &&
|
|
this.nonce === message.nonce &&
|
|
this.embeds.length === message.embeds.length &&
|
|
this.attachments.length === message.attachments.length;
|
|
|
|
if (equal && rawData) {
|
|
equal =
|
|
this.mentions.everyone === message.mentions.everyone &&
|
|
this.createdTimestamp === new Date(rawData.timestamp).getTime() &&
|
|
this.editedTimestamp === new Date(rawData.edited_timestamp).getTime();
|
|
}
|
|
|
|
return equal;
|
|
}
|
|
|
|
/**
|
|
* When concatenated with a string, this automatically concatenates the message's content instead of the object.
|
|
* @returns {string}
|
|
* @example
|
|
* // Logs: Message: This is a message!
|
|
* console.log(`Message: ${message}`);
|
|
*/
|
|
toString() {
|
|
return this.content;
|
|
}
|
|
|
|
toJSON() {
|
|
return super.toJSON({
|
|
channel: 'channelID',
|
|
author: 'authorID',
|
|
application: 'applicationID',
|
|
guild: 'guildID',
|
|
cleanContent: true,
|
|
member: false,
|
|
reactions: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = Message;
|