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
JavaScript
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¢er='
+ 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;