Skip to content

Commit

Permalink
Microsoft Teams (#353)
Browse files Browse the repository at this point in the history
* msteams

* trick to context out of scope

* send image

* send buttons

* implement buttons

* validate msteams config

* docs

* lint

* fix tests
  • Loading branch information
guidone committed Nov 28, 2019
1 parent 3e04986 commit a0e8440
Show file tree
Hide file tree
Showing 11 changed files with 1,639 additions and 336 deletions.
5 changes: 1 addition & 4 deletions .eslintrc
Expand Up @@ -14,9 +14,6 @@
"no-useless-escape": 0
},
"parserOptions":{
"ecmaVersion": 6,
"ecmaFeatures": {
"experimentalObjectRestSpread": true
}
"ecmaVersion": 2018
}
}
19 changes: 19 additions & 0 deletions __tests__/validators.js
Expand Up @@ -293,6 +293,25 @@ describe('Validators', function() {
assert.isNotNull(validators.platform.twilio(_.extend({}, base, { logfile: 42})));
});

it('validates a MSTeams configuration', function() {
var base = {
authorizedUsernames: null,
appId: '123456',
appPassword: '236472347623462376',
contextProvider: 'memory',
logfile: null
};

assert.isNull(validators.platform.msteams(base));
assert.isNotNull(validators.platform.twilio(_.extend({}, base, { appPassword: null})));
assert.isNotNull(validators.platform.twilio(_.extend({}, base, { accessappPasswordToken: ''})));
assert.isNotNull(validators.platform.twilio(_.extend({}, base, { appId: null})));
assert.isNotNull(validators.platform.twilio(_.extend({}, base, { appId: ''})));
assert.isNotNull(validators.platform.twilio(_.extend({}, base, { contextProvider: 'wrong_context'})));
assert.isNotNull(validators.platform.twilio(_.extend({}, base, { authorizedUsernames: 42})));
assert.isNotNull(validators.platform.twilio(_.extend({}, base, { logfile: 42})));
});

it('validates a Alexa configuration', function() {
var base = {
authorizedUsernames: null,
Expand Down
1 change: 1 addition & 0 deletions bin/generate-docs.js
Expand Up @@ -49,6 +49,7 @@ var mappings = {
'Alexa-Receiver-node.md': 'chatbot-alexa-receive.html|chatbot-alexa-node',
'Twilio-Receiver-node.md': 'chatbot-twilio-receive.html|chatbot-twilio-node',
'Routee-Receiver-node.md': 'chatbot-routee-receive.html|chatbot-routee-node',
'Microsoft-Teams-Receiver-node.md': 'chatbot-msteams-receive.html|chatbot-msteams-node',
'Viber-Receiver-node.md': 'chatbot-viber-receive.html|chatbot-viber-node',
'Switch-node.md': 'chatbot-rules.html',
'Inline-Query-node.md': 'chatbot-inline-query.html',
Expand Down
21 changes: 21 additions & 0 deletions lib/helpers/validators.js
Expand Up @@ -77,6 +77,27 @@ var validators = {
if (config.logfile != null && !_.isString(config.logfile)) {
result.logfile = 'Must be a valid filename';
}
_.extend(result, validators.platform.contextProvider(config));

return _.keys(result).length === 0 ? null : result;
},
msteams: function(config) {
config = config || {};
var result = {};
if (!_.isString(config.appId) || _.isEmpty(config.appId)) {
result.token = 'Missing or invalid appId';
}
if (!_.isString(config.appPassword) || _.isEmpty(config.appPassword)) {
result.token = 'Missing or invalid account appPassword';
}
if (config.authorizedUsernames != null && !_.isString(config.authorizedUsernames)) {
result.authorizedUsernames = 'Must be a comma separated list of chatIds';
}
if (config.logfile != null && !_.isString(config.logfile)) {
result.logfile = 'Must be a valid filename';
}
_.extend(result, validators.platform.contextProvider(config));

return _.keys(result).length === 0 ? null : result;
},
viber: function(config) {
Expand Down
17 changes: 17 additions & 0 deletions lib/platforms/microsoft-teams/helpers/translate-button.js
@@ -0,0 +1,17 @@
module.exports = button => {
switch(button.type) {
case 'postback':
return {
type: 'postBack',
value: button.value,
text: button.value, // il valore che viene mandato
title: button.label // label del botton
};
case 'url':
return {
type: 'openUrl',
title: button.label,
value: button.url
};
}
};
183 changes: 183 additions & 0 deletions lib/platforms/microsoft-teams/index.js
@@ -0,0 +1,183 @@
const _ = require('underscore');
const moment = require('moment');
const { ChatExpress, ChatLog } = require('chat-platform');
const utils = require('../../helpers/utils');
const when = utils.when;

const translateButton = require('./helpers/translate-button');

/*
Useful links
Reference - https://docs.microsoft.com/en-us/javascript/api/botbuilder/index?view=botbuilder-ts-latest
*/

const {
//TurnContext,
MessageFactory,
//TeamsInfo,
//TeamsActivityHandler,
CardFactory,
//CardAction,
//ActionTypes,
BotFrameworkAdapter
} = require('botbuilder');

const MicrosoftTeams = new ChatExpress({
transport: 'msteams',
transportDescription: 'Microsoft Teams',
relaxChatId: true, // sometimes chatId is not necessary (for example inline_query_id)
chatIdKey: function(payload) {
const { activity } = payload;
return activity != null && activity.from != null ? activity.from.id : null;
},
userIdKey: function(payload) {
const { activity } = payload;
return activity != null && activity.recipient != null ? activity.recipient.id : null;
},
tsKey: function(payload) {
return moment.unix(payload.timestamp / 1000);
},
type: function() {
// todo remove this
},
onStart: function() {
const { appId, appPassword } = this.getOptions();
this.connector = new BotFrameworkAdapter({ appId, appPassword });
return true;
},
events: {},
routes: {
'/redbot/msteams/test': function(req, res) {
res.send('ok');
},
'/redbot/msteams': async function(req, res) {
/*
Hello Microsoft, we meet again.
This is an example of an API interface with all bells and whistels JavaScript can offer but that is actually a
pain in the ass for the devs. The canonical way of answering to a msteams bot is
processActivity(req, res, async context => {
await context.sendActivity(`Hello world!!!!!`);
})
the only way to send back a message is using this "context" (a fancy object with proxies) that is destroyed the moment
processActivity ends, this prevents sending any message outside the scope of this function.
This is the reason req and res are stored - inefficiently - then in the sender are passed again to processActivity in order
to get another context and be able to send the message (after all the computation of the nodes of Node-RED)
*/
const activity = await this.getActivity(req, res);
this.receive({
activity,
getResponse: () => [req, res]
});
}
},
routesDescription: {
'/redbot/msteams': 'Set this in the MSTeams manifest editor',
'/redbot/msteams/test': 'Use this to test that your SSL (with certificate or ngrok) is working properly, should answer "ok"'
}
});

MicrosoftTeams.mixin({
sendActivity(message, activity) {
const [req, res] = message.originalMessage.getResponse();
return new Promise((resolve, reject) => {
this.connector.processActivity(req, res, async (context) => {
try {
const result = await context.sendActivity(activity);
resolve(result);
} catch(e) {
reject(e);
}
});
});
},
getActivity(req, res) {
return new Promise(resolve => {
this.connector.processActivity(req, res, async context => resolve(context.activity));
});
}
});

MicrosoftTeams.in(function(message) {
const { originalMessage: { activity }} = message;
const chatContext = message.chat();

if (activity.type === 'message' && activity.text != null) {
message.payload.content = activity.text;
message.payload.type = 'message';
return when(chatContext.set('message', message.payload.content))
.then(() => message);
}

return message;
});

MicrosoftTeams.out('message', function(message) {
const context = message.chat();
return this.sendActivity(message, message.payload.content)
.then(result => context.set('messageId', result.id));
});

/*
Reference:
https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-add-media-attachments?view=azure-bot-service-4.0&tabs=javascript
*/
MicrosoftTeams.out('photo', function(message) {
const base64 = message.payload.content.toString('base64');
const activity = MessageFactory.contentUrl(
`data:image/png;base64,${base64}`,
message.payload.mimeType,
'',
message.payload.caption
);
return this.sendActivity(message, activity);
});

MicrosoftTeams.out('inline-buttons', function(message) {

// https://docs.microsoft.com/en-us/javascript/api/botframework-schema/cardaction?view=botbuilder-ts-latest
const card = CardFactory.heroCard(
message.payload.content,
// put transparent image
['data:image/png;base64;iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg=='],
message.payload.buttons.map(translateButton)
);
const activity = MessageFactory.attachment(card);

return this.sendActivity(message, activity);
});

// log messages, these should be the last
MicrosoftTeams.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;
});

MicrosoftTeams.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;
});

MicrosoftTeams.registerMessageType('message', 'Message', 'Send a plain text message');
MicrosoftTeams.registerMessageType('photo', 'Photo', 'Send a photo message');
MicrosoftTeams.registerMessageType('inline-buttons', 'Inline buttons', 'Send a message with inline buttons');

module.exports = MicrosoftTeams;
17 changes: 11 additions & 6 deletions nodes/chatbot-image.js
Expand Up @@ -25,18 +25,22 @@ module.exports = function(RED) {
this.caption = config.caption;
this.filename = config.filename; // for retrocompatibility

this.on('input', function(msg) {
const chatId = getChatId(msg);
const messageId = getMessageId(msg);
const transport = getTransport(msg);
const template = MessageTemplate(msg, node);

this.on('input', function(msg, send, done) {
// send/done compatibility for node-red < 1.0
send = send || function() { node.send.apply(node, arguments) };
done = done || function(error) { node.error.call(node, error, msg) };
// check if valid message
if (!isValidMessage(msg, node)) {
return;
}
// get config
const chatId = getChatId(msg);
const messageId = getMessageId(msg);
const transport = getTransport(msg);
const template = MessageTemplate(msg, node);
// check transport compatibility
if (!ChatExpress.isSupported(transport, 'photo')) {
done(`Node "photo" is not supported by ${transport} transport`);
return;
}

Expand Down Expand Up @@ -85,6 +89,7 @@ module.exports = function(RED) {
type: 'photo',
content: file.buffer,
filename: file.filename,
mimeType: file.mimeType,
caption: caption,
chatId: chatId,
messageId: messageId,
Expand Down

0 comments on commit a0e8440

Please sign in to comment.