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
300 lines (278 loc) • 10.6 kB
JavaScript
const _ = require('lodash');
const dayjs = require('dayjs');
const fetch = require('node-fetch');
const { ChatExpress } = require('chat-platform');
const utils = require('../../lib/helpers/utils');
const { params } = utils;
// Sinch Conversation API base URL. Region is selectable (us, eu, br, ap) — defaults to us.
const regionBaseUrl = region => {
switch (region) {
case 'eu': return 'https://eu.conversation.api.sinch.com';
case 'br': return 'https://br.conversation.api.sinch.com';
case 'ap': return 'https://ap.conversation.api.sinch.com';
case 'us':
default: return 'https://us.conversation.api.sinch.com';
}
};
const AUTH_URL = 'https://auth.sinch.com/oauth2/token';
// OAuth2 client_credentials — Sinch returns a Bearer token with `expires_in` (seconds).
// Cached on the server options and refreshed 60s before expiry.
async function fetchAccessToken(options) {
const basic = Buffer.from(`${options.keyId}:${options.keySecret}`).toString('base64');
const response = await fetch(AUTH_URL, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials'
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Sinch auth failed (${response.status}): ${body}`);
}
const json = await response.json();
return {
accessToken: json.access_token,
expiresAt: Date.now() + ((json.expires_in - 60) * 1000)
};
}
async function getAccessToken(options) {
if (options._tokenCache != null && options._tokenCache.expiresAt > Date.now()) {
return options._tokenCache.accessToken;
}
options._tokenCache = await fetchAccessToken(options);
return options._tokenCache.accessToken;
}
async function sinchApiCall(options, path, body) {
const accessToken = await getAccessToken(options);
const url = `${regionBaseUrl(options.region)}/v1/projects/${options.projectId}${path}`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const errBody = await response.text();
throw new Error(`Sinch API ${path} failed (${response.status}): ${errBody}`);
}
return response.json();
}
const Sinch = new ChatExpress({
color: '#2A8FBD',
inboundMessageEvent: 'message',
transport: 'sinch',
transportDescription: 'Sinch',
relaxChatId: true,
chatIdKey: function(payload) {
if (payload.message != null && payload.message.contact_id != null) {
return payload.message.contact_id;
}
return payload.contact_id != null ? payload.contact_id : null;
},
userIdKey: function(payload) {
if (payload.message != null && payload.message.contact_id != null) {
return payload.message.contact_id;
}
return payload.contact_id != null ? payload.contact_id : null;
},
messageIdKey: function(payload) {
if (payload.message != null && payload.message.id != null) {
return payload.message.id;
}
return payload.message_id;
},
tsKey: function(payload) {
const ts = payload.accepted_time || payload.event_time
|| (payload.message != null ? payload.message.accept_time : null);
return ts != null ? dayjs(ts).toISOString() : dayjs().toISOString();
},
type: function() {
// todo remove this
},
language: function(/* payload */) {
return null;
},
onStop: function() {
var options = this.getOptions();
options._tokenCache = null;
return Promise.resolve();
},
onStart: function() {
// nothing to bootstrap — Conversation API is HTTP-only and inbound messages
// arrive via the registered /redbot/sinch webhook route
return true;
},
routes: {
'/redbot/sinch/test': function(req, res) {
res.send('ok');
},
'/redbot/sinch': function(req, res) {
const chatServer = this;
const json = req.body;
// MESSAGE_INBOUND is the standard contact-to-app event from Sinch Conversation API
const triggerType = json != null ? (json.trigger || json.event_type) : null;
if (json != null && json.message != null && (triggerType == null || triggerType === 'MESSAGE_INBOUND')) {
chatServer.receive(json);
}
res.send({ status: 'ok' });
}
},
multiWebHook: false/*,
webHookScheme: function() {
const { webHook } = this.getOptions();
const match = (webHook || '').match(/\/redbot\/sinch\/(.*)$/);
return match != null ? match[1] : '';
}*/
});
// extract plain text messages from a Conversation API inbound payload
Sinch.in(function(message) {
const botMsg = message.originalMessage;
return new Promise(function(resolve) {
const contactMessage = botMsg.message != null ? botMsg.message.contact_message : null;
if (contactMessage != null && contactMessage.text_message != null) {
message.payload.content = contactMessage.text_message.text;
message.payload.type = 'message';
}
resolve(message);
});
});
// extract location message
Sinch.in(function(message) {
const botMsg = message.originalMessage;
return new Promise(function(resolve) {
const contactMessage = botMsg.message != null ? botMsg.message.contact_message : null;
if (contactMessage != null && contactMessage.location_message != null) {
message.payload.content = {
latitude: contactMessage.location_message.coordinates != null ? contactMessage.location_message.coordinates.latitude : null,
longitude: contactMessage.location_message.coordinates != null ? contactMessage.location_message.coordinates.longitude : null,
label: contactMessage.location_message.label,
title: contactMessage.location_message.title
};
message.payload.type = 'location';
}
resolve(message);
});
});
// extract media (image, video, document) — content stays as the remote URL since the
// platform itself doesn't host media downloads
Sinch.in(function(message) {
const botMsg = message.originalMessage;
return new Promise(function(resolve) {
const contactMessage = botMsg.message != null ? botMsg.message.contact_message : null;
if (contactMessage == null) {
resolve(message);
return;
}
if (contactMessage.media_message != null) {
message.payload.url = contactMessage.media_message.url;
message.payload.content = contactMessage.media_message.url;
message.payload.type = 'photo';
} else if (contactMessage.media_card_message != null) {
message.payload.url = contactMessage.media_card_message.url;
message.payload.content = contactMessage.media_card_message.url;
message.payload.caption = contactMessage.media_card_message.title;
message.payload.type = 'photo';
}
resolve(message);
});
});
// send a plain text message via Conversation API
Sinch.out('message', async function(message) {
const options = this.getOptions();
const chatServer = this;
const param = params(message);
const body = {
app_id: options.appId,
recipient: { contact_id: message.payload.chatId },
message: {
text_message: { text: message.payload.content }
}
};
const channelPriorityOrder = param('channelPriorityOrder', null);
if (!_.isEmpty(channelPriorityOrder)) {
const channels = _.isArray(channelPriorityOrder)
? channelPriorityOrder
: String(channelPriorityOrder).split(',').map(s => s.trim()).filter(s => s !== '');
if (channels.length !== 0) {
body.channel_priority_order = channels;
}
}
const result = await sinchApiCall(options, '/messages:send', body);
chatServer.emit && chatServer.emit('debug', { topic: 'sinch.sent', payload: result });
return {
...message,
payload: {
...message.payload,
messageId: result.message_id
}
};
});
// send a location
Sinch.out('location', async function(message) {
const options = this.getOptions();
const body = {
app_id: options.appId,
recipient: { contact_id: message.payload.chatId },
message: {
location_message: {
title: message.payload.title || '',
label: message.payload.label || '',
coordinates: {
latitude: message.payload.content.latitude,
longitude: message.payload.content.longitude
}
}
}
};
const result = await sinchApiCall(options, '/messages:send', body);
return {
...message,
payload: { ...message.payload, messageId: result.message_id }
};
});
// send a media (photo / document) — Conversation API accepts a public URL in `media_message`
async function sendMedia(self, message, type) {
const options = self.getOptions();
const url = _.isString(message.payload.content) ? message.payload.content : message.payload.url;
if (_.isEmpty(url)) {
throw new Error(`Sinch ${type} requires a public URL in payload.content`);
}
const body = {
app_id: options.appId,
recipient: { contact_id: message.payload.chatId },
message: {
media_message: { url }
}
};
const result = await sinchApiCall(options, '/messages:send', body);
return {
...message,
payload: { ...message.payload, messageId: result.message_id }
};
}
Sinch.out('photo', async function(message) { return sendMedia(this, message, 'photo'); });
Sinch.out('video', async function(message) { return sendMedia(this, message, 'video'); });
Sinch.out('document', async function(message) { return sendMedia(this, message, 'document'); });
Sinch.out('audio', async function(message) { return sendMedia(this, message, 'audio'); });
Sinch.registerMessageType('message', 'Message', 'Send a plain text message');
Sinch.registerMessageType('photo', 'Photo', 'Send a photo via a public URL');
Sinch.registerMessageType('video', 'Video', 'Send a video via a public URL');
Sinch.registerMessageType('document', 'Document', 'Send a document via a public URL');
Sinch.registerMessageType('audio', 'Audio', 'Send an audio file via a public URL');
Sinch.registerMessageType('location', 'Location', 'Send a map location message');
Sinch.registerParam(
'channelPriorityOrder',
'string',
{
label: 'Channel priority order',
description: 'Comma-separated Conversation API channels in priority order (first one tried first). '
+ 'Valid channels: WHATSAPP, RCS, SMS, MMS, MESSENGER, VIBER, VIBERBM, KAKAOTALK, KAKAOTALKCHAT, '
+ 'LINE, WECHAT, TELEGRAM, INSTAGRAM, APPLEBC. Maps to Conversation API field channel_priority_order.',
placeholder: 'TELEGRAM,SMS'
}
);
module.exports = Sinch;