diff --git a/src/client/ClientDataResolver.js b/src/client/ClientDataResolver.js index edb8a015f..c25abc30a 100644 --- a/src/client/ClientDataResolver.js +++ b/src/client/ClientDataResolver.js @@ -215,6 +215,28 @@ class ClientDataResolver { return Promise.reject(new TypeError('REQ_RESOURCE_TYPE')); } + /** + * Converts a Stream to a Buffer. + * @param {Stream} resource The stream to convert + * @returns {Promise} + */ + resolveFile(resource) { + return resource ? this.resolveBuffer(resource) + .catch(() => { + if (resource.pipe && typeof resource.pipe === 'function') { + return new Promise((resolve, reject) => { + const buffers = []; + resource.once('error', reject); + resource.on('data', data => buffers.push(data)); + resource.once('end', () => resolve(Buffer.concat(buffers))); + }); + } else { + throw new TypeError('REQ_RESOURCE_TYPE'); + } + }) : + Promise.reject(new TypeError('REQ_RESOURCE_TYPE')); + } + /** * Data that can be resolved to give an emoji identifier. This can be: * * The unicode representation of an emoji diff --git a/src/errors/Messages.js b/src/errors/Messages.js index 43f47f370..37950d5eb 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -63,7 +63,7 @@ const Messages = { UDP_CONNECTION_EXISTS: 'There is already an existing UDP connection.', REQ_BODY_TYPE: 'The response body isn\'t a Buffer.', - REQ_RESOURCE_TYPE: 'The resource must be a string or Buffer.', + REQ_RESOURCE_TYPE: 'The resource must be a string, Buffer or a valid file stream.', IMAGE_FORMAT: format => `Invalid image format: ${format}`, IMAGE_SIZE: size => `Invalid image size: ${size}`, diff --git a/src/index.js b/src/index.js index cb109cee5..face2be00 100644 --- a/src/index.js +++ b/src/index.js @@ -26,6 +26,7 @@ module.exports = { splitMessage: Util.splitMessage, // Structures + Attachment: require('./structures/Attachment'), Channel: require('./structures/Channel'), ClientUser: require('./structures/ClientUser'), ClientUserSettings: require('./structures/ClientUserSettings'), diff --git a/src/structures/Attachment.js b/src/structures/Attachment.js new file mode 100644 index 000000000..94357683a --- /dev/null +++ b/src/structures/Attachment.js @@ -0,0 +1,73 @@ +/** + * Represents an attachment in a message + */ +class Attachment { + constructor(file, name) { + this.file = null; + this._attach(file, name); + } + + /** + * The name of the file + * @type {?string} + * @readonly + */ + get name() { + return this.file.name; + } + + /** + * The file + * @type {?BufferResolvable|Stream} + * @readonly + */ + get attachment() { + return this.file.attachment; + } + + /** + * Set the file of this attachment. + * @param {BufferResolvable|Stream} file The file + * @param {string} name The name of the file + * @returns {Attachment} This attachment + */ + setAttachment(file, name) { + this.file = { attachment: file, name }; + return this; + } + + /** + * Set the file of this attachment. + * @param {BufferResolvable|Stream} attachment The file + * @returns {Attachment} This attachment + */ + setFile(attachment) { + this.file.attachment = attachment; + return this; + } + + /** + * Set the name of this attachment. + * @param {string} name The name of the image + * @returns {Attachment} This attachment + */ + setName(name) { + this.file.name = name; + return this; + } + + /** + * Set the file of this attachment. + * @param {BufferResolvable|Stream} file The file + * @param {string} name The name of the file + * @private + */ + _attach(file, name) { + if (file) { + if (typeof file === 'string') this.file = file; + else this.setAttachment(file, name); + } + } +} + +module.exports = Attachment; diff --git a/src/structures/MessageEmbed.js b/src/structures/MessageEmbed.js index 78075c011..5f93548b7 100644 --- a/src/structures/MessageEmbed.js +++ b/src/structures/MessageEmbed.js @@ -1,3 +1,4 @@ +const Attachment = require('./Attachment'); const Util = require('../util/Util'); const { RangeError } = require('../errors'); @@ -129,6 +130,17 @@ class MessageEmbed { iconURL: data.footer.iconURL || data.footer.icon_url, proxyIconURL: data.footer.proxyIconURL || data.footer.proxy_icon_url, } : null; + + /** + * The files of this embed + * @type {?Object} + * @property {Array} files Files to attach + */ + if (data.files) { + for (let file of data.files) { + if (file instanceof Attachment) file = file.file; + } + } else { data.files = null; } } /** @@ -178,12 +190,15 @@ class MessageEmbed { /** * Sets the file to upload alongside the embed. This file can be accessed via `attachment://fileName.extension` when * setting an embed image or author/footer icons. Only one file may be attached. - * @param {Array} files Files to attach + * @param {Array} files Files to attach * @returns {MessageEmbed} This embed */ attachFiles(files) { if (this.files) this.files = this.files.concat(files); else this.files = files; + for (let file of files) { + if (file instanceof Attachment) file = file.file; + } return this; } @@ -286,6 +301,11 @@ class MessageEmbed { return this; } + /** + * Transforms the embed object to be processed. + * @returns {Object} The raw data of this embed + * @private + */ _apiTransform() { return { title: this.title, diff --git a/src/structures/Webhook.js b/src/structures/Webhook.js index f1ee54fad..ba62b2ba0 100644 --- a/src/structures/Webhook.js +++ b/src/structures/Webhook.js @@ -1,6 +1,8 @@ const path = require('path'); const Util = require('../util/Util'); const Embed = require('./MessageEmbed'); +const Attachment = require('./Attachment'); +const MessageEmbed = require('./MessageEmbed'); /** * Represents a webhook. @@ -82,7 +84,7 @@ class Webhook { * (see [here](https://discordapp.com/developers/docs/resources/channel#embed-object) for more details) * @property {boolean} [disableEveryone=this.client.options.disableEveryone] Whether or not @everyone and @here * should be replaced with plain-text - * @property {FileOptions|string} [file] A file to send with the message + * @property {FileOptions|BufferResolvable} [file] A file to send with the message * @property {FileOptions[]|string[]} [files] Files to send with the message * @property {string|boolean} [code] Language for optional codeblock formatting to apply * @property {boolean|SplitOptions} [split=false] Whether or not the message should be split into multiple messages if @@ -100,7 +102,7 @@ class Webhook { * .then(message => console.log(`Sent message: ${message.content}`)) * .catch(console.error); */ - send(content, options) { + send(content, options) { // eslint-disable-line complexity if (!options && typeof content === 'object' && !(content instanceof Array)) { options = content; content = ''; @@ -108,49 +110,70 @@ class Webhook { options = {}; } - if (!options.username) options.username = this.name; + if (options instanceof Attachment) options = { files: [options.file] }; + if (options instanceof MessageEmbed) options = { embeds: [options] }; + if (options.embed) options = { embeds: [options.embed] }; + if (content instanceof Array || options instanceof Array) { + const which = content instanceof Array ? content : options; + const attachments = which.filter(item => item instanceof Attachment); + const embeds = which.filter(item => item instanceof MessageEmbed); + if (attachments.length) options = { files: attachments }; + if (embeds.length) options = { embeds }; + if ((embeds.length || attachments.length) && content instanceof Array) content = ''; + } + + if (!options.username) options.username = this.name; if (options.avatarURL) { options.avatar_url = options.avatarURL; options.avatarURL = null; } - if (typeof content !== 'undefined') content = Util.resolveString(content); if (content) { - if (options.disableEveryone || - (typeof options.disableEveryone === 'undefined' && this.client.options.disableEveryone) - ) { + content = Util.resolveString(content); + let { split, code, disableEveryone } = options; + if (split && typeof split !== 'object') split = {}; + if (typeof code !== 'undefined' && (typeof code !== 'boolean' || code === true)) { + content = Util.escapeMarkdown(content, true); + content = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n${content}\n\`\`\``; + if (split) { + split.prepend = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n`; + split.append = '\n```'; + } + } + if (disableEveryone || (typeof disableEveryone === 'undefined' && this.client.options.disableEveryone)) { content = content.replace(/@(everyone|here)/g, '@\u200b$1'); } + + if (split) content = Util.splitMessage(content, split); } options.content = content; if (options.embeds) options.embeds = options.embeds.map(embed => new Embed(embed)._apiTransform()); - if (options.file) { - if (options.files) options.files.push(options.file); - else options.files = [options.file]; - } - if (options.files) { for (let i = 0; i < options.files.length; i++) { let file = options.files[i]; - if (typeof file === 'string') file = { attachment: file }; + if (typeof file === 'string' || Buffer.isBuffer(file)) file = { attachment: file }; if (!file.name) { if (typeof file.attachment === 'string') { file.name = path.basename(file.attachment); } else if (file.attachment && file.attachment.path) { file.name = path.basename(file.attachment.path); + } else if (file instanceof Attachment) { + file = { attachment: file.file, name: path.basename(file.file) || 'file.jpg' }; } else { file.name = 'file.jpg'; } + } else if (file instanceof Attachment) { + file = file.file; } options.files[i] = file; } return Promise.all(options.files.map(file => - this.client.resolver.resolveBuffer(file.attachment).then(buffer => { - file.file = buffer; + this.client.resolver.resolveFile(file.attachment).then(resource => { + file.file = resource; return file; }) )).then(files => this.client.api.webhooks(this.id, this.token).post({ @@ -161,6 +184,26 @@ class Webhook { })); } + if (content instanceof Array) { + return new Promise((resolve, reject) => { + const messages = []; + (function sendChunk() { + const opt = content.length ? null : { embeds: options.embeds, files: options.files }; + this.client.api.webhooks(this.id, this.token).post({ + data: { content: content.shift(), opt }, + query: { wait: true }, + auth: false, + }) + .then(message => { + messages.push(message); + if (content.length === 0) return resolve(messages); + return sendChunk.call(this); + }) + .catch(reject); + }.call(this)); + }); + } + return this.client.api.webhooks(this.id, this.token).post({ data: options, query: { wait: true }, diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js index e532b960c..a50609d2c 100644 --- a/src/structures/interfaces/TextBasedChannel.js +++ b/src/structures/interfaces/TextBasedChannel.js @@ -3,6 +3,8 @@ const MessageCollector = require('../MessageCollector'); const Shared = require('../shared'); const Collection = require('../../util/Collection'); const Snowflake = require('../../util/Snowflake'); +const Attachment = require('../../structures/Attachment'); +const MessageEmbed = require('../../structures/MessageEmbed'); const { Error, RangeError, TypeError } = require('../../errors'); /** @@ -39,7 +41,7 @@ class TextBasedChannel { * (see [here](https://discordapp.com/developers/docs/resources/channel#embed-object) for more details) * @property {boolean} [disableEveryone=this.client.options.disableEveryone] Whether or not @everyone and @here * should be replaced with plain-text - * @property {FileOptions[]|string[]} [files] Files to send with the message + * @property {FileOptions[]|BufferResolvable[]} [files] Files to send with the message * @property {string|boolean} [code] Language for optional codeblock formatting to apply * @property {boolean|SplitOptions} [split=false] Whether or not the message should be split into multiple messages if * it exceeds the character limit. If an object is provided, these are the options for splitting the message @@ -72,7 +74,7 @@ class TextBasedChannel { * .then(message => console.log(`Sent message: ${message.content}`)) * .catch(console.error); */ - send(content, options) { + send(content, options) { // eslint-disable-line complexity if (!options && typeof content === 'object' && !(content instanceof Array)) { options = content; content = ''; @@ -80,6 +82,18 @@ class TextBasedChannel { options = {}; } + if (options instanceof MessageEmbed) options = { embed: options }; + if (options instanceof Attachment) options = { files: [options.file] }; + + if (content instanceof Array || options instanceof Array) { + const which = content instanceof Array ? content : options; + const attachments = which.filter(item => item instanceof Attachment); + if (attachments.length) { + options = { files: attachments }; + if (content instanceof Array) content = ''; + } + } + if (!options.content) options.content = content; if (options.embed && options.embed.files) { @@ -90,22 +104,26 @@ class TextBasedChannel { if (options.files) { for (let i = 0; i < options.files.length; i++) { let file = options.files[i]; - if (typeof file === 'string') file = { attachment: file }; + if (typeof file === 'string' || Buffer.isBuffer(file)) file = { attachment: file }; if (!file.name) { if (typeof file.attachment === 'string') { file.name = path.basename(file.attachment); } else if (file.attachment && file.attachment.path) { file.name = path.basename(file.attachment.path); + } else if (file instanceof Attachment) { + file = { attachment: file.file, name: path.basename(file.file) || 'file.jpg' }; } else { file.name = 'file.jpg'; } + } else if (file instanceof Attachment) { + file = file.file; } options.files[i] = file; } return Promise.all(options.files.map(file => - this.client.resolver.resolveBuffer(file.attachment).then(buffer => { - file.file = buffer; + this.client.resolver.resolveFile(file.attachment).then(resource => { + file.file = resource; return file; }) )).then(files => {