From 0b22d9a774f385d7fd9c6996616d37a6ba1b002d Mon Sep 17 00:00:00 2001 From: SpaceEEC Date: Tue, 22 Aug 2017 00:39:27 +0200 Subject: [PATCH] Backporting Attachments (#1817) --- src/client/ClientDataResolver.js | 27 +++++++ src/client/rest/RESTMethods.js | 43 ++++++----- src/index.js | 1 + src/structures/Attachment.js | 74 +++++++++++++++++++ src/structures/RichEmbed.js | 8 +- src/structures/Webhook.js | 64 +++++++++++++--- src/structures/interfaces/TextBasedChannel.js | 27 +++++-- 7 files changed, 208 insertions(+), 36 deletions(-) create mode 100644 src/structures/Attachment.js diff --git a/src/client/ClientDataResolver.js b/src/client/ClientDataResolver.js index adf28467a..751d3447b 100644 --- a/src/client/ClientDataResolver.js +++ b/src/client/ClientDataResolver.js @@ -234,6 +234,33 @@ class ClientDataResolver { return Promise.reject(new TypeError('The resource must be a string or Buffer.')); } + /** + * @external Stream + * @see {@link https://nodejs.org/api/stream.html} + */ + + /** + * 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('The resource must be a string, Buffer or a valid file stream.'); + } + }) : + Promise.reject(new TypeError('The resource must be a string, Buffer or a valid file stream.')); + } + /** * Data that can be resolved to give an emoji identifier. This can be: * * The unicode representation of an emoji diff --git a/src/client/rest/RESTMethods.js b/src/client/rest/RESTMethods.js index 14a28ee5e..9d67e9e8f 100644 --- a/src/client/rest/RESTMethods.js +++ b/src/client/rest/RESTMethods.js @@ -102,12 +102,12 @@ class RESTMethods { if (content instanceof Array) { const messages = []; (function sendChunk(list, index) { - const options = index === list.length ? { tts, embed } : { tts }; - chan.send(list[index], options, index === list.length ? files : null).then(message => { + const options = index === list.length - 1 ? { tts, embed, files } : { tts }; + chan.send(list[index], options).then(message => { messages.push(message); if (index >= list.length - 1) return resolve(messages); return sendChunk(list, ++index); - }); + }).catch(reject); }(content, 0)); } else { this.rest.makeRequest('post', Endpoints.Channel(chan).messages, true, { @@ -747,21 +747,30 @@ class RESTMethods { false, undefined, undefined, reason); } - sendWebhookMessage(webhook, content, { avatarURL, tts, disableEveryone, embeds, username } = {}, file = null) { - username = username || webhook.name; - if (typeof content !== 'undefined') content = this.client.resolver.resolveString(content); - if (content) { - if (disableEveryone || (typeof disableEveryone === 'undefined' && this.client.options.disableEveryone)) { - content = content.replace(/@(everyone|here)/g, '@\u200b$1'); + sendWebhookMessage(webhook, content, { avatarURL, tts, embeds, username } = {}, files = null) { + return new Promise((resolve, reject) => { + username = username || webhook.name; + + if (content instanceof Array) { + const messages = []; + (function sendChunk(list, index) { + const options = index === list.length - 1 ? { tts, embeds, files } : { tts }; + webhook.send(list[index], options).then(message => { + messages.push(message); + if (index >= list.length - 1) return resolve(messages); + return sendChunk(list, ++index); + }).catch(reject); + }(content, 0)); + } else { + this.rest.makeRequest('post', `${Endpoints.Webhook(webhook.id, webhook.token)}?wait=true`, false, { + username, + avatar_url: avatarURL, + content, + tts, + embeds, + }, files).then(resolve, reject); } - } - return this.rest.makeRequest('post', `${Endpoints.Webhook(webhook.id, webhook.token)}?wait=true`, false, { - username, - avatar_url: avatarURL, - content, - tts, - embeds, - }, file); + }); } sendSlackWebhookMessage(webhook, body) { diff --git a/src/index.js b/src/index.js index 37c06d96e..fb035c771 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..e4ccbb197 --- /dev/null +++ b/src/structures/Attachment.js @@ -0,0 +1,74 @@ +/** + * Represents an attachment in a message. + * @param {BufferResolvable|Stream} file The file + * @param {string} [name] The name of the file, if any + */ +class Attachment { + constructor(file, name) { + this.file = null; + if (name) this.setAttachment(file, name); + else this._attach(file); + } + + /** + * 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 }; + 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 (typeof file === 'string') this.file = file; + else this.setAttachment(file, name); + } +} + +module.exports = Attachment; diff --git a/src/structures/RichEmbed.js b/src/structures/RichEmbed.js index 62b40e185..8e4c6eb86 100644 --- a/src/structures/RichEmbed.js +++ b/src/structures/RichEmbed.js @@ -1,4 +1,5 @@ -const ClientDataResolver = require('../client/ClientDataResolver'); +const Attachment = require('./Attachment'); +let ClientDataResolver; /** * A rich embed to be sent with a message with a fluent interface for creation. @@ -113,6 +114,7 @@ class RichEmbed { * @returns {RichEmbed} This embed */ setColor(color) { + if (!ClientDataResolver) ClientDataResolver = require('../client/ClientDataResolver'); this.color = ClientDataResolver.resolveColor(color); return this; } @@ -203,11 +205,13 @@ class RichEmbed { /** * 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 {FileOptions|string} file Local path or URL to the file to attach, or valid FileOptions for a file to attach + * @param {FileOptions|string|Attachment} file Local path or URL to the file to attach, + * or valid FileOptions for a file to attach * @returns {RichEmbed} This embed */ attachFile(file) { if (this.file) throw new RangeError('You may not upload more than one file at once.'); + if (file instanceof Attachment) file = file.file; this.file = file; return this; } diff --git a/src/structures/Webhook.js b/src/structures/Webhook.js index 9a70ce833..68cec7c4b 100644 --- a/src/structures/Webhook.js +++ b/src/structures/Webhook.js @@ -1,4 +1,7 @@ const path = require('path'); +const Util = require('../util/Util'); +const Attachment = require('./Attachment'); +const RichEmbed = require('./RichEmbed'); /** * Represents a webhook. @@ -76,11 +79,12 @@ class Webhook { * @property {string} [avatarURL] Avatar URL override for the message * @property {boolean} [tts=false] Whether or not the message should be spoken aloud * @property {string} [nonce=''] The nonce for the message - * @property {Object[]} [embeds] An array of embeds for the message + * @property {Array} [embeds] An array of embeds for the message * (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|Attachment} [file] A file to send with the message **(deprecated)** + * @property {FileOptions[]|BufferResolvable[]|Attachment[]} [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. @@ -89,7 +93,8 @@ class Webhook { /** * Send a message with this webhook. * @param {StringResolvable} content The content to send - * @param {WebhookMessageOptions} [options={}] The options to provide + * @param {WebhookMessageOptions|Attachment|RichEmbed} [options] The options to provide + * can also be just a RichEmbed or Attachment * @returns {Promise} * @example * // Send a message @@ -97,39 +102,78 @@ 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 = ''; } else if (!options) { options = {}; } + + if (options instanceof Attachment) options = { files: [options] }; + if (options instanceof RichEmbed) options = { embeds: [options] }; + + if (content) { + content = this.client.resolver.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); + } + if (options.file) { if (options.files) options.files.push(options.file); else options.files = [options.file]; } + if (options.embeds) { + const files = []; + for (const embed of options.embeds) { + if (embed.file) files.push(embed.file); + } + if (options.files) options.files.push(...files); + else options.files = files; + } + 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 this.client.resolver.resolveBuffer(options.file.attachment).then(file => - this.client.rest.methods.sendWebhookMessage(this, content, options, { - file, - name: options.file.name, + + return Promise.all(options.files.map(file => + this.client.resolver.resolveFile(file.attachment).then(resource => { + file.file = resource; + return file; }) - ); + )).then(files => this.client.rest.methods.sendWebhookMessage(this, content, options, files)); } + return this.client.rest.methods.sendWebhookMessage(this, content, options); } diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js index 07c2a7382..16b0465ad 100644 --- a/src/structures/interfaces/TextBasedChannel.js +++ b/src/structures/interfaces/TextBasedChannel.js @@ -2,6 +2,8 @@ const path = require('path'); const Message = require('../Message'); const MessageCollector = require('../MessageCollector'); const Collection = require('../../util/Collection'); +const Attachment = require('../../structures/Attachment'); +const RichEmbed = require('../../structures/RichEmbed'); const util = require('util'); /** @@ -38,8 +40,8 @@ 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} [file] A file to send with the message **(deprecated)** - * @property {FileOptions[]|string[]} [files] Files to send with the message + * @property {FileOptions|BufferResolvable|Attachment} [file] A file to send with the message **(deprecated)** + * @property {FileOptions[]|BufferResolvable[]|Attachment[]} [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 @@ -64,7 +66,8 @@ class TextBasedChannel { /** * Send a message to this channel. * @param {StringResolvable} [content] Text for the message - * @param {MessageOptions} [options={}] Options for the message + * @param {MessageOptions|Attachment|RichEmbed} [options] Options for the message, + * can also be just a RichEmbed or Attachment * @returns {Promise} * @example * // Send a message @@ -80,7 +83,13 @@ class TextBasedChannel { options = {}; } - if (options.embed && options.embed.file) options.file = options.embed.file; + if (options instanceof Attachment) options = { files: [options.file] }; + if (options instanceof RichEmbed) options = { embed: options }; + + if (options.embed && options.embed.file) { + if (options.files) options.files.push(options.embed.file); + else options.files = [options.embed.file]; + } if (options.file) { if (options.files) options.files.push(options.file); @@ -90,22 +99,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 => this.client.rest.methods.sendMessage(this, content, options, files));