UNPKG

node-red-contrib-chatbot

Version:

REDBot a Chat bot for a full featured chat bot for Telegram, Facebook Messenger and Slack. Almost no coding skills required

664 lines (593 loc) 19.7 kB
const _ = require('underscore'); const qs = require('querystring'); const url = require('url'); const moment = require('moment'); const { ChatExpress, ChatLog } = require('chat-platform'); const fetch = require('node-fetch'); const validators = require('../../helpers/validators'); const { when, params, getMessageId } = require('../../helpers/utils'); const fetchers = require('../../helpers/fetchers-obj'); const { log } = require('../../helpers/lcd'); const DOCUMENT_EXTENSIONS = ['.txt', '.doc', '.docx','.pdf', '.ppt','.pptx', '.xls', '.xlsx']; const PHOTO_EXTENSIONS = ['.jpg', '.jpeg', '.png']; const AUDIO_EXTENSIONS = ['.aac', '.mp4', '.mpeg', '.amr', '.ogg', '.opus', '.mp3']; const VIDEO_EXTENSIONS = ['.mp4', '.3gp']; const MAX_PHOTO_DIMENSIONS = 5 * 1024 * 1024; const WHATSAPP_API_URL = 'https://graph.facebook.com/v15.0'; const stream2buffer = function(stream) { return new Promise((resolve, reject) => { const _buf = []; stream.on('data', (chunk) => _buf.push(chunk)); stream.on('end', () => resolve(Buffer.concat(_buf))); stream.on('error', (err) => reject(err)); }); }; /** * txButton * Translate a RedBot button into a interactive button (in Whatsapp) * @param {object} button * @returns {object} */ const txButton = function(button) { if (button.type === 'postback') { return { type: 'reply', reply: { title: button.label, id: !_.isEmpty(button.value) ? button.value : button.label } }; } return undefined; } // set the messageId in a returning payload const setMessageId = (message, messageId) => ({ ...message, payload: { ...message.payload, messageId } }); const Whatsapp = new ChatExpress({ transport: 'whatsapp', transportDescription: 'Whatsapp', color: '#73E468', chatIdKey: function(payload) { if (_.isArray(payload.contacts) && !_.isEmpty(payload.contacts)) { // in case of message return payload.contacts[0].wa_id; } else if (payload.recipient_id != null) { // this in case of an event return payload.recipient_id; } return null; }, messageIdKey: function(payload) { return payload.id; }, userIdKey: function(payload) { if (_.isArray(payload.contacts) && !_.isEmpty(payload.contacts)) { // in case of message return payload.contacts[0].wa_id; } else if (payload.recipient_id != null) { // this in case of an event return payload.recipient_id; } return null; }, tsKey: function(payload) { return moment.unix(payload.timestamp / 1000).toISOString(); }, type: function(payload) { // todo remove this return payload.type; }, onStart: function() { this._profiles = {}; return true; }, events: {}, multiWebHook: true, webHookScheme: function() { const { token } = this.getOptions(); return token != null ? token.substr(0,10) : null; }, routes: { '/redbot/whatsapp/test': function(req, res) { res.send('ok'); }, '/redbot/whatsapp': function(req, res) { const chatServer = this; if (req.method === 'GET') { // it's authentication challenge this.sendVerificationChallenge(req, res); } else if (req.method === 'POST') { const json = req.body; // docs for entry messages // https://developers.facebook.com/docs/messenger-platform/reference/webhook-events // and // https://developers.facebook.com/docs/graph-api/webhooks/getting-started if (json != null && _.isArray(json.entry)) { // cycle through messages json.entry.forEach(entry => { if (_.isArray(entry.changes)) { entry.changes.forEach(change => { if (change?.field === 'messages' && _.isArray(change?.value.statuses)) { change?.value.statuses.forEach(status => { // move some fields, those useful to determine chatId, userId, timestamp, messageId // and eventType to first level chatServer.receive({ type: 'event', eventType: status.status, recipient_id: status.recipient_id, id: status.id, timestamp: status.timestamp, content: _.omit(status, ['recipient_id', 'id', 'timestamp', 'status']) }); }); } else if (change?.field === 'messages' && _.isArray(change?.value.messages)) { change?.value.messages.forEach(message => { chatServer.receive({ ...message, contacts: change.value.contacts, ...change.value.metadata }); }); } }); } }); } // send ok to Whatsapp server res.send({ status: 'OK' }); } } }, routesDescription: { '/redbot/whatsapp': 'Use this in the "Webhooks" section of the Facebook App ("Edit Subscription" button)', '/redbot/whatsapp/test': 'Use this to test that your SSL (with certificate or ngrok) is working properly, should answer "ok"' } }); Whatsapp.mixin({ sendMediaFactory: function(type, factoryOptions) { const chatServer = this; const opts = { caption: true, ...factoryOptions }; return async function(message) { const context = message.chat(); const options = chatServer.getOptions(); const param = params(message); let waContext = undefined; if (param('replyToMessage', false)) { waContext = { message_id: getMessageId(message) }; } // upload the media let uploaded; try { uploaded = await chatServer.uploadBuffer({ buffer: message.payload.content, mimeType: message.payload.mimeType, filename: message.payload.filename }); } catch(e) { throw `Unable to upload file: ${message.payload.filename} (${message.payload.mimeType})`; } if (uploaded.error) { throw uploaded.error.message; } const mediaObject = { id: uploaded.id }; if (opts.caption) { mediaObject.caption = message.payload.caption; } const response = await chatServer .post(`${options.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to: message.payload.chatId, type, context: waContext, [type]: mediaObject }); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.messageId); }; }, sendVerificationChallenge: function(req, res) { const options = this.getOptions(); const query = qs.parse(url.parse(req.url).query); // eslint-disable-next-line no-console log('Verifying Whatsapp token "' + query['hub.verify_token'] + '", should be ' + (options.verifyToken ? '"' + options.verifyToken + '"' : 'anyhting')); // eslint-disable-next-line no-console log('Token verified.'); return res.end(query['hub.challenge']); }, getMedia: async function(url) { const options = this.getOptions(); const res = await fetch(url, { headers: { 'Authorization': `Bearer ${options.token}`, 'Content-Type': 'application/octet-stream' } }); return await stream2buffer(res.body); }, post: function(endpoint, json) { return this.call('POST', endpoint, json); }, get: function(endpoint, json) { return this.call('GET', endpoint, json); }, call: async function(method, endpoint, json) { const options = this.getOptions(); const res = await fetch(`${WHATSAPP_API_URL}/${endpoint}`, { method: method, headers: { 'Authorization': `Bearer ${options.token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(json) }); const result = await res.json(); // check error if (result.error != null) { throw `WhatsAPI error: ${result.error.message}`; } // extract message id if any if (_.isArray(result.messages) && _.isEmpty(result.messages)) { result.messageId = result.messages[0].id; } return result; }, uploadBuffer: async function(params) { const options = this.getOptions(); params = _.extend({ mimeType: 'application/octet-stream' }, params); const formData = new FormData(); formData.append( 'file', params.buffer, { filename: params.filename, contentType: params.mimeType, } ); formData.append('messaging_product', 'whatsapp'); const response = await fetch(`${WHATSAPP_API_URL}/${options.phoneNumberId}/media`, { headers: { 'Authorization': `Bearer ${options.token}` }, method: 'POST', body: formData }); const data = await response.json() return data; }, /** * translateParameters * Translate video, document and image parameters, uploading them and replaceing with the media id * @param {*} parameters */ translateParameters: async function(parameters) { const chatServer = this; if (_.isEmpty(parameters)) { return parameters; } let idx; const translated = []; for(idx = 0; idx < parameters.length; idx++) { // for image vide and document, if local file, upload with media api and use the id if (['image', 'video', 'document'].includes(parameters[idx].type)) { if (validators.url(parameters[idx].image)) { translated.push({ type: parameters[idx].type, [parameters[idx].type]: { link: parameters[idx][parameters[idx].type] } }); } else if (validators.filepath(parameters[idx][parameters[idx].type])) { try { const uploaded = await fetchers.file(parameters[idx][parameters[idx].type]); const media = await await chatServer.uploadBuffer({ buffer: uploaded.buffer, mimeType: uploaded.mimeType, filename: uploaded.filename }); translated.push({ type: parameters[idx].type, [parameters[idx].type]: { id: media.id } }); } catch (e) { throw `Unable to find media file: ${parameters[idx]['image']}`; } } } else { // if nothing applies, leave it unchanged translated.push(parameters[idx]); } } return translated; } }); Whatsapp.in('event', function(message) { if (message.originalMessage.type != null) { message.payload.type = 'event'; message.payload.eventType = message.originalMessage.eventType; message.payload.content = message.originalMessage.content; return message; } return message; }); Whatsapp.in('text', function(message) { message.payload.content = message.originalMessage.text.body; message.payload.type = 'message'; if (message.originalMessage.context != null) { message.payload.replyTo = message.originalMessage.context.id; } return message; }); Whatsapp.in('location', function(message) { message.payload.type = 'location'; message.payload.content = message.originalMessage.location; if (message.originalMessage.context != null) { message.payload.replyTo = message.originalMessage.context.id; } return message; }); Whatsapp.in('button', function(message) { message.payload.content = message.originalMessage.button.payload; message.payload.type = 'message'; if (message.originalMessage.context != null) { message.payload.replyTo = message.originalMessage.context.id; } return message; }); Whatsapp.in('interactive', function(message) { message.payload.content = message.originalMessage.interactive?.button_reply?.id; // always not empty message.payload.type = 'message'; if (message.originalMessage.context != null) { message.payload.replyTo = message.originalMessage.context.id; } return message; }); Whatsapp.in('image', async function(message) { const chatServer = this; try { const response = await chatServer.get(message.originalMessage.image.id); const image = await this.getMedia(response.url); message.payload.type = 'photo'; message.payload.content = image; return message; } catch (e) { throw `Unable to process image ${message.originalMessage.image.id}`; } }); Whatsapp.out('message', async function(message) { const chatServer = this; const options = this.getOptions(); const context = message.chat(); const param = params(message); let waContext = undefined; if (param('replyToMessage', false)) { waContext = { message_id: getMessageId(message) }; } const response = await chatServer .post(`${options.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to: message.payload.chatId, type: 'text', context: waContext, text: { body: message.payload.content, preview_url: param('preview_url', false) } }); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.messageId); }); Whatsapp.out('location', async function(message) { const context = message.chat(); const chatServer = this; const options = this.getOptions(); const response = await chatServer .post(`${options.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to: message.payload.chatId, type: 'location', location: { longitude: message.payload.content.longitude, latitude: message.payload.content.latitude, name: message.payload.place } }); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.messageId); }); Whatsapp.out('whatsapp-template', async function(message) { const context = message.chat(); const chatServer = this; const options = this.getOptions(); // build params list const components = []; if (_.isArray(message.payload.paramsBody) && !_.isEmpty(message.payload.paramsBody)) { components.push({ type: 'body', parameters: await chatServer.translateParameters(message.payload.paramsBody) }); } if (_.isArray(message.payload.paramsHeader) && !_.isEmpty(message.payload.paramsHeader)) { components.push({ type: 'header', parameters: await chatServer.translateParameters(message.payload.paramsHeader) }); } const payload = { messaging_product: 'whatsapp', to: message.payload.chatId, type: 'template', template: { name: message.payload.template, language: { code: message.payload.language }, components: !_.isEmpty(components) ? components : undefined } }; //log(payload) const response = await chatServer .post(`${options.phoneNumberId}/messages`, payload); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.messageId); }); Whatsapp.out('document', async function(message) { return this.sendMediaFactory('document')(message); }); Whatsapp.out('photo', async function(message) { return this.sendMediaFactory('image')(message); }); Whatsapp.out('audio', async function(message) { return this.sendMediaFactory('audio', { caption: false })(message); }); Whatsapp.out('video', async function(message) { return this.sendMediaFactory('video')(message); }); Whatsapp.out('inline-buttons', async function(message) { const chatServer = this; const options = this.getOptions(); const context = message.chat(); const response = await chatServer .post(`${options.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to: message.payload.chatId, type: 'interactive', interactive: { body: { text: message.payload.content }, type: 'button', action: { buttons: message.payload.buttons .filter(({ type }) => type === 'postback') .map(txButton) } } }); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.messageId); }); // log messages, these should be the last Whatsapp.out(function(message) { const options = this.getOptions(); const logfile = options.logfile; const chatContext = message.chat(); if (!_.isEmpty(logfile)) { return when(chatContext.all()) .then(function(variables) { const chatLog = new ChatLog(variables); return chatLog.log(message, logfile); }); } return message; }); Whatsapp.in('*', function(message) { const options = this.getOptions(); const logfile = options.logfile; const chatContext = message.chat(); if (!_.isEmpty(logfile)) { return when(chatContext.all()) .then(function(variables) { const chatLog = new ChatLog(variables); return chatLog.log(message, logfile); }); } return message; }); Whatsapp.registerMessageType('message', 'Message', 'Send a plain text message'); Whatsapp.registerMessageType('location', 'Location', 'Send a map location message'); Whatsapp.registerMessageType('whatsapp-template', 'Whatsapp Template', 'Send Whatsapp template with parameters'); Whatsapp.registerMessageType('inline-buttons', 'Inline buttons', 'Send a message with inline buttons'); Whatsapp.registerMessageType( 'document', 'Document', 'Send a document or generic file', file => { if (!_.isEmpty(file.extension) && !DOCUMENT_EXTENSIONS.includes(file.extension)) { return `Unsupported file format for document node "${file.filename}", allowed formats: ${DOCUMENT_EXTENSIONS.join(', ')}`; } return null; } ); Whatsapp.registerMessageType( 'photo', 'Photo', 'Send a photo message', file => { if (!_.isEmpty(file.extension) && !PHOTO_EXTENSIONS.includes(file.extension)) { return `Unsupported file format for image node "${file.filename}", allowed formats: ${PHOTO_EXTENSIONS.join(', ')}`; } if (file.size != null && file.size > MAX_PHOTO_DIMENSIONS) { return `Excedeed size for image node "${file.filename}", max size: 5MB}`; } } ); Whatsapp.registerMessageType( 'audio', 'Audio', 'Send an audio message', file => { if (!_.isEmpty(file.extension) && !AUDIO_EXTENSIONS.includes(file.extension)) { return `Unsupported file format for audio node "${file.filename}", allowed formats: ${AUDIO_EXTENSIONS.join(', ')}`; } return null; } ); Whatsapp.registerMessageType( 'video', 'Video', 'Send video message', file => { if (!_.isEmpty(file.extension) && !VIDEO_EXTENSIONS.includes(file.extension)) { return `Unsupported file format for video node "${file.filename}", allowed formats: ${VIDEO_EXTENSIONS.join(', ')}`; } return null; } ); Whatsapp.registerParam( 'preview_url', 'boolean', { label: 'Allows for URL previews in text messages', default: false } ); Whatsapp.registerParam( 'replyToMessage', 'boolean', { label: 'Reply to message', default: false } ); Whatsapp.registerEvent('sent', 'Message was sent'); Whatsapp.registerEvent('delivered', 'Message was delivered'); Whatsapp.registerEvent('read', 'Message was read'); module.exports = Whatsapp;