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

874 lines (807 loc) 27.1 kB
const _ = require('underscore'); const qs = require('querystring'); const url = require('url'); const moment = require('moment'); const { ChatExpress, ChatLog } = require('chat-platform'); const request = require('request').defaults({ encoding: null }); const { when, params } = require('../../helpers/utils'); const { log/*, log_pretty*/ } = require('../../helpers/lcd'); const parseButtons = require('./parse-buttons'); const payloadTx = require('./payload-translators'); const hasOneOf = require('./has-one-of'); const FACEBOOK_API_URL = 'https://graph.facebook.com/v15.0'; const EVENT_MAGIC_KEYWORDS = ['referral', 'optin', 'delivery', 'account_linking', 'read', 'reaction']; // set the messageId in a returning payload const setMessageId = (message, messageId) => ({ ...message, payload: { ...message.payload, messageId } }); const Facebook = new ChatExpress({ transport: 'facebook', transportDescription: 'Facebook Messenger', color: '#4267b2', relaxChatId: true, // sometimes chatId is not necessary (for example inline_query_id) chatIdKey: function(payload) { return payload.sender != null ? payload.sender.id : null; }, messageIdKey: function(payload) { return payload.message?.mid; }, userIdKey: function(payload) { return payload.sender != null ? payload.sender.id : null; }, tsKey: function(payload) { return moment.unix(payload.timestamp / 1000).toISOString(); }, type: function() { // todo remove this }, 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/facebook/test': function(req, res) { res.send('ok'); }, '/redbot/facebook': 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)) { const entries = json.entry; _(entries).each(function (entry) { const events = entry.messaging; // if it's a messaging entry, then do a minimal parsing if (entry.messaging != null) { _(events).each(function (event) { if (event.message != null && event.message.quick_reply != null && !_.isEmpty(event.message.quick_reply.payload)) { // handle quick reply, treat as message, pass thru the payload event.message = { text: event.message.quick_reply.payload }; delete event.quick_reply; chatServer.receive(event); } else if (event.message != null) { // handle inbound messages chatServer.receive(event); } else if (event.postback != null) { // handle postbacks event.message = { text: event.postback.payload }; event.referral = event.postback.referral ? event.postback.referral : null // for GET_STARTED event delete event.postback; chatServer.receive(event); } else if (hasOneOf(event, EVENT_MAGIC_KEYWORDS)) { // if payload contains one of the platform magic keyword, then is an event // treat as platform event (read, delivery, etc) const eventType = EVENT_MAGIC_KEYWORDS.find(key => !_.isEmpty(event[key])) if (eventType != null) { chatServer.receive({ ...event, eventType, eventPayload: event[eventType] }); } } else if (event.messaging_feedback != null) { chatServer.receive(event); } }); } else { // not a messaging event, relay the whole entry chatServer.receive(entry); } }); res.send({status: 'ok'}); } } } }, routesDescription: { '/redbot/facebook': 'Use this in the "Webhooks" section of the Facebook App ("Edit Subscription" button)', '/redbot/facebook/test': 'Use this to test that your SSL (with certificate or ngrok) is working properly, should answer "ok"' } }); // Generic handler for FB platform events Facebook.in(function(message) { if (message.originalMessage.eventType != null) { message.payload.type = 'event'; message.payload.eventType = message.originalMessage.eventType; message.payload.content = message.originalMessage.eventPayload; return message; } return message; }); // get plain text messages Facebook.in(function(message) { return new Promise(function(resolve) { if (message.originalMessage.message && _.isString(message.originalMessage.message.text) && !_.isEmpty(message.originalMessage.message.text)) { message.payload.content = message.originalMessage.message.text; message.payload.type = 'message'; resolve(message); } else { resolve(message); } }); }); Facebook.in(function(message) { var chatServer = this; var attachments = message.originalMessage.message ? message.originalMessage.message.attachments: null; if (_.isArray(attachments) && !_.isEmpty(attachments)) { var attachment = attachments[0]; var type = null; if (attachment.type === 'image') { type = 'photo'; } else if (attachment.type === 'audio') { type = 'audio'; } else if (attachment.type === 'file') { type = 'document'; } else if (attachment.type === 'video') { type = 'video'; } else { // don't know what to do return message; } // download the image into a buffer return new Promise(function(resolve, reject) { chatServer.downloadFile(attachment.payload.url) .then(function (buffer) { message.payload.content = buffer; message.payload.type = type; resolve(message); }, function() { reject('Unable to download ' + attachment.payload.url); }); }); } return message; }); // get facebook user details Facebook.in(function(message) { return new Promise(function(resolve) { // skip echo messages if (message.originalMessage.message != null && message.originalMessage.message.is_echo) { // must be in a promise to return null return; } resolve(message); }); }); // detect position attachment Facebook.in(function(message) { var attachments = message.originalMessage.message ? message.originalMessage.message.attachments: null; if (_.isArray(attachments) && !_.isEmpty(attachments) && attachments[0].type === 'location') { message.payload.type = 'location'; message.payload.content = { latitude: attachments[0].payload.coordinates.lat, longitude: attachments[0].payload.coordinates.long }; return message; } return message; }); Facebook.out('message', async function(message) { const chatServer = this; const context = message.chat(); const param = params(message); const response = await chatServer.sendMessage( message.payload.chatId, payloadTx.message(message.payload), { notificationType: param('notificationType', undefined), messageTag: param('messageTag', undefined), messagingType: param('messagingType', undefined) } ); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); Facebook.out('location', async function(message) { const context = message.chat(); const chatServer = this; const lat = message.payload.content.latitude; const lon = message.payload.content.longitude; const locationAttachment = { type: 'template', payload: { template_type: 'generic', elements: { element: { title: !_.isEmpty(message.payload.place) ? message.payload.place : 'Position', image_url: 'https:\/\/maps.googleapis.com\/maps\/api\/staticmap?size=764x400&center=' + lat + ',' + lon + '&zoom=16&markers=' + lat + ',' + lon, item_url: 'http:\/\/maps.apple.com\/maps?q=' + lat + ',' + lon + '&z=16' } } } }; const response = await chatServer.sendMessage( message.payload.chatId, { attachment: locationAttachment } ); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); // handle request Facebook.out('request', async function(message) { const context = message.chat(); const chatServer = this; if (message.payload.requestType !== 'location') { throw 'Facebook only supports requests of type "location"'; } const response = await chatServer.sendMessage(message.payload.chatId, { text: message.payload.content, quick_replies: [ { content_type: 'location' } ] }); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); // quick replies Facebook.out('quick-replies', async function(message) { const chatServer = this; const context = message.chat(); const response = await chatServer.sendMessage( message.payload.chatId, { text: message.payload.content, quick_replies: parseButtons(message.payload.buttons) } ); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); // sends a photo Facebook.out('photo', async function(message) { const chatServer = this; const options = this.getOptions(); const context = message.chat(); const image = message.payload.content; const response = await chatServer.uploadBuffer({ recipient: message.payload.chatId, type: 'image', buffer: image, token: options.token, filename: message.payload.filename }); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); // sends a document Facebook.out('document', async function(message) { const chatServer = this; const options = this.getOptions(); const context = message.chat(); const image = message.payload.content; const response = await chatServer.uploadBuffer({ recipient: message.payload.chatId, type: 'file', buffer: image, token: options.token, filename: message.payload.filename }); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); // sends an audio Facebook.out('audio', async function(message) { const chatServer = this; const options = this.getOptions(); const context = message.chat(); const image = message.payload.content; const response = await chatServer.uploadBuffer({ recipient: message.payload.chatId, type: 'audio', buffer: image, token: options.token, filename: message.payload.filename }); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); Facebook.out('action', function(message) { var options = this.getOptions(); return new Promise(function (resolve, reject) { request({ method: 'POST', json: { recipient: { id: message.payload.chatId }, sender_action: 'typing_on' }, url: `${FACEBOOK_API_URL}/v3.1/me/messages?access_token=` + options.token }, function(error) { if (error != null) { reject(error); } else { resolve(message); } }); }); }); Facebook.out('inline-buttons', async function(message) { const chatServer = this; //const options = this.getOptions(); const context = message.chat(); const response = await chatServer.sendMessage(message.payload.chatId, payloadTx.inlineButtons(message.payload)) await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); Facebook.out('list-template', async function(message) { const chatServer = this; //const options = this.getOptions(); const context = message.chat(); const response = await chatServer.sendMessage(message.payload.chatId,payloadTx.listTemplate(message.payload)); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); Facebook.out('template', async function(message) { const chatServer = this; const context = message.chat(); const param = params(message); let payload; switch(message.payload.templateType) { case 'generic': payload = payloadTx.genericTemplate(message.payload, param); break; case 'receipt': payload = payloadTx.receiptTemplate(message.payload.json, param); break; case 'customer_feedback': payload = payloadTx.customerFeedbackTemplate(message.payload.json); break; case 'media': payload = payloadTx.mediaTemplate(message.payload, param); break; case 'product': payload = payloadTx.productTemplate(message.payload); break; case 'button': payload = payloadTx.buttonTemplate(message.payload); break; } const response = await chatServer.sendMessage(message.payload.chatId, payload); await when(context.set({ messageId: response.message_id, outboundMessageId: response.message_id })); return setMessageId(message, response.message_id); }); // log messages, these should be the last Facebook.out(function(message) { var options = this.getOptions(); var logfile = options.logfile; var chatContext = message.chat(); if (!_.isEmpty(logfile)) { return when(chatContext.all()) .then(function(variables) { var chatLog = new ChatLog(variables); return chatLog.log(message, logfile); }); } return message; }); Facebook.in('*', function(message) { var options = this.getOptions(); var logfile = options.logfile; var chatContext = message.chat(); if (!_.isEmpty(logfile)) { return when(chatContext.all()) .then(function(variables) { var chatLog = new ChatLog(variables); return chatLog.log(message, logfile); }); } return message; }); Facebook.mixin({ sendVerificationChallenge: function(req, res) { var options = this.getOptions(); var query = qs.parse(url.parse(req.url).query); // eslint-disable-next-line no-console log( `Verifying Facebook Messenger token ${query['hub.verify_token']}, should be ` + (options.verifyToken ? `"${options.verifyToken}"` : 'anything') ); // eslint-disable-next-line no-console log('Token verified.'); return res.end(query['hub.challenge']); /*if (query['hub.verify_token'] === options.verifyToken) { // eslint-disable-next-line no-console console.log('Token verified.'); return res.end(query['hub.challenge']); } return res.end('Error, wrong validation token');*/ }, /* DOCS: https://developers.facebook.com/docs/messenger-platform/identity/user-profile/ */ getProfile: function(id) { var options = this.getOptions(); var profileFields = !_.isEmpty(options.profileFields) ? options.profileFields : 'first_name,last_name'; return new Promise(function(resolve, reject) { request({ method: 'GET', uri: `${FACEBOOK_API_URL}/${id}`, qs: { fields: profileFields, access_token: options.token }, json: true }, function(err, res, body) { if (err) { reject(err); } else if (body.error) { reject(body.error); } else { // cleanup a little resolve(_.extend( { firstName: body.first_name, lastName: body.last_name, language: !_.isEmpty(body.locale) ? body.locale.substr(0,2) : null }, _.omit(body, 'locale', 'id', 'first_name', 'last_name') )); } }); }); }, sendMessage: function(recipient, payload, opts = {}) { const options = this.getOptions(); const json = { messaging_type: 'RESPONSE', recipient: { id: recipient }, message: payload }; if (opts.notificationType != null) { json.notification_type = opts.notificationType; } if (opts.messagingType != null) { json.messaging_type = opts.messagingType; } if (opts.messageTag != null) { json.tag = opts.messageTag; } return new Promise(function(resolve, reject) { request({ method: 'POST', uri: `${FACEBOOK_API_URL}/me/messages`, qs: { access_token: options.token }, json }, function(err, res, body) { if (err) { return reject(err) } else if ((body != null) && (_.isString(body))) { // body in string in case of error var errorJSON = null; try { errorJSON = JSON.parse(body); } catch(e) { errorJSON = {error: 'Error parsing error payload from Facebook.'}; } return reject(errorJSON.error); } else if (body != null && body.error != null) { return reject(body.error.message); } return resolve(body) }); }); }, uploadBuffer: function(params) { return new Promise(function(resolve, reject) { params = _.extend({ recipient: null, filename: 'tmp-file', token: null, buffer: null, type: 'image', mimeType: 'application/octet-stream' }, params); // prepare payload var filedata = null; switch(params.type) { case 'image': filedata = { value: params.buffer, options: { filename: params.filename || 'image.png', contentType: 'image/png' // fix extension } }; break; case 'audio': filedata = { value: params.buffer, options: { filename: params.filename || 'audio.mp3', contentType: 'audio/mp3' } }; break; case 'video': filedata = { value: params.buffer, options: { filename: params.filename || 'video.mpg', contentType: params.mimeType } }; break; case 'file': filedata = { value: params.buffer, options: { filename: params.filename || 'file', contentType: params.mimeType } }; } // upload and send const formData = { messaging_type: 'RESPONSE', recipient: '{"id":"' + params.recipient +'"}', message: '{"attachment":{"type":"' + params.type + '", "payload":{}}}', filedata: filedata }; request.post({ url: `${FACEBOOK_API_URL}/me/messages?access_token=${params.token}`, formData: formData, json: true }, function(err, _response, json) { if (err) { reject(err); } else { resolve(json); } }); }); }, downloadFile: function(url) { return new Promise(function(resolve, reject) { var options = { url: url }; request(options, function(error, response, body) { if (error) { reject(error); } else { resolve(body); } }); }); }, removePersistentMenu: function() { var options = this.getOptions(); return new Promise(function(resolve, reject) { request({ method: 'DELETE', uri: `${FACEBOOK_API_URL}/me/messenger_profile`, qs: { access_token: options.token }, json: { fields: ['persistent_menu'] } }, function(err, res, body) { if (body != null && body.error != null) { reject(body.error.message) } else { resolve(); } }); }); }, setPersistentMenu: function(items, composerInputDisabled) { const options = this.getOptions(); return new Promise(function(resolve, reject) { request({ method: 'POST', uri: `${FACEBOOK_API_URL}/me/messenger_profile`, qs: { access_token: options.token }, json: { 'persistent_menu': [ { locale: 'default', composer_input_disabled: composerInputDisabled, call_to_actions: items } ] } }, function (err, res, body) { if (body != null && body.error != null) { reject(body.error.message) } else { resolve(); } }); }); } }); const videoExtensions = ['.mp4']; const audioExtensions = ['.mp3']; const documentExtensions = ['.pdf', '.png', '.jpg', '.zip', '.gif']; const photoExtensions = ['.jpg', '.jpeg', '.png', '.gif']; Facebook.registerMessageType('action', 'Action', 'Send an action message (like typing, ...)'); Facebook.registerMessageType('buttons', 'Buttons', 'Open keyboard buttons in the client'); Facebook.registerMessageType('command', 'Command', 'Detect command-like messages'); Facebook.registerMessageType('inline-buttons', 'Inline buttons', 'Send a message with inline buttons'); Facebook.registerMessageType('message', 'Message', 'Send a plain text message'); Facebook.registerMessageType('quick-replies', 'Quick Replies', 'Send large inline buttons for quick replies'); Facebook.registerMessageType('event', 'Event', 'Event from platform'); Facebook.registerMessageType('list-template', 'List Template', 'This is deprecated, use "template" node'); Facebook.registerMessageType('template', 'Template', 'Template type, could be: generic, button, media, etc'); Facebook.registerMessageType( 'video', 'Video', 'Send video message', file => { if (!_.isEmpty(file.extension) && !videoExtensions.includes(file.extension)) { return `Unsupported file format for video node "${file.filename}", allowed formats: ${videoExtensions.join(', ')}`; } return null; } ); Facebook.registerMessageType( 'document', 'Document', 'Send a document or generic file', file => { if (!_.isEmpty(file.extension) && !documentExtensions.includes(file.extension)) { return `Unsupported file format for document node "${file.filename}", allowed formats: ${documentExtensions.join(', ')}`; } return null; } ); Facebook.registerMessageType( 'audio', 'Audio', 'Send an audio message', file => { if (!_.isEmpty(file.extension) && !audioExtensions.includes(file.extension)) { return `Unsupported file format for audio node "${file.filename}", allowed formats: ${audioExtensions.join(', ')}`; } return null; } ); Facebook.registerMessageType( 'photo', 'Photo', 'Send a photo message', file => { if (!_.isEmpty(file.extension) && !photoExtensions.includes(file.extension)) { return `Unsupported file format for image node "${file.filename}", allowed formats: ${photoExtensions.join(', ')}`; } } ); Facebook.registerParam( 'aspectRatio', 'select', { label: 'Aspect ratio', default: 'horizontal', description: 'The aspect ratio used to render images specified in generic template', placeholder: 'Ratio', options: [ { value: 'horizontal', label: 'Horizontal'}, { value: 'square', label: 'Square' } ] } ); Facebook.registerParam( 'notificationType', 'select', { label: 'Notification', default: 'REGULAR', description: 'Type of push notification a person will receive', placeholder: 'Select notification', options: [ { value: 'NO_PUSH', label: 'No notification'}, { value: 'REGULAR', label: 'Sound or vibration when a message is received by a person' }, { value: 'SILENT_PUSH', label: 'On-screen notification only' } ] } ); Facebook.registerParam( 'messagingType', 'select', { label: 'Messaging type', default: 'RESPONSE', description: 'The type of message being sent', placeholder: 'Select type', options: [ { value: 'RESPONSE', label: 'Message is in response to a received message (RESPONSE)'}, { value: 'UPDATE', label: 'Message is being sent proactively and is not in response to a received message (UPDATE)' }, { value: 'MESSAGE_TAG', label: 'Message is non-promotional and is being sent outside the 24-hour standard messaging window with a message tag (MESSAGE_TAG)' } ] } ); Facebook.registerParam( 'messageTag', 'select', { label: 'Message tag', description: 'A tag that enables your Page to send a message to a person outsde the standard 24 hour messaging window', placeholder: 'Select tag', options: [ { value: 'ACCOUNT_UPDATE', label: 'Tags the message you are sending to your customer as a non-recurring update (ACCOUNT_UPDATE)'}, { value: 'CONFIRMED_EVENT_UPDATE', label: 'Tags the message you are sending to your customer as a reminder (CONFIRMED_EVENT_UPDATE)'}, { value: 'CUSTOMER_FEEDBACK', label: 'Tags the message you are sending to your customer as a Customer Feedback Survey (CUSTOMER_FEEDBACK)'}, { value: 'HUMAN_AGENT', label: 'Allows a human agent to respond to a person\'s message (HUMAN_AGENT)'}, ] } ); Facebook.registerParam( 'sharable', 'boolean', { label: 'Native share button in template message', default: false } ); Facebook.registerEvent('referral', 'Referral data'); Facebook.registerEvent('optin', 'Referral data'); Facebook.registerEvent('delivery', 'Message was delivered'); Facebook.registerEvent('account_linking', 'Occur when the Link Account or Unlink Account button have been tapped'); Facebook.registerEvent('read', 'Message has sent has been read by the user'); Facebook.registerEvent('optin', 'Triggered when a person opts in to receiving recurring notifications'); Facebook.registerEvent('reaction', 'User reacts to a message on Messenger'); module.exports = Facebook;