├── index.js ├── .gitignore ├── .travis.yml ├── RELEASES.md ├── spec ├── support │ ├── jasmine.json │ └── jasmine-runner.js ├── helpers │ └── fake-https-request.js ├── slack │ ├── slack-reply-spec.js │ ├── slack-parse-spec.js │ └── slack-delayed-reply-spec.js ├── break-text-spec.js ├── alexa │ ├── alexa-reply-spec.js │ ├── alexa-parse-spec.js │ └── alexa-setup-spec.js ├── kik │ ├── kik-parse-spec.js │ ├── kik-reply-spec.js │ └── kik-setup-spec.js ├── is-url-spec.js ├── utils │ └── env-utils.js ├── line │ ├── line-parse-spec.js │ └── line-reply-spec.js ├── groupme │ ├── groupme-parse-spec.js │ ├── groupme-reply-spec.js │ └── groupme-setup-spec.js ├── skype │ ├── skype-parse-spec.js │ └── skype-token-spec.js ├── viber │ ├── viber-parse-spec.js │ ├── viber-reply-spec.js │ ├── viber-setup-spec.js │ └── viber-format-message-spec.js ├── telegram │ ├── telegram-parse-spec.js │ ├── telegram-reply-spec.js │ └── telegram-setup-spec.js ├── twilio │ ├── twilio-reply-spec.js │ ├── twilio-parse-spec.js │ └── twilio-setup-spec.js ├── bot-builder-spec.js └── facebook │ ├── facebook-parse-spec.js │ ├── facebook-integration-spec.js │ └── facebook-reply-spec.js ├── docs ├── GETTING_STARTED.md ├── SLACK_DELAYED_REPLY_BUILDER.md ├── SLACK_MESSAGE_MESSAGE_BUILDER.md └── API.md ├── lib ├── is-url.js ├── slack │ ├── reply.js │ ├── delayed-reply.js │ ├── parse.js │ ├── setup.js │ └── format-message.js ├── console-colors.js ├── utils │ └── env-utils.js ├── skype │ ├── parse.js │ ├── token.js │ ├── reply.js │ ├── setup.js │ └── format-message.js ├── groupme │ ├── parse.js │ ├── reply.js │ └── setup.js ├── viber │ ├── parse.js │ ├── reply.js │ ├── setup.js │ └── format-message.js ├── kik │ ├── parse.js │ ├── reply.js │ └── setup.js ├── line │ ├── parse.js │ ├── reply.js │ └── setup.js ├── alexa │ ├── reply.js │ ├── parse.js │ └── setup.js ├── facebook │ ├── validate-integrity.js │ ├── reply.js │ ├── parse.js │ └── setup.js ├── twilio │ ├── reply.js │ ├── parse.js │ └── setup.js ├── telegram │ ├── parse.js │ ├── setup.js │ ├── reply.js │ └── format-message.js ├── breaktext.js └── bot-builder.js ├── .eslintrc.json ├── ISSUE_TEMPLATE.md ├── LICENSE ├── package.json ├── CONTRIBUTING.md └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/bot-builder'); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | claudia.json 3 | .idea 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 4.3.2 4 | - 6.10.1 5 | 6 | -------------------------------------------------------------------------------- /RELEASES.md: -------------------------------------------------------------------------------- 1 | # Claudia Bot Builder release history 2 | 3 | For the history of releases visit [this link](https://github.com/claudiajs/claudia-bot-builder/releases). 4 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*[sS]pec.js" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.js" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /docs/GETTING_STARTED.md: -------------------------------------------------------------------------------- 1 | # Getting started with Claudia Bot Builder 2 | 3 | This page has moved to [https://claudiajs.com/tutorials/hello-world-chatbot.html](https://claudiajs.com/tutorials/hello-world-chatbot.html) 4 | -------------------------------------------------------------------------------- /lib/is-url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function isUrl(url) { 4 | const pattern = /^[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,63}\b(\/[-a-zA-Z0-9@:%_\(\)\+.,~#?&//=]*)?$/gi; 5 | return pattern.test(url); 6 | }; 7 | -------------------------------------------------------------------------------- /spec/helpers/fake-https-request.js: -------------------------------------------------------------------------------- 1 | /*global beforeEach, afterEach */ 2 | var fake = require('fake-http-request'); 3 | beforeEach(() => { 4 | fake.install('https'); 5 | }); 6 | afterEach(() => { 7 | fake.uninstall('https'); 8 | }); 9 | -------------------------------------------------------------------------------- /lib/slack/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function slackReply(botResponse) { 4 | if (typeof botResponse === 'string') 5 | return { 6 | text: botResponse 7 | }; 8 | 9 | return botResponse; 10 | }; 11 | -------------------------------------------------------------------------------- /lib/console-colors.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reset: '\x1b[0m', 3 | default: '\x1b[39m', 4 | dim: '\x1b[2m', 5 | blue: '\x1b[34m', 6 | cyan: '\x1b[36m', 7 | green: '\x1b[32m', 8 | magenta: '\x1b[35m', 9 | red: '\x1b[31m', 10 | yellow: '\x1b[33m' 11 | }; 12 | -------------------------------------------------------------------------------- /lib/utils/env-utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | encode(str) { 5 | return new Buffer(str).toString('base64').replace(/\+/g, '-'); 6 | }, 7 | decode(str) { 8 | return new Buffer(str.replace(/\-/g, '+'), 'base64').toString('utf8'); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /lib/skype/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(messageObject, contextId) { 4 | 5 | if (messageObject && typeof messageObject.text !== 'undefined') 6 | return { 7 | sender: messageObject.conversation.id, 8 | text: messageObject.text, 9 | originalRequest: messageObject, 10 | contextId: contextId, 11 | type: 'skype' 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /lib/groupme/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(messageObject) { 4 | if (messageObject && messageObject.text !== undefined && 5 | messageObject.group_id !== undefined && messageObject.sender_type !== 'bot'){ 6 | return { 7 | sender: messageObject.group_id, 8 | text: messageObject.text, 9 | originalRequest: messageObject, 10 | type: 'groupme' 11 | }; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /lib/viber/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(messageObject) { 4 | 5 | if (messageObject && messageObject.message){ 6 | return { 7 | sender: messageObject.sender.id, 8 | text: (typeof messageObject.message === 'object' && messageObject.message.type === 'text') ? messageObject.message.text : '', 9 | originalRequest: messageObject, 10 | type: 'viber' 11 | }; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /lib/kik/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(messageObject) { 4 | 5 | if (messageObject && messageObject.type == 'text' && messageObject.chatId){ 6 | return { 7 | sender: messageObject.from, 8 | text: messageObject.body, 9 | chatId: messageObject.chatId, 10 | kikType: messageObject.type, 11 | originalRequest: messageObject, 12 | type: 'kik' 13 | }; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "defaults", 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 6, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "semi": ["error", "always"], 13 | "no-console": "off", 14 | "indent": ["error", 2], 15 | "quotes": ["error", "single", {"avoidEscape": true, "allowTemplateLiterals": true}], 16 | "prefer-arrow-callback": "error" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/line/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(messageObject) { 4 | if (messageObject && messageObject.type && messageObject.replyToken && 5 | messageObject.source && messageObject.source.userId){ 6 | return { 7 | sender: messageObject.source.userId, 8 | replyToken: messageObject.replyToken, 9 | text: messageObject.type == 'message' ? messageObject.message.text : '', 10 | originalRequest: messageObject, 11 | type: 'line' 12 | }; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /lib/groupme/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const rp = require('minimal-request-promise'); 3 | 4 | function gmReply(message, botId) { 5 | var data = { 6 | bot_id: botId, 7 | text: typeof message === 'string' ? message : message.text 8 | }; 9 | 10 | const options = { 11 | headers: { 12 | 'content-type': 'application/json' 13 | }, 14 | body: JSON.stringify(data) 15 | }; 16 | 17 | return rp.post('https://api.groupme.com/v3/bots/post', options); 18 | } 19 | 20 | module.exports = gmReply; -------------------------------------------------------------------------------- /lib/alexa/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function alexaReply(botResponse, botName) { 4 | if (typeof botResponse === 'string' && botName) 5 | return { 6 | response: { 7 | outputSpeech: { 8 | type: 'PlainText', 9 | text: botResponse 10 | }, 11 | card: { 12 | type: 'Simple', 13 | title: botName || '', 14 | content: botResponse 15 | }, 16 | shouldEndSession: true 17 | } 18 | }; 19 | 20 | return botResponse; 21 | }; 22 | -------------------------------------------------------------------------------- /lib/facebook/validate-integrity.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | const tsscmp = require('tsscmp'); 5 | 6 | module.exports = function validateFbRequestIntegrity(request) { 7 | const xHubSignature = request.headers['X-Hub-Signature'] || request.headers['x-hub-signature']; 8 | const parsedXHubSignature = xHubSignature.split('='); 9 | const serverSignature = crypto.createHmac(parsedXHubSignature[0], request.env.facebookAppSecret).update(request.rawBody).digest('hex'); 10 | return tsscmp(parsedXHubSignature[1], serverSignature); 11 | }; 12 | -------------------------------------------------------------------------------- /lib/slack/delayed-reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const rp = require('minimal-request-promise'); 4 | const formatReply = require('./reply'); 5 | 6 | module.exports = function slackDelayedReply(message, response) { 7 | if (!message || !message.originalRequest || !message.originalRequest.response_url || !response) 8 | throw new Error('Original bot request and response are required'); 9 | 10 | return rp.post(message.originalRequest.response_url, { 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | }, 14 | body: JSON.stringify(formatReply(response)) 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /lib/slack/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(messageObject) { 4 | if (messageObject && messageObject.user_id) 5 | return { 6 | sender: messageObject.user_id, 7 | text: messageObject.text || '', 8 | originalRequest: messageObject, 9 | type: 'slack-slash-command' 10 | }; 11 | 12 | if (messageObject && messageObject.user && messageObject.actions) 13 | return { 14 | sender: messageObject.user.id, 15 | text: '', 16 | originalRequest: messageObject, 17 | type: 'slack-message-action', 18 | postback: true 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please use GitHub issues only to report bugs. To ask a general question or request assistance/support, please use the [Claudia.js Gitter Chat](https://gitter.im/claudiajs/claudia) instead. 2 | 3 | To report a bug or a problem, please fill in the sections below. The more you provide, the better we'll be able to help. 4 | 5 | --- 6 | 7 | * Expected behaviour: 8 | 9 | * What actually happens: 10 | 11 | * Which bot engine (facebook, skype...): 12 | 13 | * Link to a minimal, executable project that demonstrates the problem: 14 | 15 | * Steps to install the project: 16 | 17 | * Steps to reproduce the problem: 18 | 19 | -------------------------------------------------------------------------------- /spec/slack/slack-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect */ 2 | 'use strict'; 3 | var reply = require('../../lib/slack/reply'); 4 | 5 | describe('Slack Reply', () => { 6 | 7 | it('returns a formatted output if string is passed', () => 8 | expect(reply('string')).toEqual({ 9 | text: 'string' 10 | }) 11 | ); 12 | 13 | it('returns the same thing that was passed if argument is not a string', () => { 14 | expect(reply()).toBeUndefined(); 15 | expect(reply(123)).toBe(123); 16 | expect(reply([])).toEqual([]); 17 | expect(reply({})).toEqual({}); 18 | expect(reply({ key: 'value' })).toEqual({ key: 'value' }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /lib/kik/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const rp = require('minimal-request-promise'); 3 | 4 | function kikReply(messageObject, message, username, kikApiKey) { 5 | var data = { messages: [{ 6 | body: typeof message === 'string' ? message : message.text, 7 | to: messageObject.sender, 8 | type: messageObject.kikType, 9 | chatId: messageObject.chatId 10 | }]}; 11 | 12 | const options = { 13 | headers: { 14 | 'Authorization': `Basic ${new Buffer(username + ':' + kikApiKey).toString('base64')}`, 15 | 'content-type': 'application/json' 16 | }, 17 | body: JSON.stringify(data) 18 | }; 19 | 20 | return rp.post('https://api.kik.com/v1/message', options); 21 | } 22 | 23 | module.exports = kikReply; -------------------------------------------------------------------------------- /spec/break-text-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | var breakText = require('../lib/breaktext'); 3 | describe('breakText', () => { 4 | it('returns a single line if less than length', () => { 5 | expect(breakText('abc def', 10)).toEqual(['abc def']); 6 | expect(breakText('abc def', 7)).toEqual(['abc def']); 7 | }); 8 | it('breaks around max length', () => { 9 | expect(breakText('abc def', 5)).toEqual(['abc', 'def']); 10 | }); 11 | it('breaks words that are too long', () => { 12 | expect(breakText('abcdef 123456789', 5)).toEqual(['abcde', 'f', '1234', '56789']); 13 | }); 14 | it('does not explode on blank strings', () => { 15 | expect(breakText('', 5)).toEqual(['']); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /spec/support/jasmine-runner.js: -------------------------------------------------------------------------------- 1 | /*global jasmine, require, process*/ 2 | var Jasmine = require('jasmine'), 3 | SpecReporter = require('jasmine-spec-reporter'), 4 | noop = function () {}, 5 | jrunner = new Jasmine(), 6 | filter; 7 | process.argv.slice(2).forEach(option => { 8 | 'use strict'; 9 | if (option === 'full') { 10 | jrunner.configureDefaultReporter({print: noop}); // remove default reporter logs 11 | jasmine.getEnv().addReporter(new SpecReporter()); // add jasmine-spec-reporter 12 | } 13 | if (option.match('^filter=')) { 14 | filter = option.match('^filter=(.*)')[1]; 15 | } 16 | }); 17 | jrunner.loadConfigFile(); // load jasmine.json configuration 18 | jrunner.execute(undefined, filter); 19 | -------------------------------------------------------------------------------- /lib/twilio/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const rp = require('minimal-request-promise'); 3 | const qs = require('querystring'); 4 | 5 | function twilioReply(twilioAccountSid, twilioAuthToken, twilioSendingNumber, toNumber, message){ 6 | 7 | var data = qs.encode({ 8 | To: typeof message === 'string' ? toNumber : message.sender, 9 | From: twilioSendingNumber, 10 | Body: typeof message === 'string' ? message : message.text 11 | }); 12 | 13 | const options = { 14 | headers: { 15 | 'Authorization': `Basic ${new Buffer(twilioAccountSid + ':' + twilioAuthToken).toString('base64')}`, 16 | 'content-type': 'application/x-www-form-urlencoded', 17 | 'content-length': Buffer.byteLength(data) 18 | }, 19 | body: data 20 | }; 21 | 22 | return rp.post(`https://api.twilio.com/2010-04-01/Accounts/${twilioAccountSid}/Messages.json`, options); 23 | } 24 | 25 | 26 | module.exports = twilioReply; -------------------------------------------------------------------------------- /lib/alexa/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function getSlotValues(slots) { 4 | if (!slots) return ''; 5 | return Object.keys(slots).map(key => slots[key].value || '').join(' '); 6 | } 7 | 8 | module.exports = function alexaParse(messageObject) { 9 | if (messageObject && messageObject.request && messageObject.request.intent && messageObject.request.intent.name 10 | && messageObject.session && messageObject.session.user) { 11 | return { 12 | sender: messageObject.session.user.userId, 13 | text: getSlotValues(messageObject.request.intent.slots) || '', 14 | originalRequest: messageObject, 15 | type: 'alexa-skill' 16 | }; 17 | } 18 | 19 | if (messageObject && messageObject.session && messageObject.session.user) { 20 | return { 21 | sender: messageObject.session.user.userId, 22 | text: '', 23 | originalRequest: messageObject, 24 | type: 'alexa-skill' 25 | }; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /spec/alexa/alexa-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 'use strict'; 3 | var reply = require('../../lib/alexa/reply'); 4 | 5 | describe('Alexa Reply', () => { 6 | 7 | it('just returns the bot response when its not a string', () => { 8 | expect(reply()).toEqual(undefined); 9 | expect(reply(undefined, 'Claudia Alexa Bot')).toEqual(undefined); 10 | expect(reply({ hello: 'alexa'}, 'Claudia Alexa Bot')).toEqual({ hello: 'alexa'}); 11 | }); 12 | 13 | it('just returns the proper Alexa response when its not a string', () => { 14 | expect(reply('hello', 'Claudia Alexa Bot')) 15 | .toEqual({ 16 | response: { 17 | outputSpeech: { 18 | type: 'PlainText', 19 | text: 'hello' 20 | }, 21 | card: { 22 | type: 'Simple', 23 | title: 'Claudia Alexa Bot', 24 | content: 'hello' 25 | }, 26 | shouldEndSession: true 27 | } 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Gojko Adzic, Alexander Simovic, Slobodan Stojanovic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /lib/twilio/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const qs = require('querystring'); 3 | 4 | // Get URLs to attached media in MMS. 5 | function createMediaArray(messageObject) { 6 | const media = []; 7 | for (let i = 0; i < messageObject.NumMedia; i++) { 8 | media.push({ 9 | contentType: messageObject[`MediaContentType${i}`], 10 | url: messageObject[`MediaUrl${i}`] 11 | }); 12 | } 13 | return media; 14 | } 15 | 16 | module.exports = function(messageObject) { 17 | messageObject = qs.parse(messageObject); 18 | if (!messageObject) return; 19 | const hasBody = typeof messageObject.Body !== 'undefined' 20 | && messageObject.Body.length > 0; 21 | const hasSender = typeof messageObject.From !== 'undefined' 22 | && messageObject.From.length > 0; 23 | const hasMedia = parseInt(messageObject.NumMedia || 0); 24 | if (hasSender && (hasBody || hasMedia)) { 25 | const o = { 26 | sender: messageObject.From, 27 | text: messageObject.Body, 28 | originalRequest: messageObject, 29 | type: 'twilio' 30 | }; 31 | if (hasMedia) { 32 | o.media = createMediaArray(messageObject); 33 | } 34 | return o; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /spec/kik/kik-parse-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 'use strict'; 3 | var parse = require('../../lib/kik/parse'); 4 | 5 | describe('Kik parse', () => { 6 | it('returns nothing if the format is invalid', () => { 7 | expect(parse('string')).toBeUndefined(); 8 | expect(parse()).toBeUndefined(); 9 | expect(parse(false)).toBeUndefined(); 10 | expect(parse(123)).toBeUndefined(); 11 | expect(parse({})).toBeUndefined(); 12 | expect(parse([1, 2, 3])).toBeUndefined(); 13 | }); 14 | it('returns false if the message chatId is missing', () => { 15 | expect(parse({from: 'someUser', body: '2342342fwefwsdf', type: 'text'})).toBeUndefined(); 16 | expect(parse({body: undefined})).toBeUndefined(); 17 | }); 18 | it('returns a parsed object with proper chatId and kikType when the chatId is present and kikType is text', () => { 19 | var msg = {from: 'firstUser', chatId: 123412312, body: 'Hello Kik', type: 'text'}; 20 | var contextId = '3sdfsdfsdf24'; 21 | expect(parse(msg, contextId)).toEqual({ sender: 'firstUser', text: 'Hello Kik', chatId: 123412312, kikType: 'text', originalRequest: msg, type: 'kik'}); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /spec/is-url-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | const isUrl = require('../lib/is-url'); 3 | describe('isUrl', () => { 4 | it('should be a function', () => { 5 | expect(typeof isUrl).toBe('function'); 6 | }); 7 | it('should return false for an invalid url', () => { 8 | expect(isUrl('')).toBeFalsy(); 9 | expect(isUrl('test')).toBeFalsy(); 10 | expect(isUrl('http://')).toBeFalsy(); 11 | expect(isUrl('http//google')).toBeFalsy(); 12 | }); 13 | it('should return true for a valid url', () => { 14 | expect(isUrl('http://claudiajs.com')).toBeTruthy(); 15 | expect(isUrl('https://claudiajs.com')).toBeTruthy(); 16 | expect(isUrl('https://www.claudiajs.com')).toBeTruthy(); 17 | expect(isUrl('https://github.com/claudiajs')).toBeTruthy(); 18 | expect(isUrl('https://github.com/claudiajs/claudia-bot-builder')).toBeTruthy(); 19 | expect(isUrl('https://www.google.com/#q=claudia,bot')).toBeTruthy(); 20 | }); 21 | it('should return false if url is in the sentence', () => { 22 | expect(isUrl('This is a valid url: http://google.com')).toBeFalsy(); 23 | expect(isUrl('http://google.com is an url')).toBeFalsy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /lib/telegram/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(messageObject) { 4 | 5 | if (messageObject && messageObject.inline_query && messageObject.inline_query.id){ 6 | var inlineQuery = messageObject.inline_query; 7 | return { 8 | sender: inlineQuery.from.id, 9 | text: inlineQuery.query, 10 | originalRequest: messageObject, 11 | type: 'telegram' 12 | }; 13 | } 14 | 15 | if (messageObject && messageObject.message && messageObject.message.chat && messageObject.message.chat.id ){ 16 | var message = messageObject.message; 17 | return { 18 | sender: message.chat.id, 19 | text: message.text || '', 20 | originalRequest: messageObject, 21 | type: 'telegram' 22 | }; 23 | } 24 | 25 | if (messageObject && messageObject.callback_query && messageObject.callback_query.message.chat && 26 | messageObject.callback_query.message.chat.id ){ 27 | var callback_query = messageObject.callback_query; 28 | return { 29 | sender: callback_query.message.chat.id, 30 | text: callback_query.data || '', 31 | originalRequest: messageObject, 32 | type: 'telegram' 33 | }; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /lib/skype/token.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const rp = require('minimal-request-promise'); 4 | const qs = require('querystring'); 5 | 6 | var token; 7 | 8 | function requestToken(appId, appSecret) { 9 | 10 | var data = qs.encode({ 11 | grant_type: 'client_credentials', 12 | client_id: appId, 13 | client_secret: appSecret, 14 | scope: 'https://api.botframework.com/.default' 15 | }); 16 | 17 | const options = { 18 | headers: { 19 | 'cache-control': 'no-cache', 20 | 'content-type': 'application/x-www-form-urlencoded', 21 | 'content-length': Buffer.byteLength(data) 22 | }, 23 | body: data 24 | }; 25 | 26 | return rp.post('https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token', options); 27 | } 28 | 29 | module.exports = { 30 | getToken (appId, appSecret){ 31 | if (!token){ 32 | return requestToken(appId, appSecret) 33 | .then(response => { 34 | var body = JSON.parse(response.body); 35 | token = body.access_token; 36 | return token; 37 | }); 38 | } 39 | return Promise.resolve(token); 40 | }, 41 | clearToken () { 42 | token = undefined; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /spec/utils/env-utils.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 3 | var envUtils = require('../lib/utils/env-utils'); 4 | 5 | describe('envUtils', () => { 6 | it('should be an object', () => { 7 | expect(typeof envUtils).toBe('object'); 8 | }); 9 | describe('encode', () => { 10 | it('should be a function', () => { 11 | expect(typeof envUtils.encode).toBe('function'); 12 | }); 13 | it('should encode a string', () => { 14 | expect(envUtils.encode('')).toBe(''); 15 | expect(envUtils.encode('a')).toBe('YQ=='); 16 | expect(envUtils.encode('Claudia Bot Builder')).toBe('Q2xhdWRpYSBCb3QgQnVpbGRlcg=='); 17 | expect(envUtils.encode(`abc123!?$*&()'-=@~`)).toBe('YWJjMTIzIT8kKiYoKSctPUB-'); 18 | }); 19 | }); 20 | describe('decode', () => { 21 | it('should be a function', () => { 22 | expect(typeof envUtils.decode).toBe('function'); 23 | }); 24 | it('should decode the string', () => { 25 | expect(envUtils.decode('')).toBe(''); 26 | expect(envUtils.decode('YQ==')).toBe('a'); 27 | expect(envUtils.decode('Q2xhdWRpYSBCb3QgQnVpbGRlcg==')).toBe('Claudia Bot Builder'); 28 | expect(envUtils.decode('YWJjMTIzIT8kKiYoKSctPUB-')).toBe(`abc123!?$*&()'-=@~`); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /spec/line/line-parse-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 'use strict'; 3 | const parse = require('../../lib/line/parse'); 4 | 5 | describe('LINE parse', () => { 6 | it('returns nothing if the format is invalid', () => { 7 | expect(parse('string')).toBeUndefined(); 8 | expect(parse()).toBeUndefined(); 9 | expect(parse(false)).toBeUndefined(); 10 | expect(parse(123)).toBeUndefined(); 11 | expect(parse({})).toBeUndefined(); 12 | expect(parse([1, 2, 3])).toBeUndefined(); 13 | }); 14 | it('returns nothing if the message type or source with userId is missing', () => { 15 | expect(parse({replyToken: 'someRandomToken', source: { userId: 'ola Line'}, message: {text: 'hello'}})).toBeUndefined(); 16 | expect(parse({type: 'message', replyToken: 'someRandomToken', source: {}, message: {text: 'hello'}})).toBeUndefined(); 17 | }); 18 | it('returns a parsed object with replyToken and type line when the replyToken is present and source and source user.id is text', () => { 19 | let msg = {type: 'message', replyToken: 'someRandomToken', source: { userId: 'ola Line'}, message: {text: 'hello'}}; 20 | expect(parse(msg)).toEqual({ sender: 'ola Line', replyToken: 'someRandomToken', text: 'hello', originalRequest: msg, type: 'line'}); 21 | }); 22 | }); -------------------------------------------------------------------------------- /lib/line/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const rp = require('minimal-request-promise'); 3 | 4 | function lineReply(replyToken, message, lineChannelAccessToken) { 5 | var messages = []; 6 | if (typeof message === 'string') { 7 | messages = [{ 8 | text: message, 9 | type: 'text' 10 | }]; 11 | } else if (Array.isArray(message)) { 12 | message.forEach(msg => { 13 | if (typeof msg === 'string') { 14 | let singleMessage = { 15 | text: msg, 16 | type: 'text' 17 | }; 18 | messages.push(singleMessage); 19 | } else { 20 | messages.push(msg); 21 | } 22 | }); 23 | } else { 24 | if(!message || !message.type) throw new Error('Your LINE message is required to have a type'); 25 | messages = [message]; 26 | } 27 | 28 | let data = { 29 | replyToken: replyToken, 30 | messages: messages 31 | }; 32 | 33 | 34 | const options = { 35 | headers: { 36 | 'Authorization': `Bearer ${lineChannelAccessToken}`, 37 | 'Content-Type': 'application/json; charset=utf-8', 38 | 'Content-Length': Buffer.byteLength(JSON.stringify(data), 'utf8') 39 | }, 40 | body: JSON.stringify(data) 41 | }; 42 | 43 | return rp.post('https://api.line.me/v2/bot/message/reply', options); 44 | } 45 | 46 | module.exports = lineReply; 47 | -------------------------------------------------------------------------------- /lib/skype/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const rp = require('minimal-request-promise'); 3 | const skBearerToken = require('./token'); 4 | const retry = require('oh-no-i-insist'); 5 | 6 | const retryTimeout = 500; 7 | const numRetries = 2; 8 | 9 | function sendReply(conversationId, message, authToken, apiBaseUri, activityId){ 10 | apiBaseUri = apiBaseUri.replace(/\/$/, ''); 11 | 12 | if (typeof message === 'string') 13 | message = { 14 | type: 'message', 15 | text: message 16 | }; 17 | 18 | const options = { 19 | headers: { 20 | 'Authorization': 'Bearer ' + authToken, 21 | 'Content-Type': 'application/json' 22 | }, 23 | body: JSON.stringify(message) 24 | }; 25 | 26 | return rp.post(`${apiBaseUri}/v3/conversations/${conversationId}/activities/${activityId}`, options); 27 | } 28 | 29 | module.exports = function skReply(skypeAppId, skypePrivateKey, conversationId, message, apiBaseUri, activityId) { 30 | return retry( 31 | () => { 32 | return skBearerToken.getToken(skypeAppId, skypePrivateKey) 33 | .then((token) => sendReply(conversationId, message, token, apiBaseUri, activityId)); 34 | }, 35 | retryTimeout, 36 | numRetries, 37 | error => error.statusCode === 401, // expired / invalid token error status code 38 | skBearerToken.clearToken 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /docs/SLACK_DELAYED_REPLY_BUILDER.md: -------------------------------------------------------------------------------- 1 | # Slack delayed/multiple reply 2 | 3 | Slack allows you to send a reply to a slash command up to 5 times in 30 minutes. 4 | _Claudia Bot Builder_, since 1.4.0, allows you to package and send delayed responses easily, using the `.slackDelayedReply` helper function. 5 | 6 | The function expects two arguments: 7 | 8 | ```js 9 | slackDelayedReply(message, response); 10 | ``` 11 | 12 | * `message` – `object`, the original message received by the bot builder for the primary request 13 | * `response` – `string` or `object`, the delayed response you want to send. 14 | 15 | The function returns a `Promise` that you can use to chain requests. 16 | 17 | The same rules as for normal responses apply, so if you send a string, it will be wrapped into a Slack response format. Send an object to avoid wrapping and use 18 | more advanced Slack features. You can also use the [Slack Message Builder](SLACK_MESSAGE_MESSAGE_BUILDER.md) to create more complex responses easily. 19 | 20 | Check out the [Delayed Responses to Slack Slash Commands](https://claudiajs.com/tutorials/slack-delayed-responses.html) tutorial for a detailed walk-through of how to use this feature, and a full working example in the [Example projects](https://github.com/claudiajs/example-projects/tree/master/slack-delayed-response) repository. 21 | -------------------------------------------------------------------------------- /lib/breaktext.js: -------------------------------------------------------------------------------- 1 | /*global module*/ 2 | var chunk = function (tx, callback) { 3 | var prev = 0, 4 | re = /\s/gi, 5 | chunk; 6 | while (re.exec(tx)) { 7 | chunk = tx.slice(prev, re.lastIndex - 1); 8 | callback(chunk); 9 | prev = re.lastIndex - 1; 10 | } 11 | if (prev < tx.length) { 12 | callback(tx.slice(prev)); 13 | } 14 | }; 15 | 16 | module.exports = function (text, limit) { 17 | 'use strict'; 18 | var lines = [], 19 | currentLine = [], 20 | currentLineLength = 0, 21 | closeLine = function () { 22 | if (currentLineLength) { 23 | lines.push(currentLine.join('').trim()); 24 | currentLine = []; 25 | currentLineLength = 0; 26 | } 27 | }, 28 | processChunk = function (chunk) { 29 | var index; 30 | if (currentLineLength + chunk.length > limit) { 31 | closeLine(); 32 | } 33 | if (chunk.length > limit) { 34 | for (index = 0; index < chunk.length; index += limit) { 35 | processChunk(chunk.slice(index, index + limit)); 36 | } 37 | } else { 38 | currentLineLength += chunk.length; 39 | currentLine.push(chunk); 40 | } 41 | }; 42 | 43 | if (text === '') { 44 | return ['']; 45 | } 46 | 47 | chunk(text, processChunk); 48 | closeLine(); 49 | 50 | return lines; 51 | }; 52 | -------------------------------------------------------------------------------- /spec/groupme/groupme-parse-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 'use strict'; 3 | var parse = require('../../lib/groupme/parse'); 4 | 5 | describe('GroupMe parse', () => { 6 | it('returns nothing if the format is invalid', () => { 7 | expect(parse('string')).toBeUndefined(); 8 | expect(parse()).toBeUndefined(); 9 | expect(parse(false)).toBeUndefined(); 10 | expect(parse(123)).toBeUndefined(); 11 | expect(parse({})).toBeUndefined(); 12 | expect(parse([1, 2, 3])).toBeUndefined(); 13 | }); 14 | it('returns undefined if the message text is missing', () => { 15 | expect(parse({sender_type: 'user', group_id: 1 })).toBeUndefined(); 16 | }); 17 | it('returns undefined if the message group_id is missing', () => { 18 | expect(parse({sender_type: 'user', text: 'hello groupme'})).toBeUndefined(); 19 | }); 20 | it('returns undefined if the message sender_type is a bot', () => { 21 | expect(parse({sender_type: 'bot', text: 'hello groupme', group_id: 1})).toBeUndefined(); 22 | }); 23 | it('returns a parsed object with proper sender and text when the text and group_id are present and sender_type is not a bot', () => { 24 | var msg = {group_id: 1, text: 'hello groupme', sender_type: 'user'}; 25 | expect(parse(msg)).toEqual({ sender: 1, text: 'hello groupme', originalRequest: msg, type: 'groupme'}); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /lib/viber/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const rp = require('minimal-request-promise'); 3 | 4 | function sendSingle(receiver, authToken, messageObj) { 5 | let message; 6 | 7 | if (typeof messageObj === 'string') { 8 | message = { 9 | type: 'text', 10 | auth_token: authToken, 11 | text: messageObj, 12 | receiver: receiver 13 | }; 14 | } else { 15 | message = messageObj; 16 | if (!message.auth_token) 17 | message.auth_token = authToken; 18 | if (!message.receiver) 19 | message.receiver = receiver; 20 | } 21 | const body = JSON.stringify(message); 22 | const options = { 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | 'Content-Length': Buffer.byteLength(body, 'utf8') 26 | }, 27 | body: body 28 | }; 29 | 30 | return rp.post('https://chatapi.viber.com/pa/send_message', options); 31 | } 32 | 33 | function sendAll(receiver, authToken, messages) { 34 | if (!messages.length) { 35 | return Promise.resolve(); 36 | } else { 37 | return sendSingle(receiver, authToken, messages.shift()) 38 | .then(() => sendAll(receiver, authToken, messages)); 39 | } 40 | } 41 | 42 | function sendReply(receiver, messages, authToken) { 43 | if (!Array.isArray(messages)) 44 | messages = [messages]; 45 | 46 | return sendAll(receiver, authToken, messages); 47 | } 48 | 49 | module.exports = sendReply; 50 | -------------------------------------------------------------------------------- /spec/slack/slack-parse-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | var parse = require('../../lib/slack/parse'); 3 | 4 | describe('Slack parse', () => { 5 | it('returns nothing if the format is invalid', () => { 6 | expect(parse('string')).toBeUndefined(); 7 | expect(parse()).toBeUndefined(); 8 | expect(parse(false)).toBeUndefined(); 9 | expect(parse(123)).toBeUndefined(); 10 | expect(parse({})).toBeUndefined(); 11 | expect(parse([1, 2, 3])).toBeUndefined(); 12 | }); 13 | 14 | it('returns nothing if user_id and actions are missing', () => { 15 | expect(parse({text: 'pete'})).toBeUndefined(); 16 | }); 17 | 18 | it('returns an empty text if the text is missing', () => { 19 | var msg = { user_id: 123 }; 20 | expect(parse(msg)).toEqual({ sender: 123, text: '', originalRequest: msg, type: 'slack-slash-command'}); 21 | }); 22 | 23 | it('returns a parsed object when text and user_id are present', () => { 24 | var msg = { user_id: 123, text: 'Hello' }; 25 | expect(parse(msg)).toEqual({ sender: 123, text: 'Hello', originalRequest: msg, type: 'slack-slash-command'}); 26 | }); 27 | 28 | it('returns a parsed object when actions are present', () => { 29 | var msg = { user: { id: 123, name: 'username' }, actions: [{name: 'test', value: 'action'}] }; 30 | expect(parse(msg)).toEqual({ sender: 123, text: '', originalRequest: msg, type: 'slack-message-action', postback: true}); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /spec/skype/skype-parse-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 'use strict'; 3 | var parse = require('../../lib/skype/parse'); 4 | 5 | describe('Skype parse', () => { 6 | it('returns nothing if the format is invalid', () => { 7 | var contextId = '3sdfsdfsdf24'; 8 | expect(parse('string', contextId)).toBeUndefined(); 9 | expect(parse()).toBeUndefined(); 10 | expect(parse(false, contextId)).toBeUndefined(); 11 | expect(parse(123, contextId)).toBeUndefined(); 12 | expect(parse({}, contextId)).toBeUndefined(); 13 | expect(parse([1, 2, 3], contextId)).toBeUndefined(); 14 | }); 15 | it('returns false if the message content is missing', () => { 16 | expect(parse({from: {id: 111}, contextId: '2342342fwefwsdf'})).toBeUndefined(); 17 | expect(parse({text: undefined})).toBeUndefined(); 18 | }); 19 | it('returns a parsed object with proper contextId when the text is present and contextId is present', () => { 20 | var msg = {text:'Bonjour Skype', conversation: {id: 123412312}, id: 324234234}; 21 | var contextId = '3sdfsdfsdf24'; 22 | expect(parse(msg, contextId)).toEqual({ sender: 123412312, text: 'Bonjour Skype', originalRequest: msg, contextId: '3sdfsdfsdf24', type: 'skype'}); 23 | }); 24 | it('returns a parsed object with undefined contextId when the content is present and contextId is not', () => { 25 | var msg = {text:'Bonjour Skype', conversation: {id: 123412312}, id: 324234234}; 26 | expect(parse(msg)).toEqual({ sender: 123412312, text: 'Bonjour Skype', originalRequest: msg, contextId: undefined, type: 'skype'}); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "claudia-bot-builder", 3 | "version": "2.15.1", 4 | "description": "Create chat-bots for various platforms and deploy to AWS Lambda quickly", 5 | "main": "index.js", 6 | "scripts": { 7 | "pretest": "eslint lib spec *.js", 8 | "test": "node spec/support/jasmine-runner.js", 9 | "debug": "node debug spec/support/jasmine-runner.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/claudiajs/claudia-bot-builder" 14 | }, 15 | "keywords": [ 16 | "claudia", 17 | "aws", 18 | "lambda", 19 | "apigateway", 20 | "bot", 21 | "chatbot", 22 | "messenger", 23 | "telegram", 24 | "viber", 25 | "line", 26 | "alexa", 27 | "skype", 28 | "slack", 29 | "twilio", 30 | "kik", 31 | "groupme" 32 | ], 33 | "author": "Slobodan Stojanovic (http://slobodan.me/), ", 34 | "contributors": [ 35 | "Alexander Simovic (https://github.com/simalexan)", 36 | "Gojko Adzic (https://gojko.net)" 37 | ], 38 | "license": "MIT", 39 | "dependencies": { 40 | "alexa-message-builder": "^1.1.0", 41 | "claudia-api-builder": "^2.0.1", 42 | "minimal-request-promise": "^1.3.0", 43 | "oh-no-i-insist": "^1.0.0", 44 | "souffleur": "^1.0.0", 45 | "tsscmp": "^1.0.5" 46 | }, 47 | "devDependencies": { 48 | "claudia": "^2.0.0", 49 | "eslint": "^2.11.1", 50 | "eslint-config-defaults": "^9.0.0", 51 | "fake-http-request": "^1.3.0", 52 | "jasmine": "^2.5.2", 53 | "jasmine-spec-reporter": "^2.7.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /spec/slack/slack-delayed-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global jasmine, describe, it, expect */ 2 | 'use strict'; 3 | 4 | const slackDelayedReply = require('../../lib/slack/delayed-reply'); 5 | const https = require('https'); 6 | 7 | describe('Slack delayed reply', () => { 8 | it('should throw an error if message or response are not specified', () => { 9 | expect(() => slackDelayedReply()).toThrowError('Original bot request and response are required'); 10 | }); 11 | 12 | it('should send a text message', done => { 13 | https.request.pipe(callOptions => { 14 | expect(callOptions).toEqual(jasmine.objectContaining({ 15 | method: 'POST', 16 | hostname: 'some.fake', 17 | path: '/url', 18 | headers: { 19 | 'Content-Type': 'application/json' 20 | }, 21 | body: JSON.stringify({ 22 | text: 'Hello' 23 | }) 24 | })); 25 | done(); 26 | }); 27 | slackDelayedReply({ 28 | originalRequest: { 29 | response_url: 'https://some.fake/url' 30 | } 31 | }, 'Hello'); 32 | }); 33 | 34 | it('should send an object', done => { 35 | https.request.pipe(callOptions => { 36 | expect(callOptions).toEqual(jasmine.objectContaining({ 37 | method: 'POST', 38 | hostname: 'some.fake', 39 | path: '/url', 40 | headers: { 41 | 'Content-Type': 'application/json' 42 | }, 43 | body: JSON.stringify({ attachment: {} }) 44 | })); 45 | done(); 46 | }); 47 | slackDelayedReply({ 48 | originalRequest: { 49 | response_url: 'https://some.fake/url' 50 | } 51 | }, { attachment: {} }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/alexa/alexa-parse-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 'use strict'; 3 | 4 | var parse = require('../../lib/alexa/parse'); 5 | 6 | describe('Alexa parse', () => { 7 | it('should return nothing if the format is invalid', () => { 8 | expect(parse('string')).toBeUndefined(); 9 | expect(parse()).toBeUndefined(); 10 | expect(parse(false)).toBeUndefined(); 11 | expect(parse(123)).toBeUndefined(); 12 | expect(parse({})).toBeUndefined(); 13 | expect(parse([1, 2, 3])).toBeUndefined(); 14 | }); 15 | it('should return undefined if the session user is missing', () => { 16 | expect(parse({request: { intent: { name: 'intent 1'}}, session: {} })).toBeUndefined(); 17 | }); 18 | it('should return original request with an empty text if the intent is missing', () => { 19 | let msg = {request: {}, session: { user: { userId: 'claudia alexa user'} } }; 20 | expect(parse(msg)).toEqual({ sender: 'claudia alexa user', text: '', originalRequest: msg, type: 'alexa-skill'}); 21 | }); 22 | it('should return original request with an empty text if the intent name is missing', () => { 23 | let msg = {request: { intent: {}}, session: { user: { userId: 'claudia alexa user'} } }; 24 | expect(parse(msg)).toEqual({ sender: 'claudia alexa user', text: '', originalRequest: msg, type: 'alexa-skill'}); 25 | }); 26 | it('should return a parsed object with proper sender and text when the intent name and session user are present', () => { 27 | let msg = { 28 | request: { intent: { name: 'intent 1'}}, 29 | session: { user: { userId: 'claudia alexa user'}} }; 30 | expect(parse(msg)).toEqual({ sender: 'claudia alexa user', text: '', originalRequest: msg, type: 'alexa-skill'}); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /spec/groupme/groupme-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require, jasmine */ 2 | 'use strict'; 3 | var reply = require('../../lib/groupme/reply'), 4 | https = require('https'); 5 | 6 | describe('GroupMe Reply', () => { 7 | 8 | it('includes the Content type application/json in the header', done => { 9 | https.request.pipe(callOptions => { 10 | var data = { bot_id: 123123, text: 'hello groupme' }; 11 | expect(callOptions).toEqual(jasmine.objectContaining({ 12 | method: 'POST', 13 | hostname: 'api.groupme.com', 14 | path: '/v3/bots/post', 15 | headers: { 16 | 'content-type': 'application/json' 17 | }, 18 | body: JSON.stringify(data) 19 | })); 20 | done(); 21 | }); 22 | reply({ sender: 1, text: 'hello groupme', originalRequest: {}, type: 'groupme'}, 123123); 23 | }); 24 | 25 | it('sends messages as a string', done => { 26 | https.request.pipe(callOptions => { 27 | expect(callOptions.body).toEqual(JSON.stringify({ 28 | bot_id: 123123, 29 | text: 'hello groupme' 30 | })); 31 | done(); 32 | }); 33 | reply('hello groupme', 123123); 34 | }); 35 | 36 | it('does not resolve before the https endpoint responds', done => { 37 | https.request.pipe(done); 38 | reply({ sender: 1, text: 'hello groupme', originalRequest: {}, type: 'groupme'}, 123123 39 | ).then(done.fail, done.fail); 40 | }); 41 | 42 | it('resolves when the https endpoint responds with 200', done => { 43 | https.request.pipe(() => { 44 | setTimeout(() => { 45 | https.request.calls[0].respond('200', 'OK', 'Hello GroupMe'); 46 | }, 10); 47 | }); 48 | reply({ sender: 1, text: 'hello groupme', originalRequest: {}, type: 'groupme'}, 123123).then(done, done.fail); 49 | }); 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Developing and contributing to claudia-bot-builder 2 | 3 | ## Folder structure 4 | 5 | The main body of code is in the [lib](lib) directory. Each separate bot type should be in its own sub-directory, and the main [bot-builder.js](lib/bot-builder.js) class should only be used to set up wiring for the bots. 6 | 7 | The tests are in the [spec](spec) directory, and should follow the structure of the corresponding source files. All executable test file names should end with `-spec`, so they will be automatically picked up by `npm test`. Any additional project files, helper classes etc that must not be directly executed by the test runner should not end with `-spec`. You can use the [spec/helpers](spec/helpers) directory to store Jasmine helpers, that will be loaded before any test is executed. 8 | 9 | ## Running tests 10 | 11 | We use [Jasmine](https://jasmine.github.io/) for unit and integration tests. Unless there is a very compelling reason to use something different, please continue using Jasmine for tests. The existing tests are in the [spec](spec) folder. Here are some useful command shortcuts: 12 | 13 | Run all the tests: 14 | 15 | ```bash 16 | npm test 17 | ``` 18 | 19 | Run only some tests: 20 | 21 | ```bash 22 | npm test -- filter=prefix 23 | ``` 24 | 25 | Get detailed hierarchical test name reporting: 26 | 27 | ```bash 28 | npm test -- full 29 | ``` 30 | 31 | ## Before submitting a pull request 32 | 33 | AWS Lambda currently supports Node.js 4.3.2. Please run your tests using that version before submitting a pull request to ensure compatibility. 34 | 35 | We use [ESLint](http://eslint.org/) for syntax consistency, and the linting rules are included in this repository. Running `npm test` will check the linting rules as well. Please make sure your code has no linting errors before submitting a pull request. 36 | 37 | 38 | -------------------------------------------------------------------------------- /spec/viber/viber-parse-spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, expect, require */ 2 | 'use strict'; 3 | var parse = require('../../lib/viber/parse'); 4 | 5 | describe('Viber parse', () => { 6 | it('should not return anything if the format is invalid', () => { 7 | expect(parse('Just a string')).toBeUndefined(); 8 | expect(parse()).toBeUndefined(); 9 | expect(parse(false)).toBeUndefined(); 10 | expect(parse(123)).toBeUndefined(); 11 | expect(parse({})).toBeUndefined(); 12 | expect(parse([1, 2, 3])).toBeUndefined(); 13 | }); 14 | it('should not return anything if message is missing', () => { 15 | expect(parse({sender: { id: 1 }})).toBeUndefined(); 16 | }); 17 | it('should return a parsed object if message is in correct format', () => { 18 | var msg = { 19 | event: 'message', 20 | timestamp: new Date().getTime(), 21 | message_token: 123, 22 | sender: { 23 | id: 'ABC', 24 | name: 'Claudia', 25 | avatar: 'https://claudiajs.com/assets/claudiajs.png' 26 | }, 27 | message: { 28 | text: 'Hello', 29 | type: 'text' 30 | } 31 | }; 32 | expect(parse(msg)).toEqual({ 33 | sender: msg.sender.id, 34 | text: msg.message.text, 35 | originalRequest: msg, 36 | type: 'viber' 37 | }); 38 | }); 39 | it('should return a parsed object with an empty text if message is not textual', () => { 40 | var msg = { 41 | event: 'message', 42 | timestamp: new Date().getTime(), 43 | message_token: 123, 44 | sender: { 45 | id: 'ABC', 46 | name: 'Claudia', 47 | avatar: 'https://claudiajs.com/assets/claudiajs.png' 48 | }, 49 | message: { 50 | type: 'image' 51 | } 52 | }; 53 | expect(parse(msg)).toEqual({ 54 | sender: msg.sender.id, 55 | text: '', 56 | originalRequest: msg, 57 | type: 'viber' 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /lib/alexa/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const prompt = require('souffleur'); 4 | const alexaParse = require('./parse'); 5 | const alexaReply = require('./reply'); 6 | const color = require('../console-colors'); 7 | const envUtils = require('../utils/env-utils'); 8 | 9 | module.exports = function alexaSetup(api, bot, logError, optionalParser, optionalResponder) { 10 | let parser = optionalParser || alexaParse; 11 | let responder = optionalResponder || alexaReply; 12 | 13 | api.post('/alexa', request => { 14 | return bot(parser(request.body), request) 15 | .then(botReply => responder(botReply, envUtils.decode(request.env.alexaAppName))) 16 | .catch(logError); 17 | }); 18 | 19 | api.addPostDeployStep('alexa', (options, lambdaDetails, utils) => { 20 | return Promise.resolve().then(() => { 21 | if (options['configure-alexa-skill']) { 22 | console.log(`\n\n${color.green}Alexa skill command setup${color.reset}\n`); 23 | console.log(`\nConfigure your Alexa Skill endpoint to HTTPS and set this URL:.\n`); 24 | console.log(`\n${color.cyan}${lambdaDetails.apiUrl}/alexa${color.reset}\n`); 25 | console.log(`\nIn the SSL Certificate step, select "${color.dim}My development endpoint is a sub-domain of a domain that has a wildcard certificate from a certificate authority${color.reset}".\n`); 26 | 27 | return prompt(['Alexa bot name']) 28 | .then(results => { 29 | const deployment = { 30 | restApiId: lambdaDetails.apiId, 31 | stageName: lambdaDetails.alias, 32 | variables: { 33 | alexaAppName: envUtils.encode(results['Alexa bot name']) 34 | } 35 | }; 36 | 37 | console.log(`\n`); 38 | 39 | return utils.apiGatewayPromise.createDeploymentPromise(deployment); 40 | }); 41 | } 42 | }) 43 | .then(() => `${lambdaDetails.apiUrl}/alexa`); 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /spec/telegram/telegram-parse-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 'use strict'; 3 | var parse = require('../../lib/telegram/parse'); 4 | 5 | describe('Telegram parse', () => { 6 | it('returns nothing if the format is invalid', () => { 7 | expect(parse('string')).toBeUndefined(); 8 | expect(parse()).toBeUndefined(); 9 | expect(parse(false)).toBeUndefined(); 10 | expect(parse(123)).toBeUndefined(); 11 | expect(parse({})).toBeUndefined(); 12 | expect(parse([1, 2, 3])).toBeUndefined(); 13 | }); 14 | it('returns false the chat id are missing', () => { 15 | expect(parse({message: {chat: 'some123ChatId', text: 'ello Telegram'}})).toBeUndefined(); 16 | expect(parse({message: {text: 'pete'}})).toBeUndefined(); 17 | }); 18 | it('returns a parsed object when chat id is present', () => { 19 | var msg = {message: {chat: {id: 'some123ChatId'}, text: 'ello Telegram' }}; 20 | expect(parse(msg)).toEqual({ sender: 'some123ChatId', text: 'ello Telegram', originalRequest: msg, type: 'telegram'}); 21 | }); 22 | it('returns a parsed object when messageObject contains a callback_query', () => { 23 | var msg = {callback_query: {message: {chat: {id: 'some123ChatId'}},data: 'someCallbackData'}}; 24 | expect(parse(msg)).toEqual({ sender: 'some123ChatId', text: 'someCallbackData', originalRequest: msg, type: 'telegram'}); 25 | }); 26 | it('sender field should be equal to actual user_id', () => { 27 | var msg = { 28 | update_id: 920742096, 29 | inline_query: { 30 | id: '512944664604439953', 31 | from: { 32 | id: 119429236, 33 | first_name: 'Sergey', 34 | last_name: 'Tverskikh', 35 | username: 'tverskih' 36 | }, 37 | query: 'share', 38 | offset: '' 39 | } 40 | }; 41 | expect(parse(msg)).toEqual({ 42 | sender: 119429236, 43 | text: 'share', 44 | originalRequest: msg, 45 | type: 'telegram' 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /spec/skype/skype-token-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require, jasmine */ 2 | 'use strict'; 3 | var token = require('../../lib/skype/token'), 4 | https = require('https'), 5 | qs = require('querystring'); 6 | 7 | describe('Skype Token', () => { 8 | 9 | describe('getting the access token makes a request if the token is undefined', () => { 10 | it('includes the Skype AppId and Skype App Secret in the request body with the proper content length', done => { 11 | var credentialsData = qs.encode({ 12 | grant_type: 'client_credentials', 13 | client_id: 'someSkypeAppId123', 14 | client_secret: 'someSkypePrivateKey123', 15 | scope: 'https://api.botframework.com/.default' 16 | }); 17 | 18 | https.request.pipe(callOptions => { 19 | expect(callOptions).toEqual(jasmine.objectContaining({ 20 | method: 'POST', 21 | hostname: 'login.microsoftonline.com', 22 | path: '/botframework.com/oauth2/v2.0/token', 23 | headers: { 24 | 'cache-control': 'no-cache', 25 | 'content-type': 'application/x-www-form-urlencoded', 26 | 'content-length': Buffer.byteLength(credentialsData) 27 | }, 28 | body: credentialsData 29 | })); 30 | 31 | done(); 32 | }); 33 | token.getToken('someSkypeAppId123', 'someSkypePrivateKey123'); 34 | }); 35 | 36 | it('does not resolve before the https endpoint responds', done => { 37 | https.request.pipe(done); 38 | token.getToken('someSkypeAppId123', 'someSkypePrivateKey123').then(done.fail, done.fail); 39 | }); 40 | 41 | it('resolves when the https endpoint responds with 200', done => { 42 | https.request.pipe(() => { 43 | setTimeout(() => { 44 | https.request.calls[0].respond('200', 'OK', '{"access_token":"someAccessToken123"}'); 45 | }, 10); 46 | }); 47 | token.getToken('someSkypeAppId123', 'someSkypePrivateKey123').then(done, done.fail); 48 | }); 49 | }); 50 | 51 | }); -------------------------------------------------------------------------------- /lib/groupme/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const prompt = require('souffleur'); 4 | const gmReply = require('./reply'); 5 | const gmParse = require('./parse'); 6 | const color = require('../console-colors'); 7 | 8 | module.exports = function gmSetup(api, bot, logError, optionalParser, optionalResponder) { 9 | let parser = optionalParser || gmParse; 10 | let responder = optionalResponder || gmReply; 11 | 12 | api.post('/groupme', request => { 13 | let arr = request.body instanceof Array ? [].concat.apply([], request.body) : [request.body]; 14 | 15 | let gmHandle = parsedMessage => { 16 | if (!parsedMessage) return; 17 | return Promise.resolve(parsedMessage).then(parsedMessage => bot(parsedMessage, request)) 18 | .then(botResponse => responder(botResponse, request.env.GROUPME_BOT_ID)) 19 | .catch(logError); 20 | }; 21 | 22 | return Promise.all(arr.map(message => gmHandle(parser(message)))) 23 | .then(() => 'ok'); 24 | }); 25 | 26 | api.addPostDeployStep('groupme', (options, lambdaDetails, utils) => { 27 | return Promise.resolve() 28 | .then(() => { 29 | if (options['configure-groupme-bot']) { 30 | console.log(`\n\n${color.green}GroupMe setup${color.reset}\n`); 31 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 32 | console.log(`\nYour GroupMe bot Request URL (POST only) is ${color.cyan}${lambdaDetails.apiUrl}/groupme${color.reset}\n`); 33 | 34 | return prompt(['GroupMe Bot Id']) 35 | .then(results => { 36 | const deployment = { 37 | restApiId: lambdaDetails.apiId, 38 | stageName: lambdaDetails.alias, 39 | variables: { 40 | GROUPME_BOT_ID: results['GroupMe Bot Id'] 41 | } 42 | }; 43 | 44 | return utils.apiGatewayPromise.createDeploymentPromise(deployment); 45 | }); 46 | } 47 | }) 48 | .then(() => `${lambdaDetails.apiUrl}/groupme`); 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /lib/skype/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const prompt = require('souffleur'); 4 | const skReply = require('./reply'); 5 | const skParse = require('./parse'); 6 | const color = require('../console-colors'); 7 | 8 | module.exports = function skSetup(api, bot, logError, optionalParser, optionalResponder) { 9 | let parser = optionalParser || skParse; 10 | let responder = optionalResponder || skReply; 11 | 12 | 13 | api.post('/skype', request => { 14 | let arr = request.body instanceof Array ? [].concat.apply([], request.body) : [request.body]; 15 | 16 | let skHandle = parsedMessage => { 17 | if (!parsedMessage) return; 18 | return Promise.resolve(parsedMessage).then(parsedMessage => bot(parsedMessage, request)) 19 | .then(botResponse => responder(request.env.skypeAppId, request.env.skypePrivateKey, parsedMessage.sender, botResponse, parsedMessage.originalRequest.serviceUrl, parsedMessage.originalRequest.id)) 20 | .catch(logError); 21 | }; 22 | 23 | return Promise.all(arr.map(message => skHandle(parser(message)))) 24 | .then(() => 'ok'); 25 | }); 26 | 27 | api.addPostDeployStep('skype', (options, lambdaDetails, utils) => { 28 | return Promise.resolve().then(() => { 29 | if (options['configure-skype-bot']) { 30 | console.log(`\n\n${color.green}Skype setup${color.reset}\n`); 31 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 32 | 33 | return prompt(['Skype App ID', 'Skype Private key']) 34 | .then(results => { 35 | const deployment = { 36 | restApiId: lambdaDetails.apiId, 37 | stageName: lambdaDetails.alias, 38 | variables: { 39 | skypeAppId: results['Skype App ID'], 40 | skypePrivateKey: results['Skype Private key'] 41 | } 42 | }; 43 | 44 | return utils.apiGatewayPromise.createDeploymentPromise(deployment); 45 | }); 46 | } 47 | }) 48 | .then(() => `${lambdaDetails.apiUrl}/skype`); 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /lib/facebook/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const rp = require('minimal-request-promise'), 4 | breakText = require('../breaktext'); 5 | 6 | module.exports = function fbReply(recipient, message, fbAccessToken) { 7 | var sendSingle = function sendSingle (message) { 8 | if (typeof message === 'object' && typeof message.claudiaPause === 'number') { 9 | return new Promise(resolve => setTimeout(resolve, parseInt(message.claudiaPause, 10))); 10 | } 11 | const messageBody = { 12 | recipient: { 13 | id: recipient 14 | } 15 | }; 16 | if (message.hasOwnProperty('notification_type')) { 17 | messageBody.notification_type = message.notification_type; 18 | delete message.notification_type; 19 | } 20 | if (message.hasOwnProperty('sender_action')) { 21 | messageBody.sender_action = message.sender_action; 22 | } else { 23 | messageBody.message = message; 24 | } 25 | const options = { 26 | headers: { 27 | 'Content-Type': 'application/json' 28 | }, 29 | body: JSON.stringify(messageBody) 30 | }; 31 | return rp.post(`https://graph.facebook.com/v2.6/me/messages?access_token=${fbAccessToken}`, options); 32 | }, 33 | sendAll = function () { 34 | if (!messages.length) { 35 | return Promise.resolve(); 36 | } else { 37 | return sendSingle(messages.shift()).then(sendAll); 38 | } 39 | }, 40 | messages = []; 41 | 42 | function breakTextAndReturnFormatted(message) { 43 | return breakText(message, 640).map(m => ({ text: m })); 44 | } 45 | 46 | if (typeof message === 'string') { 47 | messages = breakTextAndReturnFormatted(message); 48 | } else if (Array.isArray(message)) { 49 | message.forEach(msg => { 50 | if (typeof msg === 'string') { 51 | messages = messages.concat(breakTextAndReturnFormatted(msg)); 52 | } else { 53 | messages.push(msg); 54 | } 55 | }); 56 | } else if (!message) { 57 | return Promise.resolve(); 58 | } else { 59 | messages = [message]; 60 | } 61 | return sendAll(); 62 | }; 63 | -------------------------------------------------------------------------------- /lib/line/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const prompt = require('souffleur'); 4 | const lnReply = require('./reply'); 5 | const lnParse = require('./parse'); 6 | const color = require('../console-colors'); 7 | 8 | module.exports = function lnSetup(api, bot, logError, optionalParser, optionalResponder) { 9 | let parser = optionalParser || lnParse; 10 | let responder = optionalResponder || lnReply; 11 | 12 | api.post('/line', request => { 13 | let arr = [].concat.apply([], request.body.events); 14 | let lnHandle = parsedMessage => { 15 | if (parsedMessage) { 16 | let replyToken = parsedMessage.replyToken; 17 | 18 | return Promise.resolve(parsedMessage).then(parsedMessage => bot(parsedMessage, request)) 19 | .then(botResponse => responder(replyToken, botResponse, new Buffer(request.env.lineChannelAccessToken, 'base64').toString('ascii'))) 20 | .catch(logError); 21 | } 22 | }; 23 | 24 | return Promise.all(arr.map(message => lnHandle(parser(message)))) 25 | .then(() => 'ok'); 26 | }); 27 | 28 | api.addPostDeployStep('line', (options, lambdaDetails, utils) => { 29 | return Promise.resolve() 30 | .then(() => { 31 | if (options['configure-line-bot']) { 32 | console.log(`\n\n${color.green}Line setup${color.reset}\n`); 33 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 34 | console.log(`\nYour LINE bot Request URL (POST only) is ${color.cyan}${lambdaDetails.apiUrl}/line${color.reset}\n`); 35 | 36 | return prompt(['LINE Channel Access Token']) 37 | .then(results => { 38 | const deployment = { 39 | restApiId: lambdaDetails.apiId, 40 | stageName: lambdaDetails.alias, 41 | variables: { 42 | lineChannelAccessToken: new Buffer(results['LINE Channel Access Token']).toString('base64') 43 | } 44 | }; 45 | console.log(`\n`); 46 | 47 | return utils.apiGatewayPromise.createDeploymentPromise(deployment); 48 | }); 49 | } 50 | }) 51 | .then(() => `${lambdaDetails.apiUrl}/line`); 52 | }); 53 | }; 54 | -------------------------------------------------------------------------------- /spec/line/line-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require, jasmine */ 2 | 'use strict'; 3 | const reply = require('../../lib/line/reply'), 4 | https = require('https'); 5 | 6 | describe('Line Reply', () => { 7 | 8 | it('includes the Line Authorization and Content type application/json in the header', done => { 9 | https.request.pipe(callOptions => { 10 | let lineChannelAccessToken = 'LineRandomAccessToken'; 11 | let data = {replyToken: 'randomLineToken', messages: [{type: 'message', text: 'hello Line'}]}; 12 | expect(callOptions).toEqual(jasmine.objectContaining({ 13 | method: 'POST', 14 | hostname: 'api.line.me', 15 | path: '/v2/bot/message/reply', 16 | headers: { 17 | 'Authorization': `Bearer ${lineChannelAccessToken}`, 18 | 'Content-Type': 'application/json; charset=utf-8', 19 | 'Content-Length': Buffer.byteLength(JSON.stringify(data), 'utf8') 20 | }, 21 | body: JSON.stringify(data) 22 | })); 23 | done(); 24 | }); 25 | reply('randomLineToken', {type: 'message', text: 'hello Line'}, 'LineRandomAccessToken'); 26 | }); 27 | 28 | it('sends messages as a string', done => { 29 | https.request.pipe(callOptions => { 30 | expect(callOptions.body).toEqual(JSON.stringify({ 31 | replyToken: 'randomLineToken', 32 | messages: [ 33 | { 34 | type: 'message', 35 | text: 'hello Line' 36 | } 37 | ]})); 38 | done(); 39 | }); 40 | reply('randomLineToken', {type: 'message', text: 'hello Line'}, 'LineRandomAccessToken'); 41 | }); 42 | 43 | it('does not resolve before the https endpoint responds', done => { 44 | https.request.pipe(done); 45 | reply('randomLineToken', {type: 'message', text: 'hello Line'}, 'LineRandomAccessToken') 46 | .then(done.fail, done.fail); 47 | }); 48 | 49 | it('resolves when the https endpoint responds with 200', done => { 50 | https.request.pipe(() => { 51 | setTimeout(() => { 52 | https.request.calls[0].respond('200', 'OK', 'hello Line'); 53 | }, 10); 54 | }); 55 | reply('randomLineToken', {type: 'message', text: 'hello Line'}, 'LineRandomAccessToken').then(done, done.fail); 56 | }); 57 | 58 | }); 59 | -------------------------------------------------------------------------------- /lib/facebook/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(messageObject) { 4 | messageObject = messageObject || {}; 5 | 6 | if (messageObject.sender && messageObject.sender.id && messageObject.message && messageObject.message.text && !messageObject.message.quick_reply && 7 | (typeof messageObject.delivery !== 'object' && typeof messageObject.read !== 'object' && (!messageObject.message || !messageObject.message.is_echo))) { // Disable delivery and read reports and message echos 8 | return { 9 | sender: messageObject.sender.id, 10 | text: messageObject.message.text, 11 | originalRequest: messageObject, 12 | type: 'facebook' 13 | }; 14 | } 15 | else if (messageObject.sender && messageObject.sender.id && messageObject.postback && messageObject.postback.payload) { 16 | return { 17 | sender: messageObject.sender.id, 18 | text: messageObject.postback.payload, 19 | originalRequest: messageObject, 20 | postback: true, 21 | type: 'facebook' 22 | }; 23 | } 24 | else if (messageObject.sender && messageObject.sender.id && messageObject.message && messageObject.message.quick_reply && messageObject.message.quick_reply.payload) { 25 | return { 26 | sender: messageObject.sender.id, 27 | text: messageObject.message.quick_reply.payload, 28 | originalRequest: messageObject, 29 | postback: true, 30 | type: 'facebook' 31 | }; 32 | } 33 | else if (messageObject.sender && messageObject.sender.id && 34 | (typeof messageObject.delivery !== 'object' && typeof messageObject.read !== 'object' && (!messageObject.message || !messageObject.message.is_echo))) { // Disable delivery and read reports and message echos 35 | return { 36 | sender: messageObject.sender.id, 37 | text: (messageObject.message && messageObject.message.text) ? messageObject.message.text : '', 38 | originalRequest: messageObject, 39 | type: 'facebook' 40 | }; 41 | } 42 | else if (messageObject.optin && messageObject.optin.ref && messageObject.optin.user_ref) { // Checkbox Plug-in 43 | return { 44 | sender: messageObject.optin.user_ref, 45 | text: '', 46 | ref: messageObject.optin.ref, 47 | originalRequest: messageObject, 48 | type: 'facebook' 49 | }; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /spec/kik/kik-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require, jasmine */ 2 | 'use strict'; 3 | var reply = require('../../lib/kik/reply'), 4 | https = require('https'); 5 | 6 | describe('Kik Reply', () => { 7 | 8 | it('includes the Kik Authorization and Content type application/json in the header', done => { 9 | https.request.pipe(callOptions => { 10 | var data = {messages: [{ body: 'hello Kik', to: 'randomKikUser', type: 'text', chatId: 123}]}; 11 | expect(callOptions).toEqual(jasmine.objectContaining({ 12 | method: 'POST', 13 | hostname: 'api.kik.com', 14 | path: '/v1/message', 15 | headers: { 16 | 'Authorization': `Basic ${new Buffer('someRandomKikUsername' + ':' + 'RandomKikApiKey').toString('base64')}`, 17 | 'content-type': 'application/json' 18 | }, 19 | body: JSON.stringify(data) 20 | })); 21 | done(); 22 | }); 23 | reply({sender: 'randomKikUser', kikType: 'text', chatId: 123}, 24 | {text: 'hello Kik'}, 'someRandomKikUsername', 'RandomKikApiKey'); 25 | }); 26 | 27 | it('sends messages as a string', done => { 28 | https.request.pipe(callOptions => { 29 | expect(callOptions.body).toEqual(JSON.stringify({ 30 | messages: [ 31 | { 32 | body: 'hello Kik', 33 | to: 'randomKikUser', 34 | type: 'text', 35 | chatId: 123 36 | } 37 | ]})); 38 | done(); 39 | }); 40 | reply({sender: 'randomKikUser', kikType: 'text', chatId: 123}, 41 | 'hello Kik', 'someRandomKikUsername', 'RandomKikApiKey'); 42 | }); 43 | 44 | it('does not resolve before the https endpoint responds', done => { 45 | https.request.pipe(done); 46 | reply({sender: 'randomKikUser', kikType: 'text', chatId: 123}, 47 | 'hello Kik', 'someRandomKikUsername', 'RandomKikApiKey' 48 | ).then(done.fail, done.fail); 49 | }); 50 | 51 | it('resolves when the https endpoint responds with 200', done => { 52 | https.request.pipe(() => { 53 | setTimeout(() => { 54 | https.request.calls[0].respond('200', 'OK', 'Hello Kik'); 55 | }, 10); 56 | }); 57 | reply({sender: 'randomKikUser', kikType: 'text', chatId: 123}, 58 | 'hello Kik', 'someRandomKikUsername', 'RandomKikApiKey').then(done, done.fail); 59 | }); 60 | 61 | }); 62 | -------------------------------------------------------------------------------- /lib/twilio/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const prompt = require('souffleur'); 4 | const twilioReply = require('./reply'); 5 | const twilioParse = require('./parse'); 6 | const color = require('../console-colors'); 7 | 8 | module.exports = function twilioSetup(api, bot, logError, optionalParser, optionalResponder) { 9 | let parser = optionalParser || twilioParse; 10 | let responder = optionalResponder || twilioReply; 11 | 12 | 13 | api.post('/twilio', request => { 14 | let arr = request.body instanceof Array ? [].concat.apply([], request.body) : [request.body]; 15 | 16 | let twilioHandle = parsedMessage => { 17 | if (!parsedMessage) return; 18 | return Promise.resolve(parsedMessage).then(parsedMessage => bot(parsedMessage, request)) 19 | .then(botResponse => responder(request.env.TWILIO_ACCOUNT_SID, request.env.TWILIO_AUTH_TOKEN, new Buffer(request.env.TWILIO_NUMBER, 'base64').toString('ascii'), parsedMessage.sender, botResponse)) 20 | .catch(logError); 21 | }; 22 | 23 | return Promise.all(arr.map(message => parser(message)).map(twilioHandle)) 24 | .then(() => ''); 25 | }, { success: { contentType: 'text/xml' }}); 26 | 27 | api.addPostDeployStep('twilio', (options, lambdaDetails, utils) => { 28 | return Promise.resolve().then(() => { 29 | if (options['configure-twilio-sms-bot']) { 30 | console.log(`\n\n${color.green}Twilio SMS setup${color.reset}\n`); 31 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 32 | 33 | return prompt(['Twilio Account ID', 'Twilio Auth Token', 'Twilio Number used for Sending']) 34 | .then(results => { 35 | const deployment = { 36 | restApiId: lambdaDetails.apiId, 37 | stageName: lambdaDetails.alias, 38 | variables: { 39 | TWILIO_ACCOUNT_SID: results['Twilio Account ID'], 40 | TWILIO_AUTH_TOKEN: results['Twilio Auth Token'], 41 | TWILIO_NUMBER: new Buffer(results['Twilio Number used for Sending']).toString('base64') 42 | } 43 | }; 44 | 45 | return utils.apiGatewayPromise.createDeploymentPromise(deployment); 46 | }); 47 | } 48 | }) 49 | .then(() => `${lambdaDetails.apiUrl}/twilio`); 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /spec/twilio/twilio-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require, jasmine */ 2 | 'use strict'; 3 | var reply = require('../../lib/twilio/reply'), 4 | qs = require('querystring'), 5 | https = require('https'); 6 | 7 | describe('Twilio Reply', () => { 8 | 9 | it('includes the Twilio Authorization and Content type x-www-form-urlencoded in the header', done => { 10 | https.request.pipe(callOptions => { 11 | var data = qs.encode({ To: '+4444444444', From: '+333333333', Body: 'SMS Twilio'}); 12 | expect(callOptions).toEqual(jasmine.objectContaining({ 13 | method: 'POST', 14 | hostname: 'api.twilio.com', 15 | path: '/2010-04-01/Accounts/someRandomTwilioAccountSID/Messages.json', 16 | headers: { 17 | 'Authorization': `Basic ${new Buffer('someRandomTwilioAccountSID' + ':' + 'RandomTwilioAuthToken').toString('base64')}`, 18 | 'content-type': 'application/x-www-form-urlencoded', 19 | 'content-length': Buffer.byteLength(data) 20 | }, 21 | body: data 22 | })); 23 | done(); 24 | }); 25 | reply('someRandomTwilioAccountSID', 'RandomTwilioAuthToken', 26 | '+333333333', '+4444444444', 'SMS Twilio'); 27 | }); 28 | 29 | it('sends text messages as a string', done => { 30 | https.request.pipe(callOptions => { 31 | expect(qs.parse(callOptions.body)).toEqual(jasmine.objectContaining({ 32 | To: '+4444444444', 33 | From: '+333333333', 34 | Body: 'SMS Twilio' 35 | })); 36 | done(); 37 | }); 38 | reply('someRandomTwilioAccountSID', 'RandomTwilioAuthToken', 39 | '+333333333', '+4444444444', 'SMS Twilio'); 40 | }); 41 | 42 | it('does not resolve before the https endpoint responds', done => { 43 | https.request.pipe(done); 44 | reply( 45 | 'someRandomTwilioAccountSID', 'RandomTwilioAuthToken', 46 | '+333333333', '+4444444444', 'SMS Twilio' 47 | ).then(done.fail, done.fail); 48 | }); 49 | 50 | it('resolves when the https endpoint responds with 200', done => { 51 | https.request.pipe(() => { 52 | setTimeout(() => { 53 | https.request.calls[0].respond('200', 'OK', 'SMS Twilio'); 54 | }, 10); 55 | }); 56 | reply( 57 | 'someRandomTwilioAccountSID', 'RandomTwilioAuthToken', 58 | '+333333333', '+4444444444', 'SMS Twilio' 59 | ).then(done, done.fail); 60 | }); 61 | 62 | }); 63 | -------------------------------------------------------------------------------- /lib/telegram/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const rp = require('minimal-request-promise'); 4 | const prompt = require('souffleur'); 5 | const tlReply = require('./reply'); 6 | const tlParse = require('./parse'); 7 | const color = require('../console-colors'); 8 | 9 | module.exports = function tlSetup(api, bot, logError, optionalParser, optionalResponder) { 10 | let parser = optionalParser || tlParse; 11 | let responder = optionalResponder || tlReply; 12 | 13 | api.post('/telegram', request => { 14 | let tlMessage = request.body; 15 | 16 | let parsedMessage = parser(tlMessage); 17 | if (!parsedMessage){ 18 | return Promise.resolve('ok'); 19 | } 20 | return Promise.resolve(parsedMessage).then(parsedMessage => bot(parsedMessage, request)) 21 | .then(botResponse => responder(parsedMessage, botResponse, request.env.telegramAccessToken)) 22 | .catch(logError); 23 | }); 24 | 25 | api.addPostDeployStep('telegram', (options, lambdaDetails, utils) => { 26 | return Promise.resolve() 27 | .then(() => { 28 | if (options['configure-telegram-bot']) { 29 | console.log(`\n\n${color.green}Telegram setup${color.reset}\n`); 30 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 31 | console.log(`\nYour Telegram bot Request URL (POST only) is ${color.cyan}${lambdaDetails.apiUrl}/telegram${color.reset}\n`); 32 | console.log(`\nIf you want your bot to receive inline queries\n just send /setinline to the @BotFather on your Telegram client and choose your bot\n`); 33 | 34 | return prompt(['Telegram Access Token']) 35 | .then(results => { 36 | const deployment = { 37 | restApiId: lambdaDetails.apiId, 38 | stageName: lambdaDetails.alias, 39 | variables: { 40 | telegramAccessToken: results['Telegram Access Token'] 41 | } 42 | }; 43 | 44 | return utils.apiGatewayPromise.createDeploymentPromise(deployment) 45 | .then(() => { 46 | let options = { 47 | headers: { 48 | 'Content-Type': 'application/json' 49 | }, 50 | body: JSON.stringify({ 51 | url: `${lambdaDetails.apiUrl}/telegram` 52 | }) 53 | }; 54 | return rp.post(`https://api.telegram.org/bot${deployment.variables.telegramAccessToken}/setWebhook`, options); 55 | }); 56 | }); 57 | } 58 | }) 59 | .then(() => `${lambdaDetails.apiUrl}/telegram`); 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /lib/viber/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const rp = require('minimal-request-promise'); 4 | const prompt = require('souffleur'); 5 | const vbReply = require('./reply'); 6 | const vbParse = require('./parse'); 7 | const color = require('../console-colors'); 8 | 9 | module.exports = function vbSetup(api, bot, logError, optionalParser, optionalResponder) { 10 | let parser = optionalParser || vbParse; 11 | let responder = optionalResponder || vbReply; 12 | 13 | api.post('/viber', request => { 14 | let vbMessage = request.body; 15 | 16 | let parsedMessage = parser(vbMessage); 17 | 18 | if (!parsedMessage){ 19 | return Promise.resolve('ok'); 20 | } 21 | return Promise.resolve(parsedMessage) 22 | .then(parsedMessage => bot(parsedMessage, request)) 23 | .then(botResponse => responder(parsedMessage.sender, botResponse, request.env.viberAccessToken)) 24 | .catch(logError); 25 | }); 26 | 27 | api.addPostDeployStep('viber', (options, lambdaDetails, utils) => { 28 | return Promise.resolve() 29 | .then(() => { 30 | if (options['configure-viber-bot']) { 31 | console.log(`\n\n${color.green}Viber setup${color.reset}\n`); 32 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 33 | console.log(`\nYour Viber bot Request URL (POST only) is ${color.cyan}${lambdaDetails.apiUrl}/viber${color.reset}\n`); 34 | 35 | return prompt(['Viber Access Token']) 36 | .then(results => { 37 | const deployment = { 38 | restApiId: lambdaDetails.apiId, 39 | stageName: lambdaDetails.alias, 40 | variables: { 41 | viberAccessToken: results['Viber Access Token'] 42 | } 43 | }; 44 | 45 | return utils.apiGatewayPromise.createDeploymentPromise(deployment) 46 | .then(() => { 47 | let body = JSON.stringify({ 48 | auth_token: deployment.variables.viberAccessToken, 49 | url: `${lambdaDetails.apiUrl}/viber`, 50 | event_types: ['delivered', 'seen'] 51 | }); 52 | let options = { 53 | headers: { 54 | 'Content-Type': 'application/json', 55 | 'Content-Length': Buffer.byteLength(body, 'utf8') 56 | }, 57 | body: body 58 | }; 59 | return rp.post(`https://chatapi.viber.com/pa/set_webhook`, options); 60 | }); 61 | }); 62 | } 63 | }) 64 | .then(() => `${lambdaDetails.apiUrl}/viber`); 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /lib/bot-builder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ApiBuilder = require('claudia-api-builder'); 4 | const fbSetup = require('./facebook/setup'); 5 | const slackSetup = require('./slack/setup'); 6 | const telegramSetup = require('./telegram/setup'); 7 | const skypeSetup = require('./skype/setup'); 8 | const twilioSetup = require('./twilio/setup'); 9 | const kikSetup = require('./kik/setup'); 10 | const groupmeSetup = require('./groupme/setup'); 11 | const lineSetup = require('./line/setup'); 12 | const viberSetup = require('./viber/setup'); 13 | const alexaSetup = require('./alexa/setup'); 14 | const fbTemplate = require('./facebook/format-message'); 15 | const slackTemplate = require('./slack/format-message'); 16 | const telegramTemplate = require('./telegram/format-message'); 17 | const viberTemplate = require('./viber/format-message'); 18 | const skypeTemplate = require('./skype/format-message'); 19 | const AlexaTemplate = require('alexa-message-builder'); 20 | const slackDelayedReply = require('./slack/delayed-reply'); 21 | 22 | let logError = function (err) { 23 | console.error(err); 24 | }; 25 | 26 | module.exports = function botBuilder(messageHandler, options, optionalLogError) { 27 | logError = optionalLogError || logError; 28 | 29 | const api = new ApiBuilder(), 30 | messageHandlerPromise = function (message, originalApiBuilderRequest) { 31 | return Promise.resolve(message).then(message => messageHandler(message, originalApiBuilderRequest)); 32 | }; 33 | 34 | api.get('/', () => 'Ok'); 35 | 36 | let isEnabled = function isEnabled(platform) { 37 | return !options || !options.platforms || options.platforms.indexOf(platform) > -1; 38 | }; 39 | 40 | if (isEnabled('facebook')) { 41 | fbSetup(api, messageHandlerPromise, logError); 42 | } 43 | if (isEnabled('slackSlashCommand')) { 44 | slackSetup(api, messageHandlerPromise, logError); 45 | } 46 | if (isEnabled('telegram')) { 47 | telegramSetup(api, messageHandlerPromise, logError); 48 | } 49 | if (isEnabled('skype')) { 50 | skypeSetup(api, messageHandlerPromise, logError); 51 | } 52 | if (isEnabled('twilio')) { 53 | twilioSetup(api, messageHandlerPromise, logError); 54 | } 55 | if (isEnabled('kik')) { 56 | kikSetup(api, messageHandlerPromise, logError); 57 | } 58 | if (isEnabled('groupme')) { 59 | groupmeSetup(api, messageHandlerPromise, logError); 60 | } 61 | if (isEnabled('line')) { 62 | lineSetup(api, messageHandlerPromise, logError); 63 | } 64 | if (isEnabled('viber')) { 65 | viberSetup(api, messageHandlerPromise, logError); 66 | } 67 | if (isEnabled('alexa')) { 68 | alexaSetup(api, messageHandlerPromise, logError); 69 | } 70 | 71 | return api; 72 | }; 73 | 74 | module.exports.fbTemplate = fbTemplate; 75 | module.exports.slackTemplate = slackTemplate; 76 | module.exports.telegramTemplate = telegramTemplate; 77 | module.exports.viberTemplate = viberTemplate; 78 | module.exports.skypeTemplate = skypeTemplate; 79 | module.exports.AlexaTemplate = AlexaTemplate; 80 | module.exports.slackDelayedReply = slackDelayedReply; 81 | -------------------------------------------------------------------------------- /lib/kik/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const rp = require('minimal-request-promise'); 4 | const prompt = require('souffleur'); 5 | const kReply = require('./reply'); 6 | const kParse = require('./parse'); 7 | const color = require('../console-colors'); 8 | 9 | module.exports = function kSetup(api, bot, logError, optionalParser, optionalResponder) { 10 | let parser = optionalParser || kParse; 11 | let responder = optionalResponder || kReply; 12 | 13 | api.post('/kik', request => { 14 | let arr = [].concat.apply([], request.body.messages); 15 | let kikHandle = parsedMessage => { 16 | if (parsedMessage){ 17 | return Promise.resolve(parsedMessage).then(parsedMessage => bot(parsedMessage, request)) 18 | .then(botResponse => responder(parsedMessage, botResponse, request.env.kikUserName, request.env.kikApiKey)) 19 | .catch(logError); 20 | } 21 | }; 22 | 23 | return Promise.all(arr.map(message => kikHandle(parser(message)))) 24 | .then(() => 'ok'); 25 | }); 26 | 27 | api.addPostDeployStep('kik', (options, lambdaDetails, utils) => { 28 | return Promise.resolve() 29 | .then(() => { 30 | if (options['configure-kik-bot']) { 31 | console.log(`\n\n${color.green}Kik setup${color.reset}\n`); 32 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 33 | console.log(`\nYour Kik bot Request URL (POST only) is ${color.cyan}${lambdaDetails.apiUrl}/kik${color.reset}\n`); 34 | 35 | return prompt(['Kik Bot Username', 'Kik Api Key']) 36 | .then(results => { 37 | const deployment = { 38 | restApiId: lambdaDetails.apiId, 39 | stageName: lambdaDetails.alias, 40 | variables: { 41 | kikUserName: results['Kik Bot Username'], 42 | kikApiKey: results['Kik Api Key'] 43 | } 44 | }; 45 | 46 | return utils.apiGatewayPromise.createDeploymentPromise(deployment) 47 | .then(() => { 48 | 49 | let options = { 50 | headers: { 51 | 'Content-Type': 'application/json', 52 | 'Authorization': `Basic ${new Buffer(deployment.variables.kikUserName + ':' + deployment.variables.kikApiKey).toString('base64')}` 53 | }, 54 | body: JSON.stringify({ 55 | webhook: `${lambdaDetails.apiUrl}/kik`, 56 | features: { 57 | receiveReadReceipts: false, 58 | receiveIsTyping: false, 59 | manuallySendReadReceipts: false, 60 | receiveDeliveryReceipts: false 61 | } 62 | }) 63 | }; 64 | return rp.post(`https://api.kik.com/v1/config`, options); 65 | }); 66 | }); 67 | } 68 | }) 69 | .then(() => `${lambdaDetails.apiUrl}/kik`); 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /spec/bot-builder-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, jasmine, expect, beforeEach*/ 2 | var botBuilder = require('../lib/bot-builder'); 3 | describe('BotBuilder', () => { 4 | var messageHandler, underTest, lambdaContextSpy; 5 | 6 | beforeEach(() => { 7 | messageHandler = jasmine.createSpy('messageHandler'); 8 | lambdaContextSpy = jasmine.createSpyObj('lambdaContext', ['done']); 9 | underTest = botBuilder(messageHandler); 10 | }); 11 | 12 | it('configures a Claudia Rest API', () => { 13 | expect(underTest.apiConfig().version).toEqual(3); 14 | }); 15 | 16 | it('sets up a GET route for /', (done) => { 17 | underTest.proxyRouter({ 18 | requestContext: { 19 | resourcePath: '/', 20 | httpMethod: 'GET' 21 | } 22 | }, lambdaContextSpy).then(() => { 23 | expect(lambdaContextSpy.done).toHaveBeenCalledWith(null, jasmine.objectContaining({body: '"Ok"'})); 24 | }).then(done, done.fail); 25 | }); 26 | [ 27 | { 28 | name: 'facebook', 29 | path: 'facebook', 30 | methods: ['GET', 'POST'] 31 | }, 32 | { 33 | name: 'slackSlashCommand', 34 | path: 'slack/slash-command', 35 | methods: ['GET', 'POST'] 36 | }, 37 | { 38 | name: 'telegram', 39 | path: 'telegram', 40 | methods: ['POST'] 41 | }, 42 | { 43 | name: 'skype', 44 | path: 'skype', 45 | methods: ['POST'] 46 | }, 47 | { 48 | name: 'twilio', 49 | path: 'twilio', 50 | methods: ['POST'] 51 | }, 52 | { 53 | name: 'kik', 54 | path: 'kik', 55 | methods: ['POST'] 56 | }, 57 | { 58 | name: 'groupme', 59 | path: 'groupme', 60 | methods: ['POST'] 61 | }, 62 | { 63 | name: 'viber', 64 | path: 'viber', 65 | methods: ['POST'] 66 | } 67 | ].forEach(platform => { 68 | describe('setting up ' + platform.name, () => { 69 | it('should setup the platform if the options are not provided', () => { 70 | underTest = botBuilder(messageHandler); 71 | expect(Object.keys(underTest.apiConfig().routes[platform.path])).toBeTruthy(); 72 | }); 73 | it('should setup the platform if the options are provided without platforms' + platform.name, () => { 74 | underTest = botBuilder(messageHandler, { plugins: [] }); 75 | expect(Object.keys(underTest.apiConfig().routes[platform.path])).toBeTruthy(); 76 | }); 77 | it('should setup the platform if the options are provided and platform is enabled' + platform.name, () => { 78 | var api = botBuilder(messageHandler, { platforms: [platform.name] }); 79 | expect(Object.keys(api.apiConfig().routes[platform.path])).toEqual(platform.methods); 80 | }); 81 | it('should not setup the platform if the options are provided and platform is disabled' + platform.name, () => { 82 | var options = { platforms: [] }; 83 | underTest = botBuilder(messageHandler, options); 84 | expect(underTest.apiConfig().routes[platform.path]).toBeFalsy(); 85 | }); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /spec/twilio/twilio-parse-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 'use strict'; 3 | var parse = require('../../lib/twilio/parse'); 4 | const qs = require('querystring'); 5 | 6 | describe('Twilio parse', () => { 7 | it('returns nothing if the format is invalid', () => { 8 | expect(parse(qs.stringify('string'))).toBeUndefined(); 9 | expect(parse()).toBeUndefined(); 10 | expect(parse(qs.stringify(false))).toBeUndefined(); 11 | expect(parse(qs.stringify(123))).toBeUndefined(); 12 | expect(parse(qs.stringify({}))).toBeUndefined(); 13 | expect(parse(qs.stringify([1, 2, 3]))).toBeUndefined(); 14 | }); 15 | it('returns nothing if the Body is undefined or missing', () => { 16 | expect(parse(qs.stringify({From: '+3333333333'}))).toBeUndefined(); 17 | expect(parse(qs.stringify({From: '+3333333333', Body: undefined}))).toBeUndefined(); 18 | }); 19 | it('returns nothing if the Body is undefined or missing and there are 0 media attachments', () => { 20 | expect(parse(qs.stringify({From: '+3333333333', NumMedia: '0'}))).toBeUndefined(); 21 | expect(parse(qs.stringify({From: '+3333333333', Body: undefined, NumMedia: '0'}))).toBeUndefined(); 22 | }); 23 | it('returns nothing if the From is undefined or missing', () => { 24 | expect(parse(qs.stringify({Body: 'SMS Twilio'}))).toBeUndefined(); 25 | expect(parse(qs.stringify({From: undefined, Body: 'SMS Twilio'}))).toBeUndefined(); 26 | }); 27 | it('returns a parsed object when Body and From are present', () => { 28 | var msg = qs.stringify({From: '+3333333333', Body: 'SMS Twilio'}); 29 | expect(parse(msg)).toEqual({ sender: '+3333333333', text: 'SMS Twilio', originalRequest: qs.parse(msg), type: 'twilio'}); 30 | }); 31 | it('returns a parsed object when From is present and MMS attachments exist', () => { 32 | var msg = qs.stringify({From: '+3333333333', NumMedia: '1', MediaContentType0: 'image/jpeg', MediaUrl0: 'https://api.twilio.com/test'}); 33 | expect(parse(msg)).toEqual({ 34 | sender: '+3333333333', 35 | text: undefined, 36 | originalRequest: qs.parse(msg), 37 | type: 'twilio', 38 | media: [{ contentType: 'image/jpeg', url: 'https://api.twilio.com/test' }] 39 | }); 40 | }); 41 | it('returns a parsed object where From is present and multiple MMS attachments exist', () => { 42 | var msg = qs.stringify({ 43 | From: '+3333333333', 44 | NumMedia: '2', 45 | MediaContentType0: 'image/jpeg', 46 | MediaContentType1: 'video/mp4', 47 | MediaUrl0: 'https://api.twilio.com/test0', 48 | MediaUrl1: 'https://api.twilio.com/test1' 49 | }); 50 | expect(parse(msg)).toEqual({ 51 | sender: '+3333333333', 52 | text: undefined, 53 | originalRequest: qs.parse(msg), 54 | type: 'twilio', 55 | media: [ 56 | { 57 | contentType: 'image/jpeg', 58 | url: 'https://api.twilio.com/test0' 59 | }, 60 | { 61 | contentType: 'video/mp4', 62 | url: 'https://api.twilio.com/test1' 63 | } 64 | ] 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /lib/telegram/reply.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const rp = require('minimal-request-promise'); 4 | const REQUEST_THROTTLE = 1000/30; 5 | const RETRIABLE_ERRORS = ['ECONNRESET', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'ETIMEDOUT', 'ECONNREFUSED', 'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN']; 6 | const NUMBER_OF_RETRIES = 20; 7 | 8 | module.exports = function tlReply(messageObject, message, tlAccessToken) { 9 | 10 | var sendHttpRequest = function sendApiRequest(tlAccessToken, method, options, numberOfRetries) { 11 | return rp.post(`https://api.telegram.org/bot${tlAccessToken}/${method}`, options) 12 | .catch(e => { 13 | let httpCode = parseInt(e.statusCode, 10); 14 | if (numberOfRetries-- > 0 && (RETRIABLE_ERRORS.indexOf(e.statusMessage) >= 0 || httpCode == 429 || httpCode == 420 || httpCode >= 500 && httpCode < 600)) { 15 | return sendHttpRequest(tlAccessToken, method, options, numberOfRetries); 16 | } 17 | throw e; 18 | }); 19 | }, 20 | sendSingle = function sendSingle (message) { 21 | var method, body; 22 | 23 | if (typeof message === 'object' && typeof message.claudiaPause === 'number') { 24 | return new Promise(resolve => setTimeout(resolve, parseInt(message.claudiaPause, 10))); 25 | } else if (typeof message !== 'string') { 26 | method = message.method ? message.method : messageObject.originalRequest.inline_query ? 27 | 'answerInlineQuery' : 'sendMessage'; 28 | body = message.body ? message.body : message; 29 | if (!body.chat_id) 30 | body.chat_id = messageObject.sender; 31 | } else if (messageObject.originalRequest.inline_query && typeof message === 'string') { 32 | method = 'answerInlineQuery'; 33 | body = { 34 | inline_query_id: messageObject.sender, 35 | results: [{ 36 | type: 'article', 37 | id: `${new Date().getTime()}`, 38 | title: messageObject.text, 39 | input_message_content: { 40 | message_text: messageObject.text 41 | } 42 | }] 43 | }; 44 | } else { 45 | method = 'sendMessage'; 46 | body = { 47 | chat_id: messageObject.sender, 48 | text: message 49 | }; 50 | } 51 | 52 | let numberOfRetries = NUMBER_OF_RETRIES; 53 | 54 | const options = { 55 | headers: { 56 | 'Content-Type': 'application/json' 57 | }, 58 | body: JSON.stringify(body) 59 | }; 60 | 61 | return sendHttpRequest(tlAccessToken, method, options, numberOfRetries); 62 | }, 63 | 64 | sendAll = function () { 65 | if (!messages.length) { 66 | return Promise.resolve(); 67 | } else { 68 | return sendSingle(messages.shift()) 69 | .then(() => { 70 | return new Promise((resolve) => { 71 | if (!messages.length) 72 | resolve(); 73 | else 74 | setTimeout(resolve, REQUEST_THROTTLE); 75 | }); 76 | }) 77 | .then(sendAll); 78 | } 79 | }, 80 | 81 | messages = Array.isArray(message) ? message : [message]; 82 | 83 | return sendAll(); 84 | }; 85 | -------------------------------------------------------------------------------- /spec/viber/viber-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require, jasmine, beforeEach */ 2 | var reply = require('../../lib/viber/reply'), 3 | https = require('https'); 4 | describe('Viber Reply', () => { 5 | 'use strict'; 6 | beforeEach(() =>{ 7 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; 8 | }); 9 | 10 | it('should include the token in the request', done => { 11 | https.request.pipe(callOptions => { 12 | expect(callOptions).toEqual(jasmine.objectContaining({ 13 | protocol: 'https:', 14 | hostname: 'chatapi.viber.com', 15 | path: '/pa/send_message', 16 | method: 'POST', 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | 'Content-Length': 79 20 | }, 21 | body: '{"type":"text","auth_token":"ACCESS123","text":"Hi there","receiver":"user123"}' 22 | })); 23 | done(); 24 | }); 25 | reply('user123', 'Hi there', 'ACCESS123'); 26 | }); 27 | it('should send a string message as a text object', done => { 28 | https.request.pipe(callOptions => { 29 | expect(JSON.parse(callOptions.body)).toEqual({ 30 | type: 'text', 31 | auth_token: 'ACCESS123', 32 | text: 'Hi there', 33 | receiver: 'user123' 34 | }); 35 | done(); 36 | }); 37 | reply('user123', 'Hi there', 'ACCESS123'); 38 | }); 39 | describe('when an array is passed', () => { 40 | it('should not send the second request until the first one completes', done => { 41 | let answers = ['foo', 'bar']; 42 | https.request.pipe(() => { 43 | Promise.resolve().then(() => { 44 | expect(https.request.calls.length).toEqual(1); 45 | }).then(done); 46 | }); 47 | reply('user123', answers, 'ACCESS123'); 48 | }); 49 | it('should send the requests in sequence', done => { 50 | let answers = ['foo', 'bar']; 51 | https.request.pipe(function () { 52 | this.respond('200', 'OK'); 53 | if (https.request.calls.length === 2) { 54 | expect(JSON.parse(https.request.calls[0].body[0])).toEqual({ 55 | type: 'text', 56 | auth_token: 'ACCESS123', 57 | text: 'foo', 58 | receiver: 'user123' 59 | }); 60 | expect(JSON.parse(https.request.calls[1].body[0])).toEqual({ 61 | type: 'text', 62 | auth_token: 'ACCESS123', 63 | text: 'bar', 64 | receiver: 'user123' 65 | }); 66 | done(); 67 | } 68 | }); 69 | reply('user123', answers, 'ACCESS123'); 70 | }); 71 | 72 | }); 73 | 74 | it('should send complex messages without transforming into a text object', done => { 75 | https.request.pipe(callOptions => { 76 | expect(JSON.parse(callOptions.body)).toEqual({ 77 | auth_token: 'ACCESS123', 78 | receiver: 'user123', 79 | tracking_data: 123, 80 | type: 'text', 81 | text: 'random text message' 82 | }); 83 | done(); 84 | }); 85 | reply('user123', { 86 | tracking_data: 123, 87 | type: 'text', 88 | text: 'random text message' 89 | }, 'ACCESS123'); 90 | }); 91 | it('should not resolve before the https endpoint responds', done => { 92 | https.request.pipe(done); 93 | reply('user123', {template: 'big', contents: { title: 'red'} }, 'ACCESS123').then(done.fail, done.fail); 94 | }); 95 | it('should resolve when the https endpoint responds with 200', done => { 96 | https.request.pipe(function () { 97 | this.respond('200', 'OK', 'Hi there'); 98 | }); 99 | reply('user123', {template: 'big', contents: { title: 'red'} }, 'ACCESS123').then(done, done.fail); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /spec/facebook/facebook-parse-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | var parse = require('../../lib/facebook/parse'); 3 | describe('Facebook parse', () => { 4 | it('returns nothing if the format is invalid', () => { 5 | expect(parse('string')).toBeUndefined(); 6 | expect(parse()).toBeUndefined(); 7 | expect(parse(false)).toBeUndefined(); 8 | expect(parse(123)).toBeUndefined(); 9 | expect(parse({})).toBeUndefined(); 10 | expect(parse([1, 2, 3])).toBeUndefined(); 11 | }); 12 | it('returns false if the message or sender are missing', () => { 13 | expect(parse({sender: 'tom'})).toBeUndefined(); 14 | expect(parse({message: 'pete'})).toBeUndefined(); 15 | }); 16 | it('returns a parsed object when there message and sender are present', () => { 17 | var msg = {sender: {id: 'tom'}, message: { text: 'Hello' }}; 18 | expect(parse(msg, {})).toEqual({ sender: 'tom', text: 'Hello', originalRequest: msg, type: 'facebook'}); 19 | }); 20 | it('returns a parsed object for postback messages', () => { 21 | var msg = { 22 | sender: { id: '12345' }, 23 | recipient: { id: '67890' }, 24 | timestamp: 1465558466933, 25 | postback: { payload: 'POSTBACK' } 26 | }; 27 | expect(parse(msg)).toEqual({ 28 | sender: '12345', 29 | text: 'POSTBACK', 30 | originalRequest: msg, 31 | postback: true, 32 | type: 'facebook' 33 | }); 34 | }); 35 | it('returns a parsed object for a quick reply', () => { 36 | var msg = { 37 | sender: { id: '12345' }, 38 | recipient: { id: '67890' }, 39 | timestamp: 1465558466933, 40 | message: { 41 | mid: 'mid.1464990849238:b9a22a2bcb1de31773', 42 | seq: 69, 43 | quick_reply: { 44 | payload: 'QUICK_REPLY' 45 | } 46 | } 47 | }; 48 | expect(parse(msg)).toEqual({ 49 | sender: '12345', 50 | text: 'QUICK_REPLY', 51 | originalRequest: msg, 52 | type: 'facebook', 53 | postback: true 54 | }); 55 | }); 56 | it('does not parse the object if it is delivery report', () => { 57 | var msg = { 58 | sender: { id: '12345' }, 59 | recipient: { id: '67890' }, 60 | timestamp: 1465558466933, 61 | delivery: { 62 | mids: ['mid.1458668856218:ed81099e15d3f4f233'], 63 | watermark: 1458668856253, 64 | seq: 37 65 | } 66 | }; 67 | expect(parse(msg)).toBeFalsy(); 68 | }); 69 | it('does not parse the object if it is read report', () => { 70 | var msg = { 71 | sender: { id: '12345' }, 72 | recipient: { id: '67890' }, 73 | timestamp: 1465558466933, 74 | read: { 75 | watermark: 1458668856253, 76 | seq: 38 77 | } 78 | }; 79 | expect(parse(msg)).toBeFalsy(); 80 | }); 81 | it('does not parse the object if it is an echo message', () => { 82 | var msg = { 83 | sender: { 84 | id: '12345' 85 | }, 86 | recipient: { 87 | id: '54321' 88 | }, 89 | timestamp: 1483413621558, 90 | message: { 91 | is_echo: true, 92 | app_id: 314159, 93 | mid: 'mid.1483413621558:a9dc28cb84', 94 | seq: 1022, 95 | text: 'Some text' 96 | } 97 | }; 98 | expect(parse(msg)).toBeFalsy(); 99 | }); 100 | it('returns a parsed message for checkbox plugin', () => { 101 | var msg = { 102 | recipient: { 103 | id: '12345' 104 | }, 105 | timestamp: 1234567890, 106 | optin: { 107 | ref:'SomeData', 108 | user_ref: '123-abc' 109 | } 110 | }; 111 | expect(parse(msg)).toEqual({ 112 | sender: '123-abc', 113 | text: '', 114 | originalRequest: msg, 115 | ref: 'SomeData', 116 | type: 'facebook' 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /spec/facebook/facebook-integration-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, jasmine, expect, beforeEach*/ 2 | var botBuilder = require('../../lib/bot-builder'), 3 | https = require('https'); 4 | describe('Facebook Bot integration test', () => { 5 | var messageHandler, 6 | underTest, 7 | lambdaContextSpy, 8 | singleMessageTemplate = { 9 | 'object':'page', 10 | 'entry':[ 11 | { 12 | 'id': 'PAGE_ID', 13 | 'time': 1457764198246, 14 | 'messaging':[ 15 | { 16 | 'sender':{ 17 | 'id':'USER_ID' 18 | }, 19 | 'recipient':{ 20 | 'id':'PAGE_ID' 21 | }, 22 | 'timestamp':1457764197627, 23 | 'message':{ 24 | 'mid':'mid.1457764197618:41d102a3e1ae206a38', 25 | 'seq':73, 26 | 'text':'hello, world!' 27 | } 28 | } 29 | ] 30 | } 31 | ] 32 | }; 33 | 34 | beforeEach(() => { 35 | messageHandler = jasmine.createSpy('messageHandler'); 36 | lambdaContextSpy = jasmine.createSpyObj('lambdaContext', ['done']); 37 | underTest = botBuilder(messageHandler, {}, () => {}); 38 | }); 39 | 40 | describe('API integration wiring', () => { 41 | describe('token verification', () => { 42 | it('uses the text/plain content type', () => { 43 | expect(underTest.apiConfig().routes.facebook.GET.success.contentType).toEqual('text/plain'); 44 | }); 45 | 46 | it('returns hub challenge if the tokens match', (done) => { 47 | underTest.proxyRouter({ 48 | requestContext: { 49 | resourcePath: '/facebook', 50 | httpMethod: 'GET' 51 | }, 52 | queryStringParameters: { 53 | 'hub.verify_token': '12345', 54 | 'hub.challenge': 'XHCG' 55 | }, 56 | stageVariables: { 57 | facebookVerifyToken: '12345' 58 | } 59 | }, lambdaContextSpy).then(() => { 60 | expect(lambdaContextSpy.done).toHaveBeenCalledWith(null, jasmine.objectContaining({body:'XHCG'})); 61 | }).then(done, done.fail); 62 | }); 63 | 64 | it('returns Error challenge if the tokens do not match', (done) => { 65 | underTest.proxyRouter({ 66 | requestContext: { 67 | resourcePath: '/facebook', 68 | httpMethod: 'GET' 69 | }, 70 | queryStringParameters: { 71 | 'hub.verify_token': '123x', 72 | 'hub.challenge': 'XHCG' 73 | }, 74 | stageVariables: { 75 | facebookVerifyToken: '12345' 76 | } 77 | }, lambdaContextSpy).then(() => { 78 | expect(lambdaContextSpy.done).toHaveBeenCalledWith(null, jasmine.objectContaining({body:'Error'})); 79 | }).then(done, done.fail); 80 | }); 81 | }); 82 | describe('message handling', () => { 83 | it('sends the response using https to facebook', done => { 84 | messageHandler.and.returnValue(Promise.resolve('YES')); 85 | 86 | underTest.proxyRouter({ 87 | requestContext: { 88 | resourcePath: '/facebook', 89 | httpMethod: 'POST' 90 | }, 91 | body: singleMessageTemplate, 92 | stageVariables: { 93 | facebookAccessToken: '12345' 94 | } 95 | }, lambdaContextSpy); 96 | 97 | https.request.pipe(callOptions => { 98 | expect(callOptions).toEqual(jasmine.objectContaining({ 99 | method: 'POST', 100 | hostname: 'graph.facebook.com', 101 | path: '/v2.6/me/messages?access_token=12345', 102 | protocol: 'https:', 103 | headers: { 'Content-Type': 'application/json' }, 104 | body: '{"recipient":{"id":"USER_ID"},"message":{"text":"YES"}}' 105 | })); 106 | done(); 107 | }); 108 | }); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /docs/SLACK_MESSAGE_MESSAGE_BUILDER.md: -------------------------------------------------------------------------------- 1 | # Slack Message builder 2 | 3 | _Slack Message builder_ allows you to generate more complex messages with attachments and buttons for Slack without writing JSON files manually. 4 | 5 | To use it, just require `slackTemplate` function from _Claudia Bot Builder_: 6 | 7 | ```js 8 | const slackTemplate = require('claudia-bot-builder').slackTemplate; 9 | ``` 10 | 11 | `slackTemplate` is a class that allows you to build different types of Slack attachments and message actions. 12 | 13 | More info about the things you can do is available on [Slack's attachments guide](https://api.slack.com/docs/message-attachments). 14 | 15 | ### API 16 | 17 | `slackTemplate` (class) - Class that allows you to build different types of Slack formatted messages 18 | _Arguments_: 19 | 20 | - `text`, string (required) - a simple text to send as a reply. 21 | 22 | ### Methods 23 | 24 | | Method | Required | Arguments | Returns | Description | 25 | |-----------|----------|-------------|---------------------|-------------| 26 | | replaceOriginal | No | value (boolean, required) | `this` for chaining | Used for message actions, tells Slack if message should be a new one or replace the current one | 27 | | disableMarkdown | No | value (boolean, required) | `this` for chaining | Markdown is by default enabled for all fields that allows it, this disables it for everything | 28 | | channelMessage | No | value (boolean, required) | `this` for chaining | By default all slash commands are private, this makes them visible for everyone | 29 | | getLatestAttachment | No | No args. | `this` for chaining | Returns the last attachment, used internally | 30 | | addAttachment | No | callbackId (string, required), fallback (string, optional) | `this` for chaining | Adds an attachment and sets a fallback and callback_id for it | 31 | | addTitle | No | text (string, required), link (url, optional) | `this` for chaining | Adds a title to the latest attachment | 32 | | addText | No | text (string, required) | `this` for chaining | Adds a text to the latest attachment | 33 | | addPretext | No | text (string, required) | `this` for chaining | Adds a pretext to the latest attachment | 34 | | addImage | No | imageUrl (url, required) | `this` for chaining | Adds an image to the latest attachment | 35 | | addThumbnail | No | thumbnailUrl (url, required) | `this` for chaining | Adds a thumbnail image to the latest attachment | 36 | | addAuthor | No | name (string, required), icon (string, optional), link (url, optional) | `this` for chaining | Adds an author for the latest attachment | 37 | | addFooter | No | text (string, required), icon (url, optional) | `this` for chaining | Adds an footer to the latest attachment | 38 | | addColor | No | color (string, required) | `this` for chaining | Adds a custom color for the latest attachment | 39 | | addTimestamp | No | timestamp (Date, required) | `this` for chaining | Adds a timestamp for the latest attachment | 40 | | addField | No | title (string, required), value (string, required), isShort (boolean, optional) | `this` for chaining | Adds a field to the latest attachment | 41 | | addAction | No | text (string, required), name (string, required), value (string, required), style (string, optional) | `this` for chaining | Adds an action button to the latest attachment, you can add up to 5 buttons per attachment, style can be 'primary' or 'danger' | 42 | | getLatestAction | No | No args. | `this` for chaining | Returns the latest action, for internal use | 43 | | addConfirmation | No | title (string, required), text (string, required), okLabel (string, optional), dismissLabel (string, optional) | `this` for chaining | Adds a confimation popup for the latest action, default labels are 'Ok' and 'Dismiss' | 44 | | get | Yes | No args. | `this` for chaining | Get method is required and it returns a formatted JSON that is ready to be passed as a response to Slack | 45 | 46 | ### Example 47 | 48 | ```js 49 | const botBuilder = require('claudia-bot-builder'); 50 | const slackTemplate = botBuilder.slackTemplate; 51 | 52 | module.exports = botBuilder(request => { 53 | if (request.type === 'slack') { 54 | const message = new slackTemplate('This is sample text'); 55 | 56 | return message 57 | .addAttachment('A1') 58 | .addAction('Button 1', 'button', '1') 59 | .addAction('Button with confirm', 'button', '2') 60 | .addConfirmation('Ok?', 'This is confirm text', 'Ok', 'Cancel') 61 | .addAction('Button 3', 'button', '3') 62 | .get(); 63 | } 64 | }); 65 | ``` 66 | 67 | ## Handling errors 68 | 69 | _Slack Message builder_ checks if the messages you are generating are following Slack guidelines and limits, in case they are not an error will be thrown. 70 | 71 | More info about limits and guidelines can be found in [Slack's attachments guide](https://api.slack.com/docs/message-attachments). 72 | -------------------------------------------------------------------------------- /lib/facebook/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | const prompt = require('souffleur'); 5 | const rp = require('minimal-request-promise'); 6 | const fbReply = require('./reply'); 7 | const fbParse = require('./parse'); 8 | const validateFbRequestIntegrity = require('./validate-integrity'); 9 | const color = require('../console-colors'); 10 | 11 | module.exports = function fbSetup(api, bot, logError, optionalParser, optionalResponder) { 12 | let parser = optionalParser || fbParse; 13 | let responder = optionalResponder || fbReply; 14 | 15 | api.get('/facebook', request => { 16 | if (request.queryString['hub.verify_token'] === request.env.facebookVerifyToken) 17 | return request.queryString['hub.challenge']; 18 | 19 | logError(`Facebook can't verify the token. It expected '${request.env.facebookVerifyToken}', but got '${request.queryString['hub.verify_token']}'. Make sure you are using the same token you set in 'facebookVerifyToken' stage env variable.`); 20 | return 'Error'; 21 | }, {success: {contentType: 'text/plain'}}); 22 | 23 | api.post('/facebook', request => { 24 | // We are doing verification if FB Secret exist in env because we don't want to break old bots that forgot to add it 25 | if (request.env.facebookAppSecret && !validateFbRequestIntegrity(request)) 26 | return Promise.reject('X-Hub-Signatures does not match'); 27 | 28 | let arr = [].concat.apply([], request.body.entry.map(entry => entry.messaging)); 29 | let fbHandle = parsedMessage => { 30 | if (parsedMessage) { 31 | var recipient = parsedMessage.sender; 32 | 33 | return Promise.resolve(parsedMessage).then(parsedMessage => bot(parsedMessage, request)) 34 | .then(botResponse => responder(recipient, botResponse, request.env.facebookAccessToken)) 35 | .catch(logError); 36 | } 37 | }; 38 | 39 | return Promise.all(arr.map(message => fbHandle(parser(message)))) 40 | .then(() => 'ok'); 41 | }); 42 | 43 | api.addPostDeployStep('facebook', (options, lambdaDetails, utils) => { 44 | return Promise.resolve().then(() => { 45 | return utils.apiGatewayPromise.getStagePromise({ 46 | restApiId: lambdaDetails.apiId, 47 | stageName: lambdaDetails.alias 48 | }).then(data => { 49 | if (options['configure-fb-bot']) { 50 | let token, pageAccessToken; 51 | 52 | return Promise.resolve().then(() => { 53 | if (data.variables && data.variables.facebookVerifyToken) 54 | return data.variables.facebookVerifyToken; 55 | 56 | return crypto.randomBytes(8); 57 | }) 58 | .then(rawToken => { 59 | token = rawToken.toString('base64').replace(/[^A-Za-z0-9]/g, ''); 60 | return utils.apiGatewayPromise.createDeploymentPromise({ 61 | restApiId: lambdaDetails.apiId, 62 | stageName: lambdaDetails.alias, 63 | variables: { 64 | facebookVerifyToken: token 65 | } 66 | }); 67 | }) 68 | .then(() => { 69 | console.log(`\n\n${color.green}Facebook Messenger setup${color.reset}\n`); 70 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 71 | console.log(`\nYour webhook URL is: ${color.cyan}${lambdaDetails.apiUrl}/facebook${color.reset}\n`); 72 | console.log(`Your verify token is: ${color.cyan}${token}${color.reset}\n`); 73 | 74 | return prompt(['Facebook page access token', 'Facebook App Secret']); 75 | }) 76 | .then(results => { 77 | console.log('\n'); 78 | pageAccessToken = results['Facebook page access token']; 79 | const deployment = { 80 | restApiId: lambdaDetails.apiId, 81 | stageName: lambdaDetails.alias, 82 | variables: { 83 | facebookAccessToken: pageAccessToken, 84 | facebookAppSecret: results['Facebook App Secret'] 85 | } 86 | }; 87 | 88 | if (!data.variables || (!data.variables.facebookAppSecret && !results['Facebook App Secret'])) 89 | console.log(`\n${color.yellow}Deprecation warning:${color.reset} your bot is not using facebook validation. Please re-run with --configure-fb-bot to set it. This will become mandatory in the next major version. See https://github.com/claudiajs/claudia-bot-builder/blob/master/docs/API.md#message-verification for more information.\n`); 90 | 91 | return utils.apiGatewayPromise.createDeploymentPromise(deployment); 92 | }) 93 | .then(() => rp.post(`https://graph.facebook.com/v2.6/me/subscribed_apps?access_token=${pageAccessToken}`)); 94 | } 95 | }); 96 | }) 97 | .then(() => `${lambdaDetails.apiUrl}/facebook`); 98 | }); 99 | }; 100 | -------------------------------------------------------------------------------- /spec/telegram/telegram-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require, jasmine */ 2 | 'use strict'; 3 | var reply = require('../../lib/telegram/reply'), 4 | https = require('https'); 5 | 6 | describe('Telegram Reply', () => { 7 | 8 | it('includes the telegram access token in the path request', done => { 9 | https.request.pipe(callOptions => { 10 | expect(callOptions).toEqual(jasmine.objectContaining({ 11 | method: 'POST', 12 | hostname: 'api.telegram.org', 13 | path: '/botACCESS123/sendMessage', 14 | headers: { 15 | 'Content-Type': 'application/json' 16 | } 17 | })); 18 | done(); 19 | }); 20 | reply( 21 | {sender: 'some123ChatId', text: 'Hello Telegram', originalRequest: {message: {}}, type: 'telegram'}, 22 | 'Hello Telegram', 23 | 'ACCESS123' 24 | ); 25 | }); 26 | 27 | it('sends text messages as a string', done => { 28 | https.request.pipe(callOptions => { 29 | expect(JSON.parse(callOptions.body)).toEqual({ 30 | chat_id: 'some123ChatId', 31 | text: 'Hello Telegram' 32 | }); 33 | done(); 34 | }); 35 | reply( 36 | {sender: 'some123ChatId', originalRequest: {}}, 37 | 'Hello Telegram', 38 | 'ACCESS123' 39 | ); 40 | }); 41 | 42 | describe('when an array is passed', () => { 43 | it('does not send the second request until the first one completes', done => { 44 | let answers = ['foo', 'bar']; 45 | https.request.pipe(() => { 46 | Promise.resolve().then(() => { 47 | expect(https.request.calls.length).toEqual(1); 48 | }).then(done); 49 | }); 50 | reply( 51 | {sender: 'some123ChatId', originalRequest: {}, type: 'telegram'}, 52 | answers, 53 | 'ACCESS123' 54 | ); 55 | }); 56 | it('sends the requests in sequence', done => { 57 | let answers = ['foo', 'bar']; 58 | https.request.pipe(function () { 59 | this.respond('200', 'OK'); 60 | if (https.request.calls.length === 2) { 61 | expect(JSON.parse(https.request.calls[0].body[0])).toEqual({chat_id:'some123ChatId',text:'foo'}); 62 | expect(JSON.parse(https.request.calls[1].body[0])).toEqual({chat_id:'some123ChatId',text:'bar'}); 63 | done(); 64 | } 65 | }); 66 | reply( 67 | {sender: 'some123ChatId', originalRequest: {}, type: 'telegram'}, 68 | answers, 69 | 'ACCESS123' 70 | ); 71 | }); 72 | 73 | }); 74 | 75 | it('sends custom object as message if user provide an object without method', done => { 76 | https.request.pipe(callOptions => { 77 | expect(JSON.parse(callOptions.body)).toEqual({ 78 | chat_id: 'some123ChatId', 79 | text: 'Hello *Telegram*', 80 | parse_mode: 'Markdown' 81 | }); 82 | done(); 83 | }); 84 | 85 | reply( 86 | { sender: 'some123ChatId', text: 'Hello Telegram', originalRequest: { message: {} }, type: 'telegram' }, 87 | { text: 'Hello *Telegram*', parse_mode: 'Markdown'}, 88 | 'ACCESS123' 89 | ); 90 | }); 91 | 92 | it('sends custom object as inline query if user provide an object without method after inline query', done => { 93 | https.request.pipe(callOptions => { 94 | expect(JSON.parse(callOptions.body)).toEqual({ 95 | chat_id: 'some123ChatId', 96 | text: 'Hello *Telegram*', 97 | parse_mode: 'Markdown' 98 | }); 99 | done(); 100 | }); 101 | 102 | reply( 103 | { sender: 'some123ChatId', text: 'Hello Telegram', originalRequest: { inline_query: {} }, type: 'telegram' }, 104 | { text: 'Hello *Telegram*', parse_mode: 'Markdown'}, 105 | 'ACCESS123' 106 | ); 107 | }); 108 | 109 | it('sends custom object with custom method if both object and method are provided', done => { 110 | https.request.pipe(callOptions => { 111 | expect(JSON.parse(callOptions.body)).toEqual({ 112 | chat_id: 'some123ChatId', 113 | text: 'Hello *Telegram*', 114 | parse_mode: 'Markdown' 115 | }); 116 | expect(callOptions.href).toEqual('https://api.telegram.org/botACCESS123/sendMessage'); 117 | done(); 118 | }); 119 | 120 | reply( 121 | { sender: 'some123ChatId', text: 'Hello Telegram', originalRequest: { inline_query: {} }, type: 'telegram' }, 122 | { method: 'sendMessage', body: { text: 'Hello *Telegram*', parse_mode: 'Markdown'} }, 123 | 'ACCESS123' 124 | ); 125 | }); 126 | 127 | it('does not resolve before the https endpoint responds', done => { 128 | https.request.pipe(done); 129 | reply( 130 | {sender: 'some123ChatId', text: 'Hello Telegram', originalRequest: {message: {}}, type: 'telegram'}, 131 | 'Hello Telegram', 132 | 'ACCESS123' 133 | ).then(done.fail, done.fail); 134 | }); 135 | 136 | it('resolves when the https endpoint responds with 200', done => { 137 | https.request.pipe(() => { 138 | setTimeout(() => { 139 | https.request.calls[0].respond('200', 'OK', 'Hello Telegram'); 140 | }, 10); 141 | }); 142 | reply( 143 | {sender: 'some123ChatId', text: 'Hello Telegram', originalRequest: {message: {}}, type: 'telegram'}, 144 | 'Hello Telegram', 145 | 'ACCESS123' 146 | ).then(done, done.fail); 147 | }); 148 | 149 | }); 150 | -------------------------------------------------------------------------------- /lib/slack/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const prompt = require('souffleur'); 4 | const rp = require('minimal-request-promise'); 5 | const qs = require('querystring'); 6 | const slackReply = require('./reply'); 7 | const slackParse = require('./parse'); 8 | const color = require('../console-colors'); 9 | 10 | module.exports = function slackSetup(api, bot, logError, optionalParser, optionalResponder) { 11 | let parser = optionalParser || slackParse; 12 | let responder = optionalResponder || slackReply; 13 | 14 | // Hanlde Slack ssl_check GET request, info: https://api.slack.com/slash-commands#ssl 15 | api.get('/slack/slash-command', () => 'OK'); 16 | 17 | api.post('/slack/slash-command', request => { 18 | if ((request.post.command && 19 | (request.post.token === request.env.slackToken || request.post.token === request.env.slackVerificationToken)) 20 | || 21 | (request.post.trigger_word && request.post.token === request.env.slackWebhookToken)) 22 | return bot(parser(request.post), request) 23 | .then(responder) 24 | .catch(logError); 25 | else 26 | return responder('unmatched token' + ' ' + request.post.token); 27 | }); 28 | 29 | api.post('/slack/message-action', request => { 30 | const payload = JSON.parse(request.post.payload); 31 | if (payload.token === request.env.slackVerificationToken || payload.token === request.env.slackToken) 32 | return bot(parser(payload), request) 33 | .then(responder) 34 | .catch(logError); 35 | else 36 | return responder('unmatched token' + ' ' + payload.token); 37 | }); 38 | 39 | api.get('/slack/landing', request => { 40 | return rp.post('https://slack.com/api/oauth.access', { 41 | headers: { 42 | 'Content-Type': 'application/x-www-form-urlencoded' 43 | }, 44 | body: qs.encode({ 45 | client_id: request.env.slackClientId, 46 | client_secret: request.env.slackClientSecret, 47 | code: request.queryString.code, 48 | redirect_uri: request.env.slackRedirectUrl 49 | }) 50 | }) 51 | .then(() => request.env.slackHomePageUrl); 52 | }, { 53 | success: 302 54 | }); 55 | 56 | api.addPostDeployStep('slackSlashCommand', (options, lambdaDetails, utils) => { 57 | return Promise.resolve().then(() => { 58 | if (options['configure-slack-slash-command']) { 59 | console.log(`\n\n${color.green}Slack slash command setup${color.reset}\n`); 60 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 61 | console.log(`\nNote that you can add one token for a slash command, and a second token for an outgoing webhook.\n`); 62 | console.log(`\nYour Slack slash command Request URL (POST only) is ${color.cyan}${lambdaDetails.apiUrl}/slack/slash-command${color.reset}\n`); 63 | console.log(`${color.dim}If you are building full-scale Slack app instead of just a slash command for your team, restart with --configure-slack-slash-app${color.reset} \n`); 64 | 65 | return prompt(['Slack token', 'Outgoing webhook token']) 66 | .then(results => { 67 | const deployment = { 68 | restApiId: lambdaDetails.apiId, 69 | stageName: lambdaDetails.alias, 70 | variables: { 71 | slackToken: results['Slack token'], 72 | slackWebhookToken: results['Outgoing webhook token'] 73 | } 74 | }; 75 | 76 | console.log(`\n`); 77 | 78 | return utils.apiGatewayPromise.createDeploymentPromise(deployment); 79 | }); 80 | } 81 | 82 | if (options['configure-slack-slash-app']) { 83 | console.log(`\n\n${color.green}Slack App slash command setup${color.reset}\n`); 84 | console.log(`\nFollowing info is required for the setup, for more info check the documentation.\n`); 85 | console.log(`\nYour Slack redirect URL is ${color.cyan}${lambdaDetails.apiUrl}/slack/landing${color.reset}\n`); 86 | console.log(`\nYour Slack slash command Request URL (POST only) is ${color.cyan}${lambdaDetails.apiUrl}/slack/slash-command${color.reset}\n`); 87 | console.log(`\nIf you are using buttons, your Action URL is ${color.cyan}${lambdaDetails.apiUrl}/slack/message-action${color.reset}\n`); 88 | console.log(`${color.dim}If you are building just a slash command integration for your team and you don't need full-scale Slack app restart with --configure-slack-slash-command${color.reset} \n`); 89 | 90 | return prompt(['Slack Client ID', 'Slack Client Secret', 'Slack verification token', 'Home page URL']) 91 | .then(results => { 92 | const deployment = { 93 | restApiId: lambdaDetails.apiId, 94 | stageName: lambdaDetails.alias, 95 | variables: { 96 | slackClientId: results['Slack Client ID'], 97 | slackClientSecret: results['Slack Client Secret'], 98 | slackVerificationToken: results['Slack verification token'], 99 | slackHomePageUrl: results['Home page URL'], 100 | slackRedirectUrl: `${lambdaDetails.apiUrl}/slack/landing` 101 | } 102 | }; 103 | 104 | console.log(`\n`); 105 | 106 | return utils.apiGatewayPromise.createDeploymentPromise(deployment); 107 | }); 108 | } 109 | }) 110 | .then(() => `${lambdaDetails.apiUrl}/slack/slash-command`); 111 | }); 112 | }; 113 | -------------------------------------------------------------------------------- /spec/facebook/facebook-reply-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require, jasmine, beforeEach */ 2 | var reply = require('../../lib/facebook/reply'), 3 | https = require('https'); 4 | describe('Facebook Reply', () => { 5 | 'use strict'; 6 | beforeEach(() =>{ 7 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; 8 | }); 9 | it('includes the token in the path request', done => { 10 | https.request.pipe(callOptions => { 11 | expect(callOptions).toEqual(jasmine.objectContaining({ 12 | method: 'POST', 13 | hostname: 'graph.facebook.com', 14 | path: '/v2.6/me/messages?access_token=ACCESS123', 15 | headers: { 16 | 'Content-Type': 'application/json' 17 | } 18 | })); 19 | done(); 20 | }); 21 | reply('user123', 'Hi there', 'ACCESS123'); 22 | }); 23 | it('sends string messages as a text object', done => { 24 | https.request.pipe(callOptions => { 25 | expect(JSON.parse(callOptions.body)).toEqual({ 26 | recipient: { 27 | id: 'user123' 28 | }, 29 | message: { 30 | text: 'Hi there' 31 | } 32 | }); 33 | done(); 34 | }); 35 | reply('user123', 'Hi there', 'ACCESS123'); 36 | }); 37 | it('sends large text messages split into several', done => { 38 | var longLongMessage = new Array(201).join('blok '); 39 | 40 | https.request.pipe(function () { 41 | this.respond('200', 'OK', 'Hi there'); 42 | }); 43 | 44 | reply('user123', longLongMessage, 'ACCESS123').then(() => { 45 | expect(https.request.calls.length).toEqual(2); 46 | expect(JSON.parse(https.request.calls[0].args[0].body)).toEqual({ 47 | recipient: { 48 | id: 'user123' 49 | }, 50 | message: { 51 | text: new Array(640/5).join('blok ') + 'blok' 52 | } 53 | }); 54 | expect(JSON.parse(https.request.calls[1].args[0].body)).toEqual({ 55 | recipient: { 56 | id: 'user123' 57 | }, 58 | message: { 59 | text: new Array((1000 - 640)/5).join('blok ') + 'blok' 60 | } 61 | }); 62 | }).then(done, done.fail); 63 | }); 64 | it('sends requests in sequence', done => { 65 | var fiveHundred = new Array(101).join('blok '); 66 | 67 | https.request.pipe(() => { 68 | Promise.resolve().then(() => { 69 | expect(https.request.calls.length).toEqual(1); 70 | }).then(done); 71 | }); 72 | 73 | reply('user123', fiveHundred, 'ACCESS123'); 74 | }); 75 | describe('when an array is passed', () => { 76 | it('does not send the second request until the first one completes', done => { 77 | let answers = ['foo', 'bar']; 78 | https.request.pipe(() => { 79 | Promise.resolve().then(() => { 80 | expect(https.request.calls.length).toEqual(1); 81 | }).then(done); 82 | }); 83 | reply('user123', answers, 'ACCESS123'); 84 | }); 85 | it('sends the requests in sequence', done => { 86 | let answers = ['foo', 'bar']; 87 | https.request.pipe(function () { 88 | this.respond('200', 'OK'); 89 | if (https.request.calls.length === 2) { 90 | expect(JSON.parse(https.request.calls[0].body[0])).toEqual({recipient:{id:'user123'},message:{text:'foo'}}); 91 | expect(JSON.parse(https.request.calls[1].body[0])).toEqual({recipient:{id:'user123'},message:{text:'bar'}}); 92 | done(); 93 | } 94 | }); 95 | reply('user123', answers, 'ACCESS123'); 96 | 97 | }); 98 | 99 | }); 100 | it('sends complex messages without transforming into a text object', done => { 101 | https.request.pipe(callOptions => { 102 | expect(JSON.parse(callOptions.body)).toEqual({ 103 | recipient: { 104 | id: 'user123' 105 | }, 106 | message: { 107 | template: 'big', 108 | contents: { 109 | title: 'red' 110 | } 111 | } 112 | }); 113 | done(); 114 | }); 115 | reply('user123', {template: 'big', contents: { title: 'red'} }, 'ACCESS123'); 116 | }); 117 | it('does not send a message if message is not provided', () => { 118 | reply('user123', null, 'ACCESS123') 119 | .then(() => { 120 | expect(https.request.calls.length).toBe(0); 121 | }); 122 | 123 | reply('user123', false, 'ACCESS123') 124 | .then(() => { 125 | expect(https.request.calls.length).toBe(0); 126 | }); 127 | 128 | reply('user123', undefined, 'ACCESS123') 129 | .then(() => { 130 | expect(https.request.calls.length).toBe(0); 131 | }); 132 | }); 133 | it('sets a typing action if "sender_action" is passed', done => { 134 | https.request.pipe(callOptions => { 135 | expect(JSON.parse(callOptions.body)).toEqual({ 136 | recipient: { 137 | id: 'user123' 138 | }, 139 | sender_action: 'typing_on' 140 | }); 141 | done(); 142 | }); 143 | reply('user123', { sender_action: 'typing_on' }, 'ACCESS123'); 144 | }); 145 | it('does not resolve before the https endpoint responds', done => { 146 | https.request.pipe(done); 147 | reply('user123', {template: 'big', contents: { title: 'red'} }, 'ACCESS123').then(done.fail, done.fail); 148 | }); 149 | it('resolves when the https endpoint responds with 200', done => { 150 | https.request.pipe(function () { 151 | this.respond('200', 'OK', 'Hi there'); 152 | }); 153 | reply('user123', {template: 'big', contents: { title: 'red'} }, 'ACCESS123').then(done, done.fail); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /spec/twilio/twilio-setup-spec.js: -------------------------------------------------------------------------------- 1 | /*global require, describe, it, expect, beforeEach, jasmine*/ 2 | 'use strict'; 3 | var setup = require('../../lib/twilio/setup'); 4 | const qs = require('querystring'); 5 | 6 | describe('Twilio setup', () => { 7 | var api, bot, logError, parser, responder, botPromise, botResolve, botReject; 8 | beforeEach(() => { 9 | api = jasmine.createSpyObj('api', ['post', 'addPostDeployStep']); 10 | botPromise = new Promise((resolve, reject) => { 11 | botResolve = resolve; 12 | botReject = reject; 13 | }); 14 | bot = jasmine.createSpy().and.returnValue(botPromise); 15 | logError = jasmine.createSpy(); 16 | parser = jasmine.createSpy(); 17 | responder = jasmine.createSpy(); 18 | setup(api, bot, logError, parser, responder); 19 | }); 20 | 21 | describe('message processor', () => { 22 | const messageTemplate = {body: qs.stringify({ 23 | To: '+4444444444', 24 | From: '+333333333', 25 | Body: 'SMS Twilio' 26 | }), env: { 27 | TWILIO_ACCOUNT_SID: 'randomTwilioAccountSID', 28 | TWILIO_AUTH_TOKEN: 'randomTwilioAuthToken', 29 | TWILIO_NUMBER: '+4444444444' 30 | }}; 31 | it('wires the POST request for twilio to the message processor', () => { 32 | expect(api.post.calls.count()).toEqual(1); 33 | expect(api.post).toHaveBeenCalledWith('/twilio', jasmine.any(Function), { success: { contentType: 'text/xml' }}); 34 | }); 35 | describe('processing a single message', () => { 36 | var handler; 37 | beforeEach(() => { 38 | handler = api.post.calls.argsFor(0)[1]; 39 | }); 40 | it('breaks down the message and puts it into the parser', () => { 41 | handler(messageTemplate); 42 | expect(parser).toHaveBeenCalledWith(qs.stringify({ 43 | To: '+4444444444', 44 | From: '+333333333', 45 | Body: 'SMS Twilio' 46 | })); 47 | }); 48 | it('passes the parsed value to the bot if a message can be parsed', (done) => { 49 | parser.and.returnValue('SMS Twilio'); 50 | handler(messageTemplate); 51 | Promise.resolve().then(() => { 52 | expect(bot).toHaveBeenCalledWith('SMS Twilio', messageTemplate); 53 | }).then(done, done.fail); 54 | }); 55 | it('does not invoke the bot if the message cannot be parsed', (done) => { 56 | parser.and.returnValue(false); 57 | handler(messageTemplate).then((message) => { 58 | expect(message).toBe(''); 59 | expect(bot).not.toHaveBeenCalled(); 60 | }).then(done, done.fail); 61 | }); 62 | it('responds when the bot resolves', (done) => { 63 | parser.and.returnValue({sender: '+333333333', text: 'SMS Twilio'}); 64 | botResolve('SMS Twilio'); 65 | handler(messageTemplate).then((message) => { 66 | expect(message).toBe(''); 67 | expect(responder).toHaveBeenCalledWith('randomTwilioAccountSID', 'randomTwilioAuthToken', new Buffer('+4444444444', 'base64').toString('ascii'), '+333333333', 'SMS Twilio'); 68 | }).then(done, done.fail); 69 | }); 70 | it('can work with bot responses as strings', (done) => { 71 | bot.and.returnValue('SMS Twilio'); 72 | parser.and.returnValue({sender: '+333333333', text: 'SMS Twili'}); 73 | handler(messageTemplate).then(() => { 74 | expect(responder).toHaveBeenCalledWith('randomTwilioAccountSID', 'randomTwilioAuthToken', new Buffer('+4444444444', 'base64').toString('ascii'), '+333333333', 'SMS Twilio'); 75 | }).then(done, done.fail); 76 | 77 | }); 78 | it('logs error when the bot rejects without responding', (done) => { 79 | parser.and.returnValue('SMS Twilio'); 80 | 81 | handler(messageTemplate).then(() => { 82 | expect(responder).not.toHaveBeenCalled(); 83 | expect(logError).toHaveBeenCalledWith('NOT AN SMS Twilio'); 84 | }).then(done, done.fail); 85 | 86 | botReject('NOT AN SMS Twilio'); 87 | }); 88 | it('logs the error when the responder throws an error', (done) => { 89 | parser.and.returnValue('SMS Twilio'); 90 | responder.and.throwError('XXX'); 91 | botResolve('SMS Twilio'); 92 | handler({body: messageTemplate}).then(() => { 93 | expect(logError).toHaveBeenCalledWith(jasmine.any(Error)); 94 | }).then(done, done.fail); 95 | }); 96 | describe('working with promises in responders', () => { 97 | var responderResolve, responderReject, responderPromise, hasResolved; 98 | beforeEach(() => { 99 | responderPromise = new Promise((resolve, reject) => { 100 | responderResolve = resolve; 101 | responderReject = reject; 102 | }); 103 | responder.and.returnValue(responderPromise); 104 | 105 | parser.and.returnValue('SMS Twilio'); 106 | }); 107 | it('waits for the responders to resolve before completing the request', (done) => { 108 | handler(messageTemplate).then(() => { 109 | hasResolved = true; 110 | }); 111 | 112 | botPromise.then(() => { 113 | expect(hasResolved).toBeFalsy(); 114 | }).then(done, done.fail); 115 | 116 | botResolve('YES'); 117 | }); 118 | it('resolves when the responder resolves', (done) => { 119 | handler(messageTemplate).then(done, done.fail); 120 | 121 | botPromise.then(() => { 122 | responderResolve('As Promised!'); 123 | }); 124 | botResolve('YES'); 125 | }); 126 | it('logs error when the responder rejects', (done) => { 127 | handler(messageTemplate).then(() => { 128 | expect(logError).toHaveBeenCalledWith('Bomb!'); 129 | }).then(done, done.fail); 130 | 131 | botPromise.then(() => { 132 | responderReject('Bomb!'); 133 | }); 134 | botResolve('YES'); 135 | }); 136 | }); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /spec/alexa/alexa-setup-spec.js: -------------------------------------------------------------------------------- 1 | /*global require, describe, it, expect, beforeEach, jasmine*/ 2 | 'use strict'; 3 | var underTest = require('../../lib/alexa/setup'), 4 | utils = require('../../lib/utils/env-utils'); 5 | describe('Alexa setup', () => { 6 | var api, bot, logError, parser, responder, botPromise, botResolve, botReject; 7 | beforeEach(() => { 8 | api = jasmine.createSpyObj('api', ['get', 'post', 'addPostDeployStep']); 9 | botPromise = new Promise((resolve, reject) => { 10 | botResolve = resolve; 11 | botReject = reject; 12 | }); 13 | bot = jasmine.createSpy().and.returnValue(botPromise); 14 | parser = jasmine.createSpy(); 15 | logError = jasmine.createSpy(); 16 | responder = jasmine.createSpy(); 17 | underTest(api, bot, logError, parser, responder); 18 | }); 19 | describe('message processor', () => { 20 | const singleMessageTemplate = { 21 | request: { 22 | intent: { 23 | name: 'intent 1', 24 | slots: { 25 | value: 'HELLO_SLOT' 26 | } 27 | } 28 | }, 29 | session: { user: { userId: 'user1'} } 30 | }; 31 | it('wires the POST request for alexa to the message processor', () => { 32 | expect(api.post.calls.count()).toEqual(1); 33 | expect(api.post).toHaveBeenCalledWith('/alexa', jasmine.any(Function)); 34 | }); 35 | describe('processing a single message', () => { 36 | var handler; 37 | beforeEach(() => { 38 | handler = api.post.calls.argsFor(0)[1]; 39 | }); 40 | it('breaks down the message and puts it into the parser', () => { 41 | handler({body: singleMessageTemplate, env: {alexaAppName: 'Claudia Alexa Bot'}}); 42 | expect(parser).toHaveBeenCalledWith({ 43 | request: { 44 | intent: { 45 | name: 'intent 1', 46 | slots: { 47 | value: 'HELLO_SLOT' 48 | } 49 | } 50 | }, 51 | session: { user: { userId: 'user1'} } 52 | }); 53 | }); 54 | it('passes the parsed value to the bot if a message can be parsed', (done) => { 55 | parser.and.returnValue('MSG1'); 56 | handler({body: singleMessageTemplate, env: {}}); 57 | Promise.resolve().then(() => { 58 | expect(bot).toHaveBeenCalledWith('MSG1', { body: singleMessageTemplate, env: {} }); 59 | }).then(done, done.fail); 60 | }); 61 | it('responds when the bot resolves', (done) => { 62 | parser.and.returnValue({sender: 'user1', text: 'MSG1', type: 'alexa-skill'}); 63 | botResolve('Hello Alexa'); 64 | handler({body: singleMessageTemplate, env: {alexaAppName: utils.encode('Claudia Alexa Bot')}}).then(() => { 65 | expect(responder).toHaveBeenCalledWith('Hello Alexa', 'Claudia Alexa Bot'); 66 | }).then(done, done.fail); 67 | }); 68 | it('can work with bot responses as strings', (done) => { 69 | botResolve('Hello Alexa'); 70 | parser.and.returnValue({sender: 'user1', text: 'Hello'}); 71 | handler({body: singleMessageTemplate, env: {alexaAppName: utils.encode('Claudia Alexa Bot')}}).then(() => { 72 | expect(responder).toHaveBeenCalledWith('Hello Alexa', 'Claudia Alexa Bot'); 73 | }).then(done, done.fail); 74 | 75 | }); 76 | it('logs error when the bot rejects without responding', (done) => { 77 | parser.and.returnValue('MSG1'); 78 | 79 | handler({body: singleMessageTemplate, env: {alexaAppName: 'Claudia Alexa Bot'}}).then(() => { 80 | expect(responder).not.toHaveBeenCalled(); 81 | expect(logError).toHaveBeenCalledWith('No No'); 82 | }).then(done, done.fail); 83 | 84 | botReject('No No'); 85 | }); 86 | it('logs the error when the responder throws an error', (done) => { 87 | parser.and.returnValue('MSG1'); 88 | responder.and.throwError('XXX'); 89 | botResolve('Yes'); 90 | handler({body: singleMessageTemplate, env: {alexaAppName: 'Claudia Alexa Bot'}}).then(() => { 91 | expect(logError).toHaveBeenCalledWith(jasmine.any(Error)); 92 | }).then(done, done.fail); 93 | }); 94 | describe('working with promises in responders', () => { 95 | var responderResolve, responderReject, responderPromise, hasResolved; 96 | beforeEach(() => { 97 | responderPromise = new Promise((resolve, reject) => { 98 | responderResolve = resolve; 99 | responderReject = reject; 100 | }); 101 | responder.and.returnValue(responderPromise); 102 | 103 | parser.and.returnValue('MSG1'); 104 | }); 105 | it('waits for the responders to resolve before completing the request', (done) => { 106 | handler({body: singleMessageTemplate, env: {alexaAppName: 'Claudia Alexa Bot'}}).then(() => { 107 | hasResolved = true; 108 | }); 109 | 110 | botPromise.then(() => { 111 | expect(hasResolved).toBeFalsy(); 112 | }).then(done, done.fail); 113 | 114 | botResolve('YES'); 115 | }); 116 | it('resolves when the responder resolves', (done) => { 117 | handler({body: singleMessageTemplate, env: {alexaAppName: 'Claudia Alexa Bot'}}).then((message) => { 118 | expect(message).toEqual('As Promised!'); 119 | }).then(done, done.fail); 120 | 121 | botPromise.then(() => { 122 | responderResolve('As Promised!'); 123 | }); 124 | botResolve('YES'); 125 | }); 126 | it('logs error when the responder rejects', (done) => { 127 | handler({body: singleMessageTemplate, env: {alexaAppName: 'Claudia Alexa Bot'}}).then(() => { 128 | expect(logError).toHaveBeenCalledWith('Bomb!'); 129 | }).then(done, done.fail); 130 | 131 | botPromise.then(() => { 132 | responderReject('Bomb!'); 133 | }); 134 | botResolve('YES'); 135 | }); 136 | }); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /spec/viber/viber-setup-spec.js: -------------------------------------------------------------------------------- 1 | /*global require, describe, it, expect, beforeEach, jasmine*/ 2 | 'use strict'; 3 | var underTest = require('../../lib/viber/setup'); 4 | describe('Viber setup', () => { 5 | var api, bot, logError, parser, responder, botPromise, botResolve, botReject; 6 | beforeEach(() => { 7 | api = jasmine.createSpyObj('api', ['post', 'addPostDeployStep']); 8 | botPromise = new Promise((resolve, reject) => { 9 | botResolve = resolve; 10 | botReject = reject; 11 | }); 12 | bot = jasmine.createSpy().and.returnValue(botPromise); 13 | parser = jasmine.createSpy(); 14 | logError = jasmine.createSpy(); 15 | responder = jasmine.createSpy(); 16 | underTest(api, bot, logError, parser, responder); 17 | }); 18 | describe('message processor', () => { 19 | const singleMessageTemplate = { 20 | event: 'message', 21 | timestamp: 1477292830416, 22 | message_token: 12345, 23 | sender: { 24 | id: 'abc', 25 | name: 'Claudia', 26 | avatar: 'https://example.com/path/to.image' 27 | }, 28 | 'message': { 29 | text: 'Hello', 30 | type: 'text' 31 | } 32 | }; 33 | it('shoyld wire the POST request for Viber to the message processor', () => { 34 | expect(api.post.calls.count()).toEqual(1); 35 | expect(api.post).toHaveBeenCalledWith('/viber', jasmine.any(Function)); 36 | }); 37 | describe('processing a single message', () => { 38 | var handler; 39 | beforeEach(() => { 40 | handler = api.post.calls.argsFor(0)[1]; 41 | }); 42 | it('should break down the message and put it into the parser', () => { 43 | handler({ body: singleMessageTemplate, env: { viberAccessToken: 'ABC' } }); 44 | expect(parser).toHaveBeenCalledWith(singleMessageTemplate); 45 | }); 46 | it('should pass the parsed value to the bot if a message can be parsed', (done) => { 47 | parser.and.returnValue('MSG1'); 48 | handler({body: singleMessageTemplate, env: {}}); 49 | Promise.resolve().then(() => { 50 | expect(bot).toHaveBeenCalledWith('MSG1', { body: singleMessageTemplate, env: {} }); 51 | }).then(done, done.fail); 52 | }); 53 | it('should not invoke the bot if the message cannot be parsed', (done) => { 54 | parser.and.returnValue(false); 55 | handler({body: singleMessageTemplate, env: {}}).then((message) => { 56 | expect(message).toBe('ok'); 57 | expect(bot).not.toHaveBeenCalled(); 58 | }).then(done, done.fail); 59 | }); 60 | it('should respond when the bot resolves', (done) => { 61 | parser.and.returnValue({sender: 'user1', text: 'MSG1'}); 62 | handler({ body: singleMessageTemplate, env: { viberAccessToken: 'ABC' } }).then(() => { 63 | expect(responder).toHaveBeenCalledWith('user1', 'Yes Yes', 'ABC'); 64 | }).then(done, done.fail); 65 | 66 | botResolve('Yes Yes'); 67 | }); 68 | it('should work with bot responses as strings', (done) => { 69 | bot.and.returnValue('Yes!'); 70 | parser.and.returnValue({sender: 'user1', text: 'MSG1'}); 71 | handler({body: singleMessageTemplate, env: {viberAccessToken: 'ABC'}}).then(() => { 72 | expect(responder).toHaveBeenCalledWith('user1', 'Yes!', 'ABC'); 73 | }).then(done, done.fail); 74 | 75 | }); 76 | it('should log an error when the bot rejects without responding', (done) => { 77 | parser.and.returnValue('MSG1'); 78 | 79 | handler({body: singleMessageTemplate, env: {}}).then(() => { 80 | expect(responder).not.toHaveBeenCalled(); 81 | expect(logError).toHaveBeenCalledWith('No No'); 82 | }).then(done, done.fail); 83 | 84 | botReject('No No'); 85 | }); 86 | it('should log an error when the responder throws an error', (done) => { 87 | parser.and.returnValue('MSG1'); 88 | responder.and.throwError('XXX'); 89 | botResolve('Yes'); 90 | handler({body: singleMessageTemplate, env: {viberAccessToken: 'ABC'}}).then(() => { 91 | expect(logError).toHaveBeenCalledWith(jasmine.any(Error)); 92 | }).then(done, done.fail); 93 | }); 94 | describe('should work with promises in responders', () => { 95 | var responderResolve, responderReject, responderPromise, hasResolved; 96 | beforeEach(() => { 97 | responderPromise = new Promise((resolve, reject) => { 98 | responderResolve = resolve; 99 | responderReject = reject; 100 | }); 101 | responder.and.returnValue(responderPromise); 102 | 103 | parser.and.returnValue('MSG1'); 104 | }); 105 | it('should wait for the responders to resolve before completing the request', (done) => { 106 | handler({body: singleMessageTemplate, env: {viberAccessToken: 'ABC'}}).then(() => { 107 | hasResolved = true; 108 | }); 109 | 110 | botPromise.then(() => { 111 | expect(hasResolved).toBeFalsy(); 112 | }).then(done, done.fail); 113 | 114 | botResolve('YES'); 115 | }); 116 | it('should resolve when the responder resolves', (done) => { 117 | handler({body: singleMessageTemplate, env: {viberAccessToken: 'ABC'}}).then((message) => { 118 | expect(message).toEqual('As Promised!'); 119 | }).then(done, done.fail); 120 | 121 | botPromise.then(() => { 122 | responderResolve('As Promised!'); 123 | }); 124 | botResolve('YES'); 125 | }); 126 | it('should log error when the responder rejects', (done) => { 127 | handler({body: singleMessageTemplate, env: {viberAccessToken: 'ABC'}}).then(() => { 128 | expect(logError).toHaveBeenCalledWith('Bomb!'); 129 | }).then(done, done.fail); 130 | 131 | botPromise.then(() => { 132 | responderReject('Bomb!'); 133 | }); 134 | botResolve('YES'); 135 | }); 136 | }); 137 | }); 138 | 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /lib/viber/format-message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const isUrl = require('../is-url'); 4 | 5 | class ViberMessage { 6 | constructor() { 7 | this.template = {}; 8 | } 9 | 10 | addReplyKeyboard(isDefaultHeight, backgroundColor) { 11 | 12 | const replyKeyboard = { 13 | Type: 'keyboard', 14 | DefaultHeight: isDefaultHeight || true, 15 | BgColor: backgroundColor || '#FFFFFF', 16 | Buttons: [] 17 | }; 18 | 19 | this.template.keyboard = replyKeyboard; 20 | 21 | return this; 22 | } 23 | 24 | addKeyboardButton(text, buttonValue, columnSize, rowSize, buttonObj) { 25 | 26 | if (!this.template.keyboard || !Array.isArray(this.template.keyboard.Buttons)) 27 | throw new Error('KeyboardButton can only be added if you previously added the ReplyKeyboard'); 28 | 29 | if (!text || typeof text !== 'string') 30 | throw new Error('Text is required for the Viber KeyboardButton template'); 31 | 32 | if (!buttonValue || typeof buttonValue !== 'string') 33 | throw new Error('buttonValue is required for the Viber KeyboardButton template, and it can be a valid URL or a string'); 34 | 35 | buttonObj = buttonObj || {}; 36 | 37 | buttonObj.Text = text; 38 | buttonObj.ActionBody = buttonValue; 39 | 40 | if (isUrl(buttonValue)) { 41 | buttonObj.ActionType = 'open-url'; 42 | } else { 43 | buttonObj.ActionType = 'reply'; 44 | } 45 | 46 | if (columnSize && typeof columnSize == 'number' && columnSize > 0 && columnSize <= 6) 47 | buttonObj.Columns = columnSize; 48 | 49 | if (rowSize && typeof rowSize === 'number' && rowSize > 0 && rowSize <= 2) 50 | buttonObj.Rows = rowSize; 51 | 52 | this.template.keyboard.Buttons.push(buttonObj); 53 | 54 | return this; 55 | } 56 | 57 | get() { 58 | return this.template; 59 | } 60 | } 61 | 62 | class Text extends ViberMessage { 63 | constructor(text) { 64 | super(); 65 | if (!text || typeof text !== 'string') 66 | throw new Error('Text is required for the Viber Text template'); 67 | 68 | this.template = { 69 | type: 'text', 70 | text: text 71 | }; 72 | } 73 | } 74 | 75 | class Photo extends ViberMessage { 76 | constructor(photo, caption) { 77 | super(); 78 | if (!photo || typeof photo !== 'string') 79 | throw new Error('Photo needs to be an URL for the Viber Photo method'); 80 | caption = caption || ''; 81 | if (caption && typeof caption !== 'string') 82 | throw new Error('Text needs to be a string for Viber Photo method'); 83 | 84 | this.template = { 85 | type: 'picture', 86 | media: photo, 87 | text: caption 88 | }; 89 | } 90 | } 91 | 92 | class Video extends ViberMessage { 93 | constructor(media, size, duration) { 94 | super(); 95 | if (!media || typeof media !== 'string') 96 | throw new Error('Media needs to be an URL for Viber Video method'); 97 | 98 | if (!size || typeof size !== 'number') 99 | throw new Error('Size needs to be a Number representing size in bytes for Viber Video method'); 100 | 101 | this.template = { 102 | type: 'video', 103 | media: media, 104 | size: size 105 | }; 106 | 107 | if (duration && typeof duration === 'number') 108 | this.template.duration = duration; 109 | } 110 | } 111 | 112 | class File extends ViberMessage { 113 | constructor(media, size, fileName) { 114 | super(); 115 | if (!media || typeof media !== 'string') 116 | throw new Error('Media needs to be an URL for the Viber File method'); 117 | 118 | if (!size || typeof size !== 'number') 119 | throw new Error('Size needs to be a Number representing size in bytes for the Viber File method'); 120 | 121 | if (!fileName || typeof fileName !== 'string') 122 | throw new Error('File name needs to be a String representing the name of the file for the Viber File method'); 123 | 124 | this.template = { 125 | type: 'file', 126 | media: media, 127 | size: size, 128 | file_name: fileName 129 | }; 130 | } 131 | } 132 | 133 | class Contact extends ViberMessage { 134 | constructor(name, phoneNumber) { 135 | super(); 136 | if (!name || !phoneNumber || typeof name !== 'string' || typeof phoneNumber !== 'string') 137 | throw new Error('Contact name and phone number are required for the Viber Contact template'); 138 | 139 | this.template = { 140 | type: 'contact', 141 | contact: { 142 | name: name, 143 | phone_number: phoneNumber 144 | } 145 | }; 146 | } 147 | } 148 | 149 | 150 | class Location extends ViberMessage { 151 | constructor(latitude, longitude) { 152 | super(); 153 | if (!latitude || !longitude || typeof latitude !== 'number' || typeof longitude !== 'number') 154 | throw new Error('Latitude and longitude are required for the Viber Location template'); 155 | 156 | this.template = { 157 | type: 'location', 158 | location: { 159 | lat: latitude, 160 | lon: longitude 161 | } 162 | }; 163 | } 164 | } 165 | 166 | class Url extends ViberMessage { 167 | constructor(url) { 168 | super(); 169 | if (!url || !isUrl(url) || typeof url !== 'string') 170 | throw new Error('Media needs to be an URL for the Viber URL method'); 171 | 172 | if (url.length > 2000) 173 | throw new Error('Media URL can not be longer than 2000 characters for the Viber URL method'); 174 | 175 | this.template = { 176 | type: 'url', 177 | media: url 178 | }; 179 | } 180 | } 181 | 182 | class Sticker extends ViberMessage { 183 | constructor(stickerId) { 184 | super(); 185 | if (!stickerId || typeof stickerId !== 'number') 186 | throw new Error('Sticker ID and '); 187 | 188 | this.template = { 189 | type: 'sticker', 190 | sticker_id: stickerId 191 | }; 192 | } 193 | } 194 | 195 | module.exports = { 196 | Text: Text, 197 | Photo: Photo, 198 | Video: Video, 199 | File: File, 200 | Contact: Contact, 201 | Location: Location, 202 | Url: Url, 203 | Sticker: Sticker 204 | }; 205 | -------------------------------------------------------------------------------- /lib/slack/format-message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const isUrl = require('../is-url'); 4 | 5 | class SlackTemplate { 6 | constructor(text) { 7 | this.template = { 8 | mrkdwn: true 9 | }; 10 | this.template.attachments = []; 11 | 12 | if (text) 13 | this.template.text = text; 14 | } 15 | 16 | replaceOriginal(value) { 17 | this.template.replace_original = !!value; 18 | return this; 19 | } 20 | 21 | disableMarkdown(value) { 22 | if (value) 23 | this.template.mrkdwn = !value; 24 | 25 | return this; 26 | } 27 | 28 | // This works for Slash commands only 29 | channelMessage(value) { 30 | if (value && value !== 'ephemeral') 31 | this.template.response_type = 'in_channel'; 32 | 33 | return this; 34 | } 35 | 36 | getLatestAttachment() { 37 | if (!this.template.attachments.length) 38 | throw new Error('Add at least one attachment first'); 39 | 40 | return this.template.attachments[this.template.attachments.length - 1]; 41 | } 42 | 43 | addAttachment(callbackId, fallback) { 44 | if (this.template.attachments.length === 20) 45 | throw new Error('You can not add more than 20 attachments'); 46 | 47 | const attachment = { 48 | actions: [] 49 | }; 50 | 51 | if (callbackId) 52 | attachment.callback_id = callbackId; 53 | 54 | attachment.fallback = fallback || 'Slack told us that you are not able to see this attachment 😢'; 55 | 56 | this.template.attachments.push(attachment); 57 | 58 | return this; 59 | } 60 | 61 | addTitle(text, link) { 62 | if (!text) 63 | throw new Error('Title text is required for addTitle method'); 64 | 65 | const attachment = this.getLatestAttachment(); 66 | attachment.title = text; 67 | if (isUrl(link)) 68 | attachment.title_link = link; 69 | 70 | return this; 71 | } 72 | 73 | addText(text) { 74 | if (!text) 75 | throw new Error('Text is required for addText method'); 76 | 77 | const attachment = this.getLatestAttachment(); 78 | attachment.text = text; 79 | 80 | return this; 81 | } 82 | 83 | addPretext(text) { 84 | if (!text) 85 | throw new Error('Text is required for addPretext method'); 86 | 87 | const attachment = this.getLatestAttachment(); 88 | attachment.pretext = text; 89 | 90 | return this; 91 | } 92 | 93 | addImage(url) { 94 | if (!isUrl(url)) 95 | throw new Error('addImage method requires a valid URL'); 96 | 97 | const attachment = this.getLatestAttachment(); 98 | attachment.image_url = url; 99 | 100 | return this; 101 | } 102 | 103 | addThumbnail(url) { 104 | if (!isUrl(url)) 105 | throw new Error('addThumbnail method requires a valid URL'); 106 | 107 | const attachment = this.getLatestAttachment(); 108 | attachment.thumb_url = url; 109 | 110 | return this; 111 | } 112 | 113 | addAuthor(name, icon, link) { 114 | if (!name) 115 | throw new Error('Name is required for addAuthor method'); 116 | 117 | const attachment = this.getLatestAttachment(); 118 | attachment.author_name = name; 119 | 120 | if (icon) 121 | attachment.author_icon = icon; 122 | 123 | if (isUrl(link)) 124 | attachment.author_link = link; 125 | 126 | return this; 127 | } 128 | 129 | addFooter(text, icon) { 130 | if (!text) 131 | throw new Error('Text is required for addFooter method'); 132 | 133 | const attachment = this.getLatestAttachment(); 134 | attachment.footer = text; 135 | 136 | if (icon) 137 | attachment.footer_icon = icon; 138 | 139 | return this; 140 | } 141 | 142 | addColor(color) { 143 | if (!color) 144 | throw new Error('Color is required for addColor method'); 145 | 146 | const attachment = this.getLatestAttachment(); 147 | attachment.color = color; 148 | 149 | return this; 150 | } 151 | 152 | addTimestamp(timestamp) { 153 | if (!(timestamp instanceof Date)) 154 | throw new Error('Timestamp needs to be a valid Date object'); 155 | 156 | const attachment = this.getLatestAttachment(); 157 | attachment.ts = timestamp.getTime(); 158 | 159 | return this; 160 | } 161 | 162 | addField(title, value, isShort) { 163 | if (!title || !value) 164 | throw new Error('Title and value are required for addField method'); 165 | 166 | const attachment = this.getLatestAttachment(); 167 | if (!attachment.fields) 168 | attachment.fields = []; 169 | 170 | attachment.fields.push({ 171 | title: title, 172 | value: value, 173 | short: !!isShort 174 | }); 175 | 176 | return this; 177 | } 178 | 179 | addAction(text, name, value, style) { 180 | if (this.getLatestAttachment().actions.length === 5) 181 | throw new Error('You can not add more than 5 actions'); 182 | 183 | if (!text || !name || !value) 184 | throw new Error('Text, name and value are requeired for addAction method'); 185 | 186 | const action = { 187 | text: text, 188 | name: name, 189 | value: value, 190 | type: 'button' 191 | }; 192 | 193 | if (style) 194 | action.style = style; 195 | 196 | this.getLatestAttachment().actions.push(action); 197 | 198 | return this; 199 | } 200 | 201 | getLatestAction() { 202 | const actions = this.getLatestAttachment().actions; 203 | 204 | if (!actions.length) 205 | throw new Error('At least one action is requeired for getLatestAction method'); 206 | 207 | return actions[actions.length - 1]; 208 | } 209 | 210 | addConfirmation(title, text, okLabel, dismissLabel) { 211 | const action = this.getLatestAction(); 212 | 213 | if (!title || !text) 214 | throw new Error('Title and text are required for addConfirmation method'); 215 | 216 | action.confirm = { 217 | title: title, 218 | text: text, 219 | ok_text: okLabel || 'Ok', 220 | dismiss_text: dismissLabel || 'Dismiss' 221 | }; 222 | 223 | return this; 224 | } 225 | 226 | get() { 227 | return this.template; 228 | } 229 | } 230 | 231 | module.exports = SlackTemplate; 232 | -------------------------------------------------------------------------------- /spec/groupme/groupme-setup-spec.js: -------------------------------------------------------------------------------- 1 | /*global require, describe, it, expect, beforeEach, jasmine*/ 2 | 'use strict'; 3 | var underTest = require('../../lib/groupme/setup'); 4 | describe('GroupMe setup', () => { 5 | var api, bot, logError, parser, responder, botPromise, botResolve, botReject; 6 | beforeEach(() => { 7 | api = jasmine.createSpyObj('api', ['get', 'post', 'addPostDeployStep']); 8 | botPromise = new Promise((resolve, reject) => { 9 | botResolve = resolve; 10 | botReject = reject; 11 | }); 12 | bot = jasmine.createSpy().and.returnValue(botPromise); 13 | parser = jasmine.createSpy(); 14 | logError = jasmine.createSpy(); 15 | responder = jasmine.createSpy(); 16 | underTest(api, bot, logError, parser, responder); 17 | }); 18 | describe('message processor', () => { 19 | const singleMessageTemplate = { 20 | group_id: 345678, 21 | text: 'hello GroupMe', 22 | sender_type: 'user' 23 | }; 24 | it('wires the POST request for kik to the message processor', () => { 25 | expect(api.post.calls.count()).toEqual(1); 26 | expect(api.post).toHaveBeenCalledWith('/groupme', jasmine.any(Function)); 27 | }); 28 | describe('processing a single message', () => { 29 | var handler; 30 | beforeEach(() => { 31 | handler = api.post.calls.argsFor(0)[1]; 32 | }); 33 | it('breaks down the message and puts it into the parser', () => { 34 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}); 35 | expect(parser).toHaveBeenCalledWith({ 36 | group_id: 345678, 37 | text: 'hello GroupMe', 38 | sender_type: 'user' 39 | }); 40 | }); 41 | it('passes the parsed value to the bot if a message can be parsed', (done) => { 42 | parser.and.returnValue('Group me with the group'); 43 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}); 44 | Promise.resolve().then(() => { 45 | expect(bot).toHaveBeenCalledWith('Group me with the group', { body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123} }); 46 | }).then(done, done.fail); 47 | }); 48 | it('does not invoke the bot if the message cannot be parsed', (done) => { 49 | parser.and.returnValue(false); 50 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}).then((message) => { 51 | expect(message).toBe('ok'); 52 | expect(bot).not.toHaveBeenCalled(); 53 | }).then(done, done.fail); 54 | }); 55 | it('responds when the bot resolves', (done) => { 56 | parser.and.returnValue({sender: 123123, text: 'Test GroupMe'}); 57 | botResolve('Group me with the group'); 58 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}).then((message) => { 59 | expect(message).toBe('ok'); 60 | expect(responder).toHaveBeenCalledWith('Group me with the group', 123123123); 61 | }).then(done, done.fail); 62 | }); 63 | it('can work with bot responses as strings', (done) => { 64 | bot.and.returnValue('Group me with the group'); 65 | parser.and.returnValue({sender: 'user1', text: 'Hello'}); 66 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}).then((message) => { 67 | expect(message).toBe('ok'); 68 | expect(responder).toHaveBeenCalledWith('Group me with the group', 123123123); 69 | }).then(done, done.fail); 70 | 71 | }); 72 | it('logs error when the bot rejects without responding', (done) => { 73 | parser.and.returnValue('MSG1'); 74 | 75 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}).then((message) => { 76 | expect(message).toBe('ok'); 77 | expect(responder).not.toHaveBeenCalled(); 78 | expect(logError).toHaveBeenCalledWith('No No GroupMe'); 79 | }).then(done, done.fail); 80 | 81 | botReject('No No GroupMe'); 82 | }); 83 | it('logs the error when the responder throws an error', (done) => { 84 | parser.and.returnValue('MSG1'); 85 | responder.and.throwError('XXX'); 86 | botResolve('Yes'); 87 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}).then((message) => { 88 | expect(message).toBe('ok'); 89 | expect(logError).toHaveBeenCalledWith(jasmine.any(Error)); 90 | }).then(done, done.fail); 91 | }); 92 | describe('working with promises in responders', () => { 93 | var responderResolve, responderReject, responderPromise, hasResolved; 94 | beforeEach(() => { 95 | responderPromise = new Promise((resolve, reject) => { 96 | responderResolve = resolve; 97 | responderReject = reject; 98 | }); 99 | responder.and.returnValue(responderPromise); 100 | 101 | parser.and.returnValue('MSG1'); 102 | }); 103 | it('waits for the responders to resolve before completing the request', (done) => { 104 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}).then(() => { 105 | hasResolved = true; 106 | }); 107 | 108 | botPromise.then(() => { 109 | expect(hasResolved).toBeFalsy(); 110 | }).then(done, done.fail); 111 | 112 | botResolve('YES'); 113 | }); 114 | it('resolves when the responder resolves', (done) => { 115 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}).then((message) => { 116 | expect(message).toEqual('ok'); 117 | }).then(done, done.fail); 118 | 119 | botPromise.then(() => { 120 | responderResolve('As Promised!'); 121 | }); 122 | botResolve('YES'); 123 | }); 124 | it('logs error when the responder rejects', (done) => { 125 | handler({body: singleMessageTemplate, env: {GROUPME_BOT_ID: 123123123}}).then((message) => { 126 | expect(message).toEqual('ok'); 127 | expect(logError).toHaveBeenCalledWith('Bomb!'); 128 | }).then(done, done.fail); 129 | 130 | botPromise.then(() => { 131 | responderReject('Bomb!'); 132 | }); 133 | botResolve('YES'); 134 | }); 135 | }); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /lib/skype/format-message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class SkypeMessage { 4 | constructor() { 5 | this.template = {}; 6 | this.template.attachments = []; 7 | } 8 | 9 | get() { 10 | return this.template; 11 | } 12 | } 13 | 14 | class Text extends SkypeMessage { 15 | constructor(text, format) { 16 | super(); 17 | if (!text || typeof text !== 'string') 18 | throw new Error('Text is required for Skype Text template'); 19 | 20 | this.template = { 21 | type: 'message', 22 | text: text, 23 | textFormat: format || 'plain' 24 | }; 25 | } 26 | } 27 | 28 | class Photo extends SkypeMessage { 29 | constructor(base64Photo) { 30 | super(); 31 | if (!base64Photo || typeof base64Photo !== 'string') 32 | throw new Error('Photo is required for the Skype Photo template'); 33 | 34 | this.template = { 35 | type: 'message', 36 | attachments: [{ 37 | contentType: 'image/png', 38 | contentUrl: base64Photo 39 | }] 40 | }; 41 | } 42 | } 43 | 44 | class Carousel extends SkypeMessage { 45 | constructor(summary, text) { 46 | super(); 47 | 48 | this.template = { 49 | type: 'message', 50 | attachmentLayout: 'carousel', 51 | summary: summary || '', 52 | text: text || '', 53 | attachments: [] 54 | }; 55 | 56 | return this; 57 | } 58 | 59 | getCurrentAttachment() { 60 | let current = this.template.attachments.length - 1; 61 | 62 | if (current < 0) { 63 | throw new Error('You need to add attachment to Carousel'); 64 | } 65 | 66 | return current; 67 | } 68 | 69 | addHero(images) { 70 | if(images && !Array.isArray(images)) { 71 | throw new Error('Images should be sent as array for the Skype Hero template'); 72 | } 73 | 74 | this.template.attachments.push({ 75 | contentType: 'application/vnd.microsoft.card.hero', 76 | content: { 77 | title: '', 78 | subtitle: '', 79 | text: '', 80 | images: images ? images.map(image => ({url: image, alt: ''})) : [], 81 | buttons: [] 82 | } 83 | }); 84 | 85 | return this; 86 | } 87 | 88 | addThumbnail(images) { 89 | if(images && !Array.isArray(images)) { 90 | throw new Error('Images should be sent as array for the Skype Thumbnail template'); 91 | } 92 | 93 | this.template.attachments.push({ 94 | contentType: 'application/vnd.microsoft.card.thumbnail', 95 | content: { 96 | title: '', 97 | subtitle: '', 98 | text: '', 99 | images: images ? images.map(image => ({url: image, alt: ''})) : [], 100 | buttons: [] 101 | } 102 | }); 103 | 104 | return this; 105 | } 106 | 107 | addReceipt(total, tax, vat) { 108 | this.template.attachments.push({ 109 | contentType: 'application/vnd.microsoft.card.receipt', 110 | content: { 111 | title: '', 112 | subtitle: '', 113 | text: '', 114 | total: total || '', 115 | tax: tax || '', 116 | vat: vat || '', 117 | items: [], 118 | facts: [], 119 | buttons: [] 120 | } 121 | }); 122 | 123 | return this; 124 | } 125 | 126 | addFact(key, value) { 127 | let currentAttachment = this.getCurrentAttachment(); 128 | 129 | this.template.attachments[currentAttachment].content.facts.push({ 130 | key: key || '', 131 | value: value || '' 132 | }); 133 | 134 | return this; 135 | } 136 | 137 | addItem(title, subtitle, text, price, quantity, image) { 138 | let currentAttachment = this.getCurrentAttachment(); 139 | 140 | this.template.attachments[currentAttachment].content.items.push({ 141 | title: title || '', 142 | subtitle: subtitle || '', 143 | text: text || '', 144 | price: price || '', 145 | quantity: quantity || '', 146 | image: { 147 | url: image || '' 148 | } 149 | }); 150 | 151 | return this; 152 | } 153 | 154 | addTitle(title) { 155 | let currentAttachment = this.getCurrentAttachment(); 156 | 157 | if (!title || typeof title !== 'string') 158 | throw new Error('Title needs to be a string for Skype addTitle method'); 159 | 160 | this.template.attachments[currentAttachment].content.title = title; 161 | 162 | return this; 163 | } 164 | 165 | addSubtitle(subtitle) { 166 | let currentAttachment = this.getCurrentAttachment(); 167 | 168 | if (!subtitle || typeof subtitle !== 'string') 169 | throw new Error('Subtitle needs to be a string for Skype addSubtitle method'); 170 | 171 | this.template.attachments[currentAttachment].content.subtitle = subtitle; 172 | 173 | return this; 174 | } 175 | 176 | addText(text) { 177 | let currentAttachment = this.getCurrentAttachment(); 178 | 179 | if (!text || typeof text !== 'string') 180 | throw new Error('Text needs to be a string for Skype addText method'); 181 | 182 | this.template.attachments[currentAttachment].content.text = text; 183 | 184 | return this; 185 | } 186 | 187 | addButton(title, value, type) { 188 | let currentAttachment = this.getCurrentAttachment(); 189 | 190 | if (!title || typeof title !== 'string') 191 | throw new Error('Title needs to be a string for Skype addButton method'); 192 | 193 | if (!value || typeof value !== 'string') 194 | throw new Error('Value needs to be a string for Skype addButton method'); 195 | 196 | if (!type || typeof type !== 'string') 197 | throw new Error('Type needs to be a string for Skype addButton method'); 198 | 199 | let validTypes = ['openUrl', 'imBack', 'postBack', 'playAudio', 'playVideo', 'showImage', 'downloadFile', 'signin']; 200 | if (validTypes.indexOf(type) == -1) 201 | throw new Error('Type needs to be a valid type string for Skype addButton method'); 202 | 203 | this.template.attachments[currentAttachment].content.buttons.push({ 204 | type: type, 205 | title: title, 206 | value: value 207 | }); 208 | 209 | return this; 210 | } 211 | } 212 | 213 | class Typing extends SkypeMessage { 214 | constructor() { 215 | super(); 216 | this.template = { 217 | type: 'typing' 218 | }; 219 | 220 | return this.template; 221 | } 222 | } 223 | 224 | //TODO: investigate how to send Hero, Thumbnail and Receipt without carousel 225 | 226 | module.exports = { 227 | Text: Text, 228 | Photo: Photo, 229 | Carousel: Carousel, 230 | Typing: Typing 231 | }; 232 | -------------------------------------------------------------------------------- /spec/telegram/telegram-setup-spec.js: -------------------------------------------------------------------------------- 1 | /*global require, describe, it, expect, beforeEach, jasmine*/ 2 | 'use strict'; 3 | var setup = require('../../lib/telegram/setup'); 4 | 5 | describe('Telegram setup', () => { 6 | var api, bot, logError, parser, responder, botPromise, botResolve, botReject; 7 | beforeEach(() => { 8 | api = jasmine.createSpyObj('api', ['post', 'addPostDeployStep']); 9 | botPromise = new Promise((resolve, reject) => { 10 | botResolve = resolve; 11 | botReject = reject; 12 | }); 13 | bot = jasmine.createSpy().and.returnValue(botPromise); 14 | logError = jasmine.createSpy(); 15 | parser = jasmine.createSpy(); 16 | responder = jasmine.createSpy(); 17 | setup(api, bot, logError, parser, responder); 18 | }); 19 | 20 | describe('message processor', () => { 21 | const singleMessageTemplate = { 22 | 'update_id': 2837645, 23 | 'message':{ 24 | 'message_id': 32423423, 25 | 'from': { 26 | id: 12321, 27 | first_name: 'Testy', 28 | last_name: 'Lasty', 29 | username: 'testy_lasty' 30 | }, 31 | 'date': 1457764198246, 32 | 'chat': { 'id': 123123, 'type': 'private' } 33 | } 34 | }; 35 | it('wires the POST request for telegram to the message processor', () => { 36 | expect(api.post.calls.count()).toEqual(1); 37 | expect(api.post).toHaveBeenCalledWith('/telegram', jasmine.any(Function)); 38 | }); 39 | describe('processing a single message', () => { 40 | var handler; 41 | beforeEach(() => { 42 | handler = api.post.calls.argsFor(0)[1]; 43 | }); 44 | it('breaks down the message and puts it into the parser', () => { 45 | handler({body: singleMessageTemplate}); 46 | expect(parser).toHaveBeenCalledWith({ 47 | 'update_id': 2837645, 48 | 'message': { 49 | 'message_id': 32423423, 50 | 'from': { 51 | id: 12321, 52 | first_name: 'Testy', 53 | last_name: 'Lasty', 54 | username: 'testy_lasty' 55 | }, 56 | 'date': 1457764198246, 57 | 'chat': {'id': 123123, 'type': 'private'} 58 | } 59 | }); 60 | }); 61 | it('passes the parsed value to the bot if a message can be parsed', (done) => { 62 | parser.and.returnValue('MSG1'); 63 | handler({body: singleMessageTemplate}); 64 | Promise.resolve().then(() => { 65 | expect(bot).toHaveBeenCalledWith('MSG1', {body: singleMessageTemplate}); 66 | }).then(done, done.fail); 67 | }); 68 | it('does not invoke the bot if the message cannot be parsed', (done) => { 69 | parser.and.returnValue(false); 70 | handler({body: singleMessageTemplate}).then((message) => { 71 | expect(message).toBe('ok'); 72 | expect(bot).not.toHaveBeenCalled(); 73 | }).then(done, done.fail); 74 | }); 75 | it('responds when the bot resolves', (done) => { 76 | parser.and.returnValue({sender: 'user1', text: 'MSG1'}); 77 | botResolve('Yes Yes'); 78 | handler({body: singleMessageTemplate, env: {telegramAccessToken: 'some123AccessToken'}}).then(() => { 79 | expect(responder).toHaveBeenCalledWith({sender: 'user1', text: 'MSG1'}, 'Yes Yes', 'some123AccessToken'); 80 | }).then(done, done.fail); 81 | }); 82 | it('can work with bot responses as strings', (done) => { 83 | bot.and.returnValue('Yes!'); 84 | parser.and.returnValue({sender: 'user1', text: 'MSG1'}); 85 | handler({body: singleMessageTemplate, env: {telegramAccessToken: 'some123AccessToken'}}).then(() => { 86 | expect(responder).toHaveBeenCalledWith({sender: 'user1', text: 'MSG1'}, 'Yes!', 'some123AccessToken'); 87 | }).then(done, done.fail); 88 | 89 | }); 90 | it('logs error when the bot rejects without responding', (done) => { 91 | parser.and.returnValue('MSG1'); 92 | 93 | handler({body: singleMessageTemplate}).then(() => { 94 | expect(responder).not.toHaveBeenCalled(); 95 | expect(logError).toHaveBeenCalledWith('No No'); 96 | }).then(done, done.fail); 97 | 98 | botReject('No No'); 99 | }); 100 | it('logs the error when the responder throws an error', (done) => { 101 | parser.and.returnValue('MSG1'); 102 | responder.and.throwError('XXX'); 103 | botResolve('Yes'); 104 | handler({body: singleMessageTemplate, env: {telegramAccessToken: 'some123AccessToken'}}).then(() => { 105 | expect(logError).toHaveBeenCalledWith(jasmine.any(Error)); 106 | }).then(done, done.fail); 107 | }); 108 | describe('working with promises in responders', () => { 109 | var responderResolve, responderReject, responderPromise, hasResolved; 110 | beforeEach(() => { 111 | responderPromise = new Promise((resolve, reject) => { 112 | responderResolve = resolve; 113 | responderReject = reject; 114 | }); 115 | responder.and.returnValue(responderPromise); 116 | 117 | parser.and.returnValue('MSG1'); 118 | }); 119 | it('waits for the responders to resolve before completing the request', (done) => { 120 | handler({body: singleMessageTemplate, env: {telegramAccessToken: 'some123AccessToken'}}).then(() => { 121 | hasResolved = true; 122 | }); 123 | 124 | botPromise.then(() => { 125 | expect(hasResolved).toBeFalsy(); 126 | }).then(done, done.fail); 127 | 128 | botResolve('YES'); 129 | }); 130 | it('resolves when the responder resolves', (done) => { 131 | handler({body: singleMessageTemplate, env: {telegramAccessToken: 'some123AccessToken'}}).then(done, done.fail); 132 | 133 | botPromise.then(() => { 134 | responderResolve('As Promised!'); 135 | }); 136 | botResolve('YES'); 137 | }); 138 | it('logs error when the responder rejects', (done) => { 139 | handler({body: singleMessageTemplate, env: {telegramAccessToken: 'some123AccessToken'}}).then(() => { 140 | expect(logError).toHaveBeenCalledWith('Bomb!'); 141 | }).then(done, done.fail); 142 | 143 | botPromise.then(() => { 144 | responderReject('Bomb!'); 145 | }); 146 | botResolve('YES'); 147 | }); 148 | }); 149 | }); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /spec/kik/kik-setup-spec.js: -------------------------------------------------------------------------------- 1 | /*global require, describe, it, expect, beforeEach, jasmine*/ 2 | 'use strict'; 3 | var underTest = require('../../lib/kik/setup'); 4 | describe('Kik setup', () => { 5 | var api, bot, logError, parser, responder, botPromise, botResolve, botReject; 6 | beforeEach(() => { 7 | api = jasmine.createSpyObj('api', ['get', 'post', 'addPostDeployStep']); 8 | botPromise = new Promise((resolve, reject) => { 9 | botResolve = resolve; 10 | botReject = reject; 11 | }); 12 | bot = jasmine.createSpy().and.returnValue(botPromise); 13 | parser = jasmine.createSpy(); 14 | logError = jasmine.createSpy(); 15 | responder = jasmine.createSpy(); 16 | underTest(api, bot, logError, parser, responder); 17 | }); 18 | describe('message processor', () => { 19 | const singleMessageTemplate = { 20 | messages: [ 21 | { 22 | from: 'randomKikUser', 23 | body: 'hello Kik', 24 | chatId: 123, 25 | type: 'test' 26 | } 27 | ] 28 | }; 29 | it('wires the POST request for kik to the message processor', () => { 30 | expect(api.post.calls.count()).toEqual(1); 31 | expect(api.post).toHaveBeenCalledWith('/kik', jasmine.any(Function)); 32 | }); 33 | describe('processing a single message', () => { 34 | var handler; 35 | beforeEach(() => { 36 | handler = api.post.calls.argsFor(0)[1]; 37 | }); 38 | it('breaks down the message and puts it into the parser', () => { 39 | handler({body: singleMessageTemplate, env: {kikUserName: 'randomKikUserName', kikApiKey: 'RandomAPIKey'}}); 40 | expect(parser).toHaveBeenCalledWith({ 41 | from: 'randomKikUser', 42 | body: 'hello Kik', 43 | chatId: 123, 44 | type: 'test' 45 | }); 46 | }); 47 | it('passes the parsed value to the bot if a message can be parsed', (done) => { 48 | parser.and.returnValue('MSG1'); 49 | handler({body: singleMessageTemplate, env: {}}); 50 | Promise.resolve().then(() => { 51 | expect(bot).toHaveBeenCalledWith('MSG1', { body: singleMessageTemplate, env: {} }); 52 | }).then(done, done.fail); 53 | }); 54 | it('does not invoke the bot if the message cannot be parsed', (done) => { 55 | parser.and.returnValue(false); 56 | handler({body: singleMessageTemplate, env: {}}).then((message) => { 57 | expect(message).toBe('ok'); 58 | expect(bot).not.toHaveBeenCalled(); 59 | }).then(done, done.fail); 60 | }); 61 | it('responds when the bot resolves', (done) => { 62 | parser.and.returnValue({sender: 'user1', text: 'MSG1'}); 63 | botResolve('Kik Kik the bucket'); 64 | handler({body: singleMessageTemplate, env: {kikUserName: 'randomKikUserName', kikApiKey: 'RandomAPIKey'}}).then((message) => { 65 | expect(message).toBe('ok'); 66 | expect(responder).toHaveBeenCalledWith({sender: 'user1', text: 'MSG1'}, 'Kik Kik the bucket', 'randomKikUserName', 'RandomAPIKey'); 67 | }).then(done, done.fail); 68 | }); 69 | it('can work with bot responses as strings', (done) => { 70 | bot.and.returnValue('Kik the bucket'); 71 | parser.and.returnValue({sender: 'user1', text: 'Hello'}); 72 | handler({body: singleMessageTemplate, env: {kikUserName: 'randomKikUserName', kikApiKey: 'RandomAPIKey'}}).then((message) => { 73 | expect(message).toBe('ok'); 74 | expect(responder).toHaveBeenCalledWith({sender: 'user1', text: 'Hello'}, 'Kik the bucket', 'randomKikUserName', 'RandomAPIKey'); 75 | }).then(done, done.fail); 76 | 77 | }); 78 | it('logs error when the bot rejects without responding', (done) => { 79 | parser.and.returnValue('MSG1'); 80 | 81 | handler({body: singleMessageTemplate, env: {}}).then((message) => { 82 | expect(message).toBe('ok'); 83 | expect(responder).not.toHaveBeenCalled(); 84 | expect(logError).toHaveBeenCalledWith('No No'); 85 | }).then(done, done.fail); 86 | 87 | botReject('No No'); 88 | }); 89 | it('logs the error when the responder throws an error', (done) => { 90 | parser.and.returnValue('MSG1'); 91 | responder.and.throwError('XXX'); 92 | botResolve('Yes'); 93 | handler({body: singleMessageTemplate, env: {kikUserName: 'randomKikUserName', kikApiKey: 'RandomAPIKey'}}).then((message) => { 94 | expect(message).toBe('ok'); 95 | expect(logError).toHaveBeenCalledWith(jasmine.any(Error)); 96 | }).then(done, done.fail); 97 | }); 98 | describe('working with promises in responders', () => { 99 | var responderResolve, responderReject, responderPromise, hasResolved; 100 | beforeEach(() => { 101 | responderPromise = new Promise((resolve, reject) => { 102 | responderResolve = resolve; 103 | responderReject = reject; 104 | }); 105 | responder.and.returnValue(responderPromise); 106 | 107 | parser.and.returnValue('MSG1'); 108 | }); 109 | it('waits for the responders to resolve before completing the request', (done) => { 110 | handler({body: singleMessageTemplate, env: {kikUserName: 'randomKikUserName', kikApiKey: 'RandomAPIKey'}}).then(() => { 111 | hasResolved = true; 112 | }); 113 | 114 | botPromise.then(() => { 115 | expect(hasResolved).toBeFalsy(); 116 | }).then(done, done.fail); 117 | 118 | botResolve('YES'); 119 | }); 120 | it('resolves when the responder resolves', (done) => { 121 | handler({body: singleMessageTemplate, env: {kikUserName: 'randomKikUserName', kikApiKey: 'RandomAPIKey'}}).then((message) => { 122 | expect(message).toEqual('ok'); 123 | }).then(done, done.fail); 124 | 125 | botPromise.then(() => { 126 | responderResolve('As Promised!'); 127 | }); 128 | botResolve('YES'); 129 | }); 130 | it('logs error when the responder rejects', (done) => { 131 | handler({body: singleMessageTemplate, env: {kikUserName: 'randomKikUserName', kikApiKey: 'RandomAPIKey'}}).then((message) => { 132 | expect(message).toEqual('ok'); 133 | expect(logError).toHaveBeenCalledWith('Bomb!'); 134 | }).then(done, done.fail); 135 | 136 | botPromise.then(() => { 137 | responderReject('Bomb!'); 138 | }); 139 | botResolve('YES'); 140 | }); 141 | }); 142 | }); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # Claudia Bot Builder API 2 | 3 | The Claudia Bot Builder is based on a simple callback API. Whenever a message is received by an endpoint, the Bot Builder will parse the message and pass it on to your callback. Register the endpoint by executing the `claudia-bot-builder` function and export the result from your module. 4 | 5 | ```javascript 6 | const botBuilder = require('claudia-bot-builder'); 7 | 8 | module.exports = botBuilder(function (message, originalApiRequest) { 9 | return `I got ${message.text}`; 10 | }); 11 | ``` 12 | 13 | The first argument is the message object, as explained below. 14 | 15 | The second argument (since version `1.2.0`) is the [Claudia API Builder](https://github.com/claudiajs/claudia-api-builder/blob/master/docs/api.md#the-request-object) request object, with all the details of the HTTP request and Lambda context. 16 | 17 | ## Selecting platforms 18 | 19 | By default, Bot Builder will set up endpoints for all supported platforms. This can slow down deployment unnecessarily if you only want to use one or two bot engines. Pass the second optional argument to the `botBuilder` function, and include a list of platform names into the `platforms` array key to limit the deployed platform APIs: 20 | 21 | ```javascript 22 | const botBuilder = require('claudia-bot-builder'); 23 | 24 | module.exports = botBuilder(function (message, originalApiRequest) { 25 | return `I got ${message.text}`; 26 | }, { platforms: ['facebook', 'twilio'] }); 27 | ``` 28 | 29 | The list of platform names can include: `facebook`, `slackSlashCommand`, `telegram`, `skype`, `twilio`, `kik`, `groupme`, `viber`, `alexa`, `line`. 30 | 31 | ## Message object structure 32 | 33 | The message object contains the following fields 34 | 35 | * **`text`**: `string` the text of the message received, extracted from a bot-specific format. In most cases, if you just want to reply to text messages, this is the only piece of information you'll need. 36 | * **`type`**: `string` the type of the end-point receiving the message. It can be `facebook`, `slack-slash-command`, `skype`, `telegram`, `twilio`, `alexa`, `viber`, `kik` or `groupme` 37 | * **`originalRequest`**: `object` the complete original message, in a bot-specific format, useful if you want to do more than just reply to text messages. 38 | * **`sender`**: `string` the identifier of the sender 39 | * **`postback`**: `boolean` true if the message is the result of a post-back (for example clicking on a button created by a previous message in Facebook). It will be `undefined` (falsy) for completely new messages. 40 | 41 | _Note_: FB Messenger message echoes, delivery and read reports will not be parsed. 42 | 43 | ## Message verification 44 | 45 | _Claudia Bot Builder_ will verify message payload as recommended for each platform. 46 | 47 | However, Facebook Messenger beside token validation offers additional security via `X-Hub-Signature` header, but it requires your Facebook App Secret. 48 | 49 | This security step is also available in _Claudia Bot Builder_ but it is optional in current version. This will become mandatory in the next major version, so please run `claudia update` with `--configure-fb-bot` flag and set your Facebook App Secret on your next update or any time before the next major version. 50 | 51 | You can read more about security check for Facebook Messenger in [Messenger's documentation](https://developers.facebook.com/docs/messenger-platform/webhook-reference#security). 52 | 53 | ## Reply formats 54 | 55 | If you reply with a string, the response will be packaged in a bot-specific format representing a simple text message. _Claudia Bot Builder_ helps in that way to handle generic simple text responses easily. 56 | 57 | Individual bots support more complex responses, such as buttons, attachments and so on. You can send all those responses by replying with an object, instead of a string. In that case, _Claudia Bot Builder_ does not transform the response at all, and just passes it back to the sender. It's then your responsibility to ensure that the resulting object is in the correct format for the bot engine. Use `request.type` to discover the bot engine sending the requests. 58 | 59 | If you reply with an array multiple messages will be sent in sequence. Each array item can be text or already formatted object and it'll follow the same rules explained above. At the moment, this is supported for Facebook Messenger only. 60 | 61 | Additionally, _Claudia Bot Builder_ exports message generators for for generating more complex responses including buttons and attachments for Facebook and Slack and function for sending delayed/multiple replies. 62 | 63 | For the details see: 64 | 65 | - [Facebook Template Message builder documentation](FB_TEMPLATE_MESSAGE_BUILDER.md) 66 | - [Skype custom messages documentation](SKYPE_CUSTOM_MESSAGES.md) 67 | - [Slack Message builder documentation](SLACK_MESSAGE_MESSAGE_BUILDER.md) 68 | - [Slack Delayed reply documentation](SLACK_DELAYED_REPLY_BUILDER.md) 69 | - [Telegram custom messages documentation](TELEGRAM_CUSTOM_MESSAGES.md) 70 | - [Viber custom messages documentation](VIBER_CUSTOM_MESSAGES.md) 71 | - [Alexa custom messages documentation](https://github.com/stojanovic/alexa-message-builder#documentation), external link, because Claudia Bot Builder is using [Alexa Message Builder module](https://www.npmjs.com/package/alexa-message-builder) for Alexa custom messages. 72 | 73 | ### Synchronous replies 74 | 75 | Just return the result from the function. 76 | 77 | ### Asynchronous replies 78 | 79 | Return a `Promise` from the callback function, and resolve the promise later with a string or object. The convention is the same as for synchronous replies. 80 | 81 | If you plan to reply asynchronously, make sure to configure your lambda function so it does not get killed prematurely. By default, Lambda functions are only allowed to run for 3 seconds. See [update-function-configuration](http://docs.aws.amazon.com/cli/latest/reference/lambda/update-function-configuration.html) in the AWS Command Line tools for information on how to change the default timeout. 82 | 83 | ## Bot configuration 84 | 85 | _Claudia Bot Builder_ automates most of the configuration tasks, and stores access keys and tokens into API Gateway stage variables. You can configure those interactively while executing `claudia create` or `claudia update` by passing an additional argument from the command line: 86 | 87 | * For Facebook messenger bots, use `--configure-fb-bot` 88 | * For Slack App slash commands, use `--configure-slack-slash-app` 89 | * For Slack slash commands for your team, use `--configure-slack-slash-command` 90 | * For Skype, use `--configure-skype-bot` 91 | * For Viber, use `--configure-viber-bot` 92 | * For Line, use `--configure-line-bot` 93 | * For Telegram, use `--configure-telegram-bot` 94 | * For Twilio, use `--configure-twilio-sms-bot` 95 | * For Amazon Alexa, use `--configure-alexa-skill` 96 | * For Kik, use `--configure-kik-bot` 97 | * For GroupMe, use `--configure-groupme-bot` 98 | 99 | You need to do this only once per version. If you create different versions for development, testing and production, remember to configure the bots. 100 | 101 | An example tutorial for creating a bot with these you can find on [Claudia.js Hello World Chatbot](https://claudiajs.com/tutorials/hello-world-chatbot.html) 102 | -------------------------------------------------------------------------------- /lib/telegram/format-message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class TelegramMessage { 4 | constructor() { 5 | this.template = {}; 6 | } 7 | 8 | disableNotification() { 9 | this.template.disable_notification = true; 10 | } 11 | 12 | addReplyKeyboard(keyboardArray, resizeKeyboard, oneTimeKeyboard) { 13 | if (!Array.isArray(keyboardArray)) 14 | throw new Error('KeyboardArray needs to be valid array of arrays for addReplyKeyboard method'); 15 | 16 | const replyKeyboard = { 17 | keyboard: keyboardArray 18 | }; 19 | 20 | if (resizeKeyboard) 21 | replyKeyboard.resize_keyboard = true; 22 | 23 | if (oneTimeKeyboard) 24 | replyKeyboard.one_time_keyboard = true; 25 | 26 | this.template.reply_markup = JSON.stringify(replyKeyboard); 27 | 28 | return this; 29 | } 30 | 31 | addInlineKeyboard(keyboardArray) { 32 | if (!Array.isArray(keyboardArray)) 33 | throw new Error('KeyboardArray needs to be valid array of arrays for addInlineKeyboard method'); 34 | 35 | const inlineKeyboard = { 36 | inline_keyboard: keyboardArray 37 | }; 38 | 39 | this.template.reply_markup = JSON.stringify(inlineKeyboard); 40 | 41 | return this; 42 | } 43 | 44 | replyKeyboardHide(selective) { 45 | const replyKeyboardHide = { 46 | hide_keyboard: true, 47 | selective: !!selective 48 | }; 49 | 50 | this.template.reply_markup = JSON.stringify(replyKeyboardHide); 51 | 52 | return this; 53 | } 54 | 55 | forceReply(selective) { 56 | const forceReply = { 57 | force_reply: true, 58 | selective: !!selective 59 | }; 60 | 61 | this.template.reply_markup = JSON.stringify(forceReply); 62 | 63 | return this; 64 | } 65 | 66 | get() { 67 | return this.template; 68 | } 69 | } 70 | 71 | class Text extends TelegramMessage { 72 | constructor(text) { 73 | super(); 74 | if (!text || typeof text !== 'string') 75 | throw new Error('Text is required for Telegram Text template'); 76 | 77 | this.template = { 78 | text: text, 79 | parse_mode: 'Markdown' 80 | }; 81 | } 82 | 83 | disableMarkdown() { 84 | delete this.template.parse_mode; 85 | return this; 86 | } 87 | } 88 | 89 | class Photo extends TelegramMessage { 90 | constructor(photo, caption) { 91 | super(); 92 | if (!photo || typeof photo !== 'string') 93 | throw new Error('Photo needs to be an ID or URL for Telegram Photo method'); 94 | 95 | this.template = { 96 | photo: photo 97 | }; 98 | 99 | if (caption && typeof caption === 'string') 100 | this.template.caption = caption; 101 | } 102 | 103 | get() { 104 | return { 105 | method: 'sendPhoto', 106 | body: this.template 107 | }; 108 | } 109 | } 110 | 111 | class Audio extends TelegramMessage { 112 | constructor(audio, caption, duration) { 113 | super(); 114 | if (!audio || typeof audio !== 'string') 115 | throw new Error('Audio needs to be an ID or URL for Telegram Audio method'); 116 | 117 | this.template = { 118 | audio: audio 119 | }; 120 | 121 | if (caption && typeof caption === 'string') 122 | this.template.caption = caption; 123 | 124 | if (duration && typeof duration === 'number') 125 | this.template.duration = duration; 126 | } 127 | 128 | addTitle(title) { 129 | if (!title || typeof title != 'string') 130 | throw new Error('Title is required for Telegram addTitle method'); 131 | 132 | this.template.title = title; 133 | 134 | return this; 135 | } 136 | 137 | addPerformer(performer) { 138 | if (!performer) 139 | throw new Error('Performer is required for Telegram addPerformer method'); 140 | 141 | this.template.performer = performer; 142 | 143 | return this; 144 | } 145 | 146 | get() { 147 | return { 148 | method: 'sendAudio', 149 | body: this.template 150 | }; 151 | } 152 | } 153 | 154 | class Location extends TelegramMessage { 155 | constructor(latitude, longitude) { 156 | super(); 157 | if (!latitude || !longitude || typeof latitude !== 'number' || typeof longitude !== 'number') 158 | throw new Error('Latitude and longitude are required for Telegram Location template'); 159 | 160 | this.template = { 161 | latitude: latitude, 162 | longitude: longitude 163 | }; 164 | } 165 | 166 | get() { 167 | return { 168 | method: 'sendLocation', 169 | body: this.template 170 | }; 171 | } 172 | } 173 | 174 | class Venue extends TelegramMessage { 175 | constructor(latitude, longitude, title, address) { 176 | super(); 177 | if (!latitude || !longitude || typeof latitude !== 'number' || typeof longitude !== 'number') 178 | throw new Error('Latitude and longitude are required for Telegram Venue template'); 179 | 180 | if (!title || typeof title !== 'string') 181 | throw new Error('Title is required for Telegram Venue template'); 182 | 183 | if (!address || typeof address !== 'string') 184 | throw new Error('Address is required for Telegram Venue template'); 185 | 186 | this.template = { 187 | latitude: latitude, 188 | longitude: longitude, 189 | title: title, 190 | address: address 191 | }; 192 | } 193 | 194 | addFoursqare(foursquareId) { 195 | if (!foursquareId) 196 | throw new Error('Foursquare ID is required for Telegram Venue template addFoursqare method'); 197 | 198 | this.template.foursquare_id = foursquareId; 199 | 200 | return this; 201 | } 202 | 203 | get() { 204 | return { 205 | method: 'sendVenue', 206 | body: this.template 207 | }; 208 | } 209 | } 210 | 211 | class ChatAction extends TelegramMessage { 212 | constructor(action) { 213 | super(); 214 | const AVAILABLE_TYPES = ['typing', 'upload_photo', 'record_video', 'upload_video', 'record_audio', 'upload_audio', 'upload_document', 'find_location']; 215 | 216 | if (AVAILABLE_TYPES.indexOf(action) < 0) 217 | throw new Error('Valid action is required for Telegram ChatAction template. Check https://core.telegram.org/bots/api#sendchataction for all available actions.'); 218 | 219 | this.template = { 220 | action: action 221 | }; 222 | } 223 | 224 | get() { 225 | return { 226 | method: 'sendChatAction', 227 | body: this.template 228 | }; 229 | } 230 | } 231 | 232 | class Pause { 233 | constructor(miliseconds) { 234 | this.template = { 235 | claudiaPause: miliseconds || 500 236 | }; 237 | } 238 | 239 | get() { 240 | return this.template; 241 | } 242 | } 243 | 244 | class File extends TelegramMessage { 245 | constructor(document, caption) { 246 | super(); 247 | if (!document || typeof document !== 'string') 248 | throw new Error('Document needs to be an URL for the Telegram File method'); 249 | 250 | this.template = { 251 | document: document 252 | }; 253 | 254 | // caption is optional 255 | if (caption && typeof caption === 'string') 256 | this.template.caption = caption; 257 | } 258 | 259 | get() { 260 | return { 261 | method: 'sendDocument', 262 | body: this.template 263 | }; 264 | } 265 | } 266 | 267 | class Sticker extends TelegramMessage { 268 | constructor(sticker) { 269 | super(); 270 | if (!sticker || typeof sticker !== 'string') 271 | throw new Error('Sticker needs to be an URL or sticker ID for the Telegram Sticker method'); 272 | 273 | this.template = { 274 | sticker: sticker 275 | }; 276 | } 277 | 278 | get() { 279 | return { 280 | method: 'sendSticker', 281 | body: this.template 282 | }; 283 | } 284 | } 285 | 286 | class Contact extends TelegramMessage { 287 | constructor(phone, firstName, lastName) { 288 | super(); 289 | if (!phone || typeof phone !== 'string') 290 | throw new Error('Phone number needs to be a string for Telegram Contact method'); 291 | 292 | if (!firstName || typeof firstName !== 'string') 293 | throw new Error('First name needs to be a string for Telegram Contact method'); 294 | 295 | this.template = { 296 | phone_number: phone, 297 | first_name: firstName 298 | }; 299 | 300 | // lastName is optional 301 | if (lastName && typeof lastName === 'string') 302 | this.template.last_name = lastName; 303 | } 304 | 305 | get() { 306 | return { 307 | method: 'sendContact', 308 | body: this.template 309 | }; 310 | } 311 | } 312 | 313 | module.exports = { 314 | Text: Text, 315 | Photo: Photo, 316 | Audio: Audio, 317 | Location: Location, 318 | Venue: Venue, 319 | ChatAction: ChatAction, 320 | Pause: Pause, 321 | File: File, 322 | Sticker: Sticker, 323 | Contact: Contact 324 | }; 325 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Claudia Bot Builder 2 | 3 | [![npm](https://img.shields.io/npm/v/claudia-bot-builder.svg?maxAge=2592000?style=plastic)](https://www.npmjs.com/package/claudia-bot-builder) 4 | [![npm](https://img.shields.io/npm/dt/claudia-bot-builder.svg?maxAge=2592000?style=plastic)](https://www.npmjs.com/package/claudia-bot-builder) 5 | [![npm](https://img.shields.io/npm/l/claudia-bot-builder.svg?maxAge=2592000?style=plastic)](https://github.com/claudiajs/claudia-bot-builder/blob/master/LICENSE) 6 | [![Build Status](https://travis-ci.org/claudiajs/claudia-bot-builder.svg?branch=master)](https://travis-ci.org/claudiajs/claudia-bot-builder) 7 | [![Join the chat at https://gitter.im/claudiajs/claudia](https://badges.gitter.im/claudiajs/claudia.svg)](https://gitter.im/claudiajs/claudia?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 8 | 9 | _Claudia Bot Builder_ helps developers create and deploy chat-bots for various platforms in minutes to AWS Lambda. It simplifies the messaging workflows, automatically sets up the correct web hooks, and guides you through configuration steps, so that you can focus on important business problems and not have to worry about infrastructure code. 10 | 11 | | [🚀 Getting Started](https://claudiajs.com/tutorials/hello-world-chatbot.html) | [🛠 API Docs](docs/API.md) | [🤖 Example projects](https://github.com/claudiajs/example-projects#chat-bots) | [🤔 FAQ](#frequently-asked-questions) | [💬 Chat on Gitter](https://gitter.im/claudiajs/claudia) | 12 | |-----------------|----------|------------------|-----|----| 13 | 14 | Check out [this two minute video](https://vimeo.com/170647056) to see how you can create and deploy a bot quickly: 15 | 16 | [![](https://claudiajs.com/assets/claudia-bot-builder-video.jpg)](https://vimeo.com/170647056) 17 | 18 | Here's a simple example: 19 | 20 | ```javascript 21 | const botBuilder = require('claudia-bot-builder'); 22 | const excuse = require('huh'); 23 | 24 | module.exports = botBuilder(function (message) { 25 | return 'Thanks for sending ' + message.text + 26 | 'Your message is very important to us, but ' + 27 | excuse.get(); 28 | }); 29 | ``` 30 | 31 | This code is enough to operate bots for all supported platforms. Claudia Bot Builder automatically parses the incoming messages into a common format, so you can handle it easily. It also automatically packages the response into the correct message template for the requesting bot, so you do not have to worry about individual bot protocols. 32 | 33 | ## Supported platforms 34 | 35 | * Facebook Messenger 36 | * Slack (channel slash commands and apps with slash commands) 37 | * Skype 38 | * Viber 39 | * Telegram 40 | * Twilio (messaging service) 41 | * Amazon Alexa 42 | * Line 43 | * Kik 44 | * GroupMe 45 | 46 | ## Creating bots 47 | 48 | [![](https://nodei.co/npm/claudia-bot-builder.svg?downloads=true&downloadRank=true&stars=true)](https://www.npmjs.com/package/claudia-bot-builder) 49 | 50 | Check out the [Getting Started](https://claudiajs.com/tutorials/hello-world-chatbot.html) guide for information on how to set up a simple bot in minutes and [API Documentation](docs/API.md) for detailed information on the API. 51 | 52 | ## Examples 53 | 54 | See the [Chat-Bots section](https://github.com/claudiajs/example-projects#chat-bots) of the Claudia.js example projects list 55 | 56 | ## Frequently asked questions 57 | 58 | 1. **How to run it locally?** 59 | 60 | You can't. At least not easy. Claudia Bot Builder doesn't have a stand-alone http server in the background (such as Express, Hapi, etc.), instead it uses API Gateway and it's not trivial to simulate similar environment locally. Deploy it with `--version test` to create a separate test environment directly in AWS Lambda. 61 | 62 | 2. **How to test your bot?** 63 | 64 | Your chat bot is just a Lambda function, which means it is just a simple JavaScript function and you should be able to, at least in theory, run everything locally as simple automated tests. 65 | 66 | The most important thing is to design testable Lambda functions, [this guide](https://claudiajs.com/tutorials/designing-testable-lambdas.html) will help you to do that. 67 | 68 | Integration tests can be a bit more complex if you have some integrations with external or AWS services. Check [this guide](https://claudiajs.com/tutorials/testing-locally.html) to see how to write integration tests and run automated tests locally. 69 | 70 | 3. **My Facebook messenger bot responds to my messages only. Why it's not responding to everyone?** 71 | 72 | Facebook has [a review process](https://developers.facebook.com/docs/messenger-platform/app-review) for chat bots. Make sure your bot is approved. 73 | 74 | 4. **Can I send Slack slash command delayed responses?** 75 | 76 | Yes, here's [the tutorial for that](https://claudiajs.com/tutorials/slack-delayed-responses.html). 77 | 78 | 5. **What's new in v2?** 79 | 80 | It's a new major version because of the dependencies - there are big improvements in the _Claudia API Builder_ and _Claudia_, so _Claudia Bot Builder_ v1.x is not compatible with them. 81 | V2.x also brings support for many new platforms. 82 | 83 | 6. **How to speed up the deployment** 84 | 85 | You can use `claudia update` with `--cache-api-config` flag to cache the API Gateway config, for more info visit [docs page for claudia update](https://github.com/claudiajs/claudia/blob/master/docs/update.md). 86 | 87 | Also, from version 2.7.0, you can disable platforms that you are not using, check the full explanation in the [API docs](https://github.com/claudiajs/claudia-bot-builder/blob/master/docs/API.md#selecting-platforms). 88 | 89 | Have a question that is not on this list? Feel free to ask it on [Claudia chat on Gitter](https://gitter.im/claudiajs/claudia). 90 | 91 | _Please, do not use GitHub issues for asking questions or requesting assistance/support, use it only to report bugs._ 92 | 93 | ## Contributing 94 | 95 | Contributions are greatly appreciated. See the [Contributors' guide](CONTRIBUTING.md) for information on running and testing code. 96 | 97 | ## What's new since...? 98 | 99 | See the [Release History](https://github.com/claudiajs/claudia-bot-builder/releases) 100 | 101 | ## Cool things built with _Claudia bot Builder_ 102 | 103 | - [AWS Bot for Slack](https://github.com/andypowe11/AWS-Claudia-AWSBot) - A Slack bot to stop and start selected AWS EC2 instances and generally keep an eye on your AWS estate. 104 | - [Comic Book Bot](https://github.com/stojanovic/comic-book-bot) - A simple Viber chatbot for Marvel characters. 105 | - [DotCom Bot](http://dotcom.montoyaindustries.com) - Search & buy domain names and check @usernames fast on Slack & Facebook Messenger! 106 | - [Eksplorer](http://eksplo.weebly.com) - The Facebook chat bot that will help you discover amazing things in your neighborhood. 107 | - [Fact Bot](https://github.com/claudiajs/example-projects/tree/master/bot-with-buttons) - The bot will query WikiData for anything you send it and print out the facts. 108 | - [Food Recommendation Bot](https://github.com/lnmunhoz/food-recommendation-bot) - Shows you open restaurants around you based on Google Places API. 109 | - [JS Belgrade bot](https://github.com/JSBelgrade/jsbelgrade-chatbot) - Simple meetup group Telegram chatbot created during the meetup. 110 | - [LaptopFriendly Bot](https://github.com/stojanovic/laptop-friendly-bot) - Viber bot for [LaptopFriendly.co](https://laptopfriendly.co). 111 | - [MrRoadboto](https://github.com/antsankov/MrRoadboto) - A low-bandwidth and easy to use Facebook chat bot that serves Colorado's Department of Transportation (CDOT) alerts for I70 road-closures affecting major ski resorts. You can read about the motivation [here](https://medium.com/@antsankov/domo-arigato-mr-roadboto-pt-1-introducing-the-problem-b0d44e384dc#.tcsq9nrs4). 112 | - [PingdomBot](https://github.com/andypowe11/AWS-Claudia-PingdomBot) - A Slack bot to see the status of Pingdom website monitoring. 113 | - [Pokemon Lookup bot](https://www.facebook.com/PokedexLookup/) - Simple pokemon lookup bot, [source code](https://github.com/kirkins/PokedexBot). 114 | - [QRCode Bot](https://www.facebook.com/QRCode-Bot-1779956152289103/) - Artistic QR code maker, [source code](https://github.com/jveres/qrcode-bot). 115 | - [Quote bot](https://github.com/philnash/quote-bot) - A very simple bot that will respond with an inspirational quote from the Forismatic API. 116 | - [Robbert](https://www.facebook.com/Robbert-1119546194768078) - General chatbot. 117 | - [slackslash-radar](https://github.com/Ibuprofen/slackslash-radar) - A Claudiajs bot which retrieves a Wunderground radar animated gif and posts to Slack. 118 | - [Space Explorer Bot](https://github.com/stojanovic/space-explorer-bot) - A simple Messenger chat bot that uses NASA's API to get the data and images about the Space. 119 | - [Space Explorer Bot for Viber](https://github.com/stojanovic/space-explorer-bot-viber) - Viber version of Space Explorer Bot. 120 | - [Vacation tracker bot](http://vacationtrackerbot.com/) - A simple Slack bot to help you manage your team’s vacations, sick days and days off. 121 | - [MDNBot](https://vejather.github.io/mdn-bot-landing-page/) - A Slack bot that helps developers search MDN directory without leaving Slack channel. 122 | - [Ver.bot](https://rping.github.io/Ver.bot-site) - Subscribe GitHub, npm, PyPI projects, and get new version releases notifications! 123 | 124 | Building something cool with Claudia Bot Builder? Let us know or send a PR to update this list! 125 | 126 | ## Authors 127 | 128 | * [Gojko Adžić](https://github.com/gojko) 129 | * [Aleksandar Simović](https://github.com/simalexan) 130 | * [Slobodan Stojanović](https://github.com/stojanovic) 131 | 132 | ## License 133 | 134 | MIT -- see [LICENSE](LICENSE) 135 | -------------------------------------------------------------------------------- /spec/viber/viber-format-message-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | 'use strict'; 3 | 4 | const formatMessage = require('../../lib/viber/format-message'); 5 | 6 | describe('Viber format message', () => { 7 | it('should export an object', () => { 8 | expect(typeof formatMessage).toBe('object'); 9 | }); 10 | 11 | describe('Text', () => { 12 | it('should be a class', () => { 13 | const message = new formatMessage.Text('text'); 14 | expect(typeof formatMessage.Text).toBe('function'); 15 | expect(message instanceof formatMessage.Text).toBeTruthy(); 16 | }); 17 | 18 | it('should throw an error if text is not provided', () => { 19 | expect(() => new formatMessage.Text()).toThrowError('Text is required for the Viber Text template'); 20 | }); 21 | 22 | it('should generate a valid Viber template object', () => { 23 | const message = new formatMessage.Text('Some text').get(); 24 | expect(message).toEqual({ 25 | type: 'text', 26 | text: 'Some text' 27 | }); 28 | }); 29 | 30 | it('should add reply keyboard', () => { 31 | const message = new formatMessage 32 | .Text('Some text') 33 | .addReplyKeyboard(true, '#FAFAFA') 34 | .get(); 35 | expect(message).toEqual({ 36 | type: 'text', 37 | text: 'Some text', 38 | keyboard: { 39 | Type: 'keyboard', 40 | DefaultHeight: true, 41 | BgColor: '#FAFAFA', 42 | Buttons: [] 43 | } 44 | }); 45 | }); 46 | 47 | it('should add reply keyboard with reply action button', () => { 48 | const message = new formatMessage.Text('Some text') 49 | .addReplyKeyboard(true) 50 | .addKeyboardButton('test', 'test body', 2, 1) 51 | .get(); 52 | expect(message).toEqual({ 53 | type: 'text', 54 | text: 'Some text', 55 | keyboard: { 56 | Type: 'keyboard', 57 | DefaultHeight: true, 58 | BgColor: '#FFFFFF', 59 | Buttons: [ 60 | { 61 | Text: 'test', 62 | ActionType: 'reply', 63 | ActionBody: 'test body', 64 | Columns: 2, 65 | Rows: 1 66 | } 67 | ] 68 | } 69 | }); 70 | }); 71 | 72 | it('should reply text and add reply keyboard with open-url action button', () => { 73 | const message = new formatMessage.Text('Claudia.js') 74 | .addReplyKeyboard() 75 | .addKeyboardButton('Open Claudia.js website', 'https://claudiajs.com', 2, 1) 76 | .get(); 77 | expect(message).toEqual({ 78 | type: 'text', 79 | text: 'Claudia.js', 80 | keyboard: { 81 | Type: 'keyboard', 82 | DefaultHeight: true, 83 | BgColor: '#FFFFFF', 84 | Buttons: [ 85 | { 86 | Text: 'Open Claudia.js website', 87 | ActionType: 'open-url', 88 | ActionBody: 'https://claudiajs.com', 89 | Columns: 2, 90 | Rows: 1 91 | } 92 | ] 93 | } 94 | }); 95 | }); 96 | }); 97 | 98 | it('should reply text and add reply keyboard with open-url action button and button color if custom object is passed', () => { 99 | const message = new formatMessage.Text('Claudia.js') 100 | .addReplyKeyboard() 101 | .addKeyboardButton('Open Claudia.js website', 'https://claudiajs.com', 2, 1, { 102 | BgColor: '#BADA55' 103 | }) 104 | .get(); 105 | expect(message).toEqual({ 106 | type: 'text', 107 | text: 'Claudia.js', 108 | keyboard: { 109 | Type: 'keyboard', 110 | DefaultHeight: true, 111 | BgColor: '#FFFFFF', 112 | Buttons: [ 113 | { 114 | Text: 'Open Claudia.js website', 115 | ActionType: 'open-url', 116 | ActionBody: 'https://claudiajs.com', 117 | Columns: 2, 118 | Rows: 1, 119 | BgColor: '#BADA55' 120 | } 121 | ] 122 | } 123 | }); 124 | }); 125 | 126 | describe('Photo', () => { 127 | it('should be a class', () => { 128 | const message = new formatMessage.Photo('https://claudiajs.com/assets/claudiajs.svg', 'Claudia.js photo text'); 129 | expect(typeof formatMessage.Photo).toBe('function'); 130 | expect(message instanceof formatMessage.Photo).toBeTruthy(); 131 | }); 132 | 133 | it('should throw an error if photo url is not provided', () => { 134 | expect(() => new formatMessage.Photo()).toThrowError('Photo needs to be an URL for the Viber Photo method'); 135 | }); 136 | 137 | it('should throw an error if photo text is provided but not string', () => { 138 | expect(() => new formatMessage.Photo('https://claudiajs.com/assets/claudiajs.svg', { foo: 'bar' })).toThrowError('Text needs to be a string for Viber Photo method'); 139 | }); 140 | 141 | it('should generate a valid Viber template object', () => { 142 | const message = new formatMessage.Photo('https://claudiajs.com/assets/claudiajs.svg', 'Claudia.js photo text').get(); 143 | expect(message).toEqual({ 144 | type: 'picture', 145 | media: 'https://claudiajs.com/assets/claudiajs.svg', 146 | text: 'Claudia.js photo text' 147 | }); 148 | }); 149 | 150 | it('should generate a valid Viber template object with an empty text if it is not provided', () => { 151 | const message = new formatMessage.Photo('https://claudiajs.com/assets/claudiajs.svg').get(); 152 | expect(message).toEqual({ 153 | type: 'picture', 154 | media: 'https://claudiajs.com/assets/claudiajs.svg', 155 | text: '' 156 | }); 157 | }); 158 | 159 | it('should generate a valid Telegram template object with caption', () => { 160 | const message = new formatMessage.Photo('https://claudiajs.com/assets/claudiajs.svg', 'Claudia.js photo text').get(); 161 | expect(message).toEqual({ 162 | type: 'picture', 163 | media: 'https://claudiajs.com/assets/claudiajs.svg', 164 | text: 'Claudia.js photo text' 165 | }); 166 | }); 167 | }); 168 | 169 | describe('Video', () => { 170 | it('should be a class', () => { 171 | const message = new formatMessage.Video('https://vimeo.com/170647056', 25600, 156); 172 | expect(typeof formatMessage.Video).toBe('function'); 173 | expect(message instanceof formatMessage.Video).toBeTruthy(); 174 | }); 175 | 176 | it('should throw an error if video url is not available', () => { 177 | expect(() => new formatMessage.Video()).toThrowError('Media needs to be an URL for Viber Video method'); 178 | }); 179 | 180 | it('should throw an error if video size is not available', () => { 181 | expect(() => new formatMessage.Video('https://vimeo.com/170647056')).toThrowError('Size needs to be a Number representing size in bytes for Viber Video method'); 182 | }); 183 | 184 | it('should generate a valid Viber Video template object', () => { 185 | const message = new formatMessage.Video('https://vimeo.com/170647056', 25600, 156).get(); 186 | expect(message).toEqual({ 187 | type: 'video', 188 | media: 'https://vimeo.com/170647056', 189 | size: 25600, 190 | duration: 156 191 | }); 192 | }); 193 | }); 194 | 195 | describe('File', () => { 196 | it('should be a class', () => { 197 | const message = new formatMessage.File('https://some.file.com/address.md', 25600, 'addressing'); 198 | expect(typeof formatMessage.File).toBe('function'); 199 | expect(message instanceof formatMessage.File).toBeTruthy(); 200 | }); 201 | 202 | it('should throw an error if file url is not available', () => { 203 | expect(() => new formatMessage.File()).toThrowError('Media needs to be an URL for the Viber File method'); 204 | }); 205 | 206 | it('should throw an error if file size is not available', () => { 207 | expect(() => new formatMessage.File('https://some.file.com/address.md')).toThrowError('Size needs to be a Number representing size in bytes for the Viber File method'); 208 | }); 209 | 210 | it('should throw an error if file_name is not available', () => { 211 | expect(() => new formatMessage.File('https://some.file.com/address.md', 25600)).toThrowError('File name needs to be a String representing the name of the file for the Viber File method'); 212 | }); 213 | 214 | it('should generate a valid Viber File template object', () => { 215 | const message = new formatMessage.File('https://some.file.com/address.md', 25600, 'addressing').get(); 216 | expect(message).toEqual({ 217 | type: 'file', 218 | media: 'https://some.file.com/address.md', 219 | size: 25600, 220 | file_name: 'addressing' 221 | }); 222 | }); 223 | }); 224 | 225 | describe('Contact', () => { 226 | it('should be a class', () => { 227 | const message = new formatMessage.Contact('claudia', '+333333333'); 228 | expect(typeof formatMessage.Contact).toBe('function'); 229 | expect(message instanceof formatMessage.Contact).toBeTruthy(); 230 | }); 231 | 232 | it('should throw an error if contact name and contact phone number are not valid', () => { 233 | expect(() => new formatMessage.Contact()).toThrowError('Contact name and phone number are required for the Viber Contact template'); 234 | }); 235 | 236 | it('should generate a valid Viber Contact template object', () => { 237 | const message = new formatMessage.Contact('claudia', '+333333333').get(); 238 | expect(message).toEqual({ 239 | type: 'contact', 240 | contact: { 241 | name: 'claudia', 242 | phone_number: '+333333333' 243 | } 244 | }); 245 | }); 246 | }); 247 | 248 | describe('Location', () => { 249 | it('should be a class', () => { 250 | const message = new formatMessage.Location(20, 44); 251 | expect(typeof formatMessage.Location).toBe('function'); 252 | expect(message instanceof formatMessage.Location).toBeTruthy(); 253 | }); 254 | 255 | it('should throw an error if latitude and longitude are not valid', () => { 256 | expect(() => new formatMessage.Location()).toThrowError('Latitude and longitude are required for the Viber Location template'); 257 | }); 258 | 259 | it('should generate a valid Viber Location template object', () => { 260 | const message = new formatMessage.Location(20, 44).get(); 261 | expect(message).toEqual({ 262 | type: 'location', 263 | location: { 264 | lat: 20, 265 | lon: 44 266 | } 267 | }); 268 | }); 269 | }); 270 | 271 | describe('Url', () => { 272 | it('should be a class', () => { 273 | const message = new formatMessage.Url('https://claudiajs.com'); 274 | expect(typeof formatMessage.Url).toBe('function'); 275 | expect(message instanceof formatMessage.Url).toBeTruthy(); 276 | }); 277 | 278 | it('should throw an error if media url is not valid', () => { 279 | expect(() => new formatMessage.Url()).toThrowError('Media needs to be an URL for the Viber URL method'); 280 | }); 281 | 282 | it('should generate a valid Viber Url template object', () => { 283 | const message = new formatMessage.Url('https://claudiajs.com').get(); 284 | expect(message).toEqual({ 285 | type: 'url', 286 | media: 'https://claudiajs.com' 287 | }); 288 | }); 289 | }); 290 | 291 | }); 292 | --------------------------------------------------------------------------------