├── test ├── lib │ ├── views │ │ ├── testView.pug │ │ └── testModal.pug │ ├── customViews │ │ ├── customView.pug │ │ └── customModal.pug │ ├── normalizeObject.test.js │ ├── unpackView.test.js │ ├── plugins.test.js │ └── aido.test.js ├── middleware │ ├── emptyResponse.test.js │ ├── normalizeBody.test.js │ ├── checkToken.test.js │ └── initSlash.test.js ├── slash │ ├── render.test.js │ ├── transport.test.js │ └── session.test.js └── stubs.js ├── docs ├── assets │ ├── number.png │ └── aido-lifecycle.png └── API.md ├── .gitignore ├── examples ├── number │ ├── number.pug │ └── index.js ├── todo │ ├── views │ │ ├── addItem.pug │ │ ├── removeItem.pug │ │ └── todo.pug │ ├── index.js │ └── slash │ │ └── todo.js └── polls │ ├── views │ ├── poll.pug │ ├── viewPoll.pug │ └── editPoll.pug │ ├── index.js │ ├── database │ ├── index.js │ ├── option.js │ ├── poll.js │ └── vote.js │ ├── plugins │ └── polls.js │ └── slash │ └── polack.js ├── lib ├── middleware │ ├── emptyResponse.js │ ├── normalizeBody.js │ ├── checkToken.js │ └── initSlash.js ├── utils │ ├── logger.js │ ├── normalizeObject.js │ ├── customApi.js │ └── detectViews.js ├── templaters │ └── pug.js ├── database │ ├── migrations │ │ └── 20200411-add-oauth.js │ ├── oauth.js │ ├── session.js │ ├── createTable.js │ └── index.js ├── routes.js ├── index.js └── slash.js ├── .eslintrc.json ├── package.json └── README.md /test/lib/views/testView.pug: -------------------------------------------------------------------------------- 1 | body 2 | p Test paragraph 3 | -------------------------------------------------------------------------------- /test/lib/customViews/customView.pug: -------------------------------------------------------------------------------- 1 | body 2 | p Test paragraph 3 | -------------------------------------------------------------------------------- /test/lib/views/testModal.pug: -------------------------------------------------------------------------------- 1 | body(class="modal") 2 | p A test paragraph 3 | -------------------------------------------------------------------------------- /test/lib/customViews/customModal.pug: -------------------------------------------------------------------------------- 1 | body(class="modal") 2 | p A test paragraph 3 | -------------------------------------------------------------------------------- /docs/assets/number.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidojs/aido/HEAD/docs/assets/number.png -------------------------------------------------------------------------------- /docs/assets/aido-lifecycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidojs/aido/HEAD/docs/assets/aido-lifecycle.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | coverage 4 | 5 | examples/**/sessions.db 6 | test/**/*.db 7 | 8 | .env -------------------------------------------------------------------------------- /examples/number/number.pug: -------------------------------------------------------------------------------- 1 | body 2 | section 3 | p #{state.number} 4 | section 5 | button(name="increment") Add 1 6 | button(name="decrement") Remove 1 -------------------------------------------------------------------------------- /lib/middleware/emptyResponse.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Gives the Slack webhook an empty response 3 | * @param {Object} ctx 4 | * @param {Function} next 5 | */ 6 | async function emptyResponse(ctx) { 7 | ctx.body = '' 8 | } 9 | 10 | module.exports = { emptyResponse } 11 | -------------------------------------------------------------------------------- /lib/utils/logger.js: -------------------------------------------------------------------------------- 1 | const { createLogger, transports, format } = require('winston') 2 | 3 | const logger = createLogger({ 4 | format: format.combine( 5 | format.errors({ stack: true }), 6 | format.metadata(), 7 | format.json(), 8 | ), 9 | transports: [new transports.Console()], 10 | }) 11 | 12 | module.exports = logger 13 | -------------------------------------------------------------------------------- /test/middleware/emptyResponse.test.js: -------------------------------------------------------------------------------- 1 | const { emptyResponse } = require('../../lib/middleware/emptyResponse') 2 | 3 | describe('Middleware - emptyResponse', () => { 4 | test('Returns the context with an empty body', async () => { 5 | const ctx = {} 6 | await emptyResponse(ctx) 7 | expect(ctx.body).toEqual('') 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /examples/todo/views/addItem.pug: -------------------------------------------------------------------------------- 1 | body(class="modal") 2 | section 3 | form(action="addItem") 4 | header Add an item 5 | label What needs done ? 6 | input( 7 | type="text" 8 | name="label" 9 | placeholder="Pick the groceries..." 10 | required 11 | ) 12 | input(type="submit" value="DO") 13 | -------------------------------------------------------------------------------- /lib/middleware/normalizeBody.js: -------------------------------------------------------------------------------- 1 | const normalizeObject = require('../utils/normalizeObject') 2 | 3 | /** 4 | * Check the Slack token to authenticate request 5 | * @param {Object} ctx 6 | * @param {Function} next 7 | */ 8 | async function normalizeBody (ctx, next) { 9 | ctx.request.body = normalizeObject(ctx.request.body) 10 | await next() 11 | } 12 | 13 | module.exports = { normalizeBody } 14 | -------------------------------------------------------------------------------- /examples/todo/views/removeItem.pug: -------------------------------------------------------------------------------- 1 | body(class="modal") 2 | section 3 | form(action="removeItem") 4 | header Remove an item 5 | label Select 6 | select( 7 | name="idx" 8 | placeholder="Chose one..." 9 | required 10 | ) 11 | each item, idx in state.items 12 | option(value=idx) #{item.label} 13 | input(type="submit" value="DELETE") 14 | -------------------------------------------------------------------------------- /lib/utils/normalizeObject.js: -------------------------------------------------------------------------------- 1 | const { camelCase, mapKeys, mapValues, isPlainObject } = require('lodash') 2 | 3 | /** 4 | * Recursively camelCases the keys of an object 5 | * @param {Object} object 6 | * @returns {Object} 7 | */ 8 | const normalizeObject = object => mapKeys( 9 | mapValues(object, value => isPlainObject(value) 10 | ? normalizeObject(value) 11 | : value 12 | ), 13 | (val, key) => camelCase(key) 14 | ) 15 | 16 | module.exports = normalizeObject 17 | -------------------------------------------------------------------------------- /lib/templaters/pug.js: -------------------------------------------------------------------------------- 1 | const pug = require('pug') 2 | 3 | /** 4 | * Custom templating function 5 | * Return the templated Slack message or use next() to send output to html2slack 6 | * @param {Object} view 7 | * @param {Object} state 8 | * @param {Function} next 9 | * @returns {Object} 10 | */ 11 | async function template(view, locals, next) { 12 | const templatedHtml = pug.render(view.template ,locals) 13 | return next(templatedHtml) 14 | } 15 | 16 | module.exports = template 17 | -------------------------------------------------------------------------------- /examples/polls/views/poll.pug: -------------------------------------------------------------------------------- 1 | body 2 | section 3 | p #{user.slackProfile.realName} 4 | span would like to ask you a question : 5 | section 6 | blockquote #{state.poll.question} 7 | section 8 | each option in state.poll.options 9 | button( 10 | name="vote" 11 | value={ pollId: option.pollId, optionId: option.id } 12 | ) #{option.text} (#{(option.votes && option.votes.length) || 0}) 13 | if (hasVotes) 14 | section( 15 | image-url=chartUrl 16 | ) 17 | -------------------------------------------------------------------------------- /examples/polls/views/viewPoll.pug: -------------------------------------------------------------------------------- 1 | body 2 | section( 3 | title="Previewing your poll" 4 | color="warning" 5 | ) 6 | q #{state.poll.question} 7 | each option in state.poll.options 8 | button(name="dummy" value="") #{option.text} 9 | section( 10 | title="Actions" 11 | color="warning" 12 | ) 13 | button(name="view" value="editPoll") Edit 14 | button(name="savePoll" class="primary") Publish 15 | if hint 16 | section(color="danger") 17 | i Please publish your poll before you can vote on it 18 | -------------------------------------------------------------------------------- /examples/todo/views/todo.pug: -------------------------------------------------------------------------------- 1 | body 2 | section 3 | p: b Awesome slack powered todo-list of #{user.slackProfile.realName} 4 | each item, idx in state.items 5 | section(color=getColor(item.done)) 6 | button( 7 | name="markDone" 8 | value={ idx, done: !item.done } 9 | class=getClass(item.done) 10 | ) #{getLabel(item.done, item.label)} 11 | section(color="danger") 12 | button(name="view" value="addItem") ✍️ Add item 13 | if (state.items.length) 14 | button(name="view" value="removeItem") ❌ Remove item 15 | 16 | -------------------------------------------------------------------------------- /lib/database/migrations/20200411-add-oauth.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up(knex) { 3 | return knex.schema 4 | .createTable('oauth', (table) => { 5 | table.string('team').primary() 6 | table.string('token') 7 | table.json('profile') 8 | }) 9 | .table('session', (table) => { 10 | table.string('team').index() 11 | }) 12 | }, 13 | down(knex) { 14 | return knex.schema 15 | .dropTable('oauth') 16 | .table('session', (table) => { 17 | table.dropColumn('team') 18 | }) 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /test/middleware/normalizeBody.test.js: -------------------------------------------------------------------------------- 1 | const { normalizeBody } = require('../../lib/middleware/normalizeBody') 2 | 3 | const originalObject = { 4 | some_property: 'test', 5 | other_property: 'test', 6 | } 7 | 8 | const normalizedObject = { 9 | someProperty: 'test', 10 | otherProperty: 'test', 11 | } 12 | 13 | describe('Middleware - Normalise body', () => { 14 | test('Camelcases the properties of a payload', () => { 15 | const ctx = { 16 | request: { body: originalObject }, 17 | } 18 | normalizeBody(ctx, () => null) 19 | expect(ctx.request.body).toEqual(normalizedObject) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /examples/todo/index.js: -------------------------------------------------------------------------------- 1 | const aido = require('../../lib') 2 | const Todo = require('./slash/todo') 3 | 4 | // Configure global application 5 | aido.init({ 6 | getSlackProfile: true, 7 | slash: { todo: Todo }, 8 | // tunnel: { 9 | // // If you already have a tunnel setup just uncomment the following line and enter your actual tunnel URL 10 | // custom: 'https://xxxxxx.ngrok.io', 11 | // }, 12 | // appId: 'AXXXXXXX', 13 | // slackVerificationToken: 'xxxxxxxxxxxxxxxxxxxxxxxxx', 14 | // appToken: 'xoxp-xxxxxxxxx-xxxxxxxx', 15 | // botToken: 'xoxb-xxxxxxxxx', 16 | // legacyToken: 'xoxp-xxxxxxxxx-xxxxxxxx', 17 | }) 18 | 19 | aido.start(3000) 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "amd": true, 5 | "es6": true, 6 | "jest": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "ecmaVersion": 2018 11 | }, 12 | "rules": { 13 | "indent": [ 14 | "error", 15 | 2 16 | ], 17 | "linebreak-style": [ 18 | "error", 19 | "unix" 20 | ], 21 | "quotes": [ 22 | "error", 23 | "single", 24 | { "avoidEscape": true } 25 | ], 26 | "semi": [ 27 | "error", 28 | "never" 29 | ], 30 | "comma-dangle": [ 31 | "error", 32 | "always-multiline" 33 | ], 34 | "no-console": "off" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/database/oauth.js: -------------------------------------------------------------------------------- 1 | const { Model } = require('objection') 2 | 3 | class Oauth extends Model { 4 | static get tableName() { 5 | return 'oauth' 6 | } 7 | 8 | static get idColumn() { 9 | return ['team'] 10 | } 11 | 12 | static get jsonAttributes() { 13 | return ['profile'] 14 | } 15 | 16 | static get jsonSchema() { 17 | return { 18 | type: 'object', 19 | required: ['team', 'token', 'profile'], 20 | 21 | properties: { 22 | team: { type: 'string', minLength: 8, maxLength: 12 }, 23 | token: { type: 'string', minLength: 56, maxLength: 56 }, 24 | profile: { type: 'object' }, 25 | }, 26 | } 27 | } 28 | } 29 | 30 | module.exports = Oauth 31 | -------------------------------------------------------------------------------- /lib/database/session.js: -------------------------------------------------------------------------------- 1 | const { Model } = require('objection') 2 | 3 | class Session extends Model { 4 | static get tableName() { 5 | return 'session' 6 | } 7 | 8 | static get idColumn() { 9 | return ['id', 'user'] 10 | } 11 | 12 | static get jsonAttributes() { 13 | return ['state'] 14 | } 15 | 16 | static get jsonSchema() { 17 | return { 18 | type: 'object', 19 | required: ['id', 'user', 'state'], 20 | 21 | properties: { 22 | id: { type: 'string', minLength: 1, maxLength: 255 }, 23 | user: { type: 'string', minLength: 9, maxLength: 9 }, 24 | state: { type: 'object' }, 25 | }, 26 | } 27 | } 28 | } 29 | 30 | module.exports = Session 31 | -------------------------------------------------------------------------------- /lib/database/createTable.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Binds the knex instance to the create table method 3 | * @param {Object} knex 4 | */ 5 | function createTableFactory(knex) { 6 | /** 7 | * 8 | * @param {String} tableName - the table name 9 | * @param {Function} callback - a callback to create the model in Objection 10 | */ 11 | async function createTable(tableName, callback) { 12 | // Check if the table needs to be created 13 | const table = await knex.schema.hasTable(tableName) 14 | if (!table) { 15 | return knex.schema.createTable(tableName, callback).then(() => {}) // Coerce schema creation to promise 16 | } 17 | } 18 | 19 | return createTable 20 | } 21 | 22 | module.exports = createTableFactory 23 | -------------------------------------------------------------------------------- /examples/polls/index.js: -------------------------------------------------------------------------------- 1 | const aido = require('../../lib') 2 | const Polack = require('./slash/polack') 3 | const pollsPlugin = require('./plugins/polls') 4 | 5 | // Configure global application 6 | aido.init({ 7 | getSlackProfile: true, 8 | slash: { polack: Polack }, 9 | plugins: [pollsPlugin], 10 | // tunnel: { 11 | // // If you already have a tunnel setup just uncomment the following line and enter your actual tunnel URL 12 | // custom: 'https://xxxxxx.ngrok.io', 13 | // }, 14 | // appId: 'AXXXXXXX', 15 | // slackVerificationToken: 'xxxxxxxxxxxxxxxxxxxxxxxxx', 16 | // appToken: 'xoxp-xxxxxxxxx-xxxxxxxx', 17 | // botToken: 'xoxb-xxxxxxxxx', 18 | // legacyToken: 'xoxp-xxxxxxxxx-xxxxxxxx', 19 | }) 20 | 21 | aido.start(3000) 22 | -------------------------------------------------------------------------------- /lib/utils/customApi.js: -------------------------------------------------------------------------------- 1 | const request = require('request-promise-native') 2 | 3 | const logger = require('./logger') 4 | 5 | /** 6 | * This is an alternative slack client, allowing you to hit undocumented endpoints 7 | * or get full responses including headers 8 | * @param {String} endpoint 9 | * @param {Object} args 10 | * @param {Boolean} fullResponse 11 | * @param {String} token 12 | */ 13 | async function customApi(endpoint, args = {}, fullResponse = false, token = null) { 14 | const res = await request({ 15 | method: 'POST', 16 | uri: `https://slack.com/api/${endpoint}`, 17 | form: { 18 | token, 19 | ...args, 20 | }, 21 | json: true, 22 | resolveWithFullResponse: fullResponse, 23 | }).catch(e => logger.error(e)) 24 | 25 | return res 26 | } 27 | 28 | module.exports = customApi 29 | -------------------------------------------------------------------------------- /examples/polls/database/index.js: -------------------------------------------------------------------------------- 1 | const Poll = require('./poll') 2 | const Vote = require('./vote') 3 | const Option = require('./option') 4 | 5 | /** 6 | * Initializes the required models 7 | * @param {Function} createTable 8 | * @param {Model} Model 9 | */ 10 | async function init(createTable) { 11 | await createTable('poll', (table) => { 12 | table.increments('id').primary() 13 | table.string('user') 14 | table.string('question') 15 | }) 16 | await createTable('option', (table) => { 17 | table.increments('id').primary() 18 | table.integer('pollId') 19 | table.string('text') 20 | }) 21 | await createTable('vote', (table) => { 22 | table.integer('pollId') 23 | table.string('user') 24 | table.integer('optionId') 25 | table.primary(['pollId', 'user']) 26 | }) 27 | 28 | return { Poll, Vote, Option } 29 | } 30 | 31 | module.exports = { init } 32 | -------------------------------------------------------------------------------- /examples/number/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | const aido = require('../../lib') 4 | const { Slash } = aido 5 | 6 | class Number extends Slash { 7 | /** 8 | * Initializes the internal state of the application 9 | */ 10 | initState() { 11 | return { 12 | number: 0 13 | } 14 | } 15 | 16 | /** 17 | * Increments the number. This method will be called by clicking the first button. 18 | * (because it has the same `name` as the button) 19 | */ 20 | increment() { 21 | this.state.number += 1 22 | } 23 | 24 | /** 25 | * Decrements the number. This method will be called by clicking the second button. 26 | * (because it has the same `name` as the button) 27 | */ 28 | decrement() { 29 | this.state.number -= 1 30 | } 31 | } 32 | 33 | // Configure global application 34 | aido.init({ 35 | viewsFolder: __dirname, 36 | slash: { number: Number }, 37 | signingSecret: process.env['SIGNING_SECRET'], 38 | appToken: process.env['APP_TOKEN'], 39 | }) 40 | aido.start() 41 | -------------------------------------------------------------------------------- /test/slash/render.test.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon') 2 | 3 | const Slash = require('../../lib/slash') 4 | 5 | describe('Slash class - render method', () => { 6 | beforeEach(() => { 7 | this.slash = new Slash() 8 | this.slash.view = 'test' 9 | this.slash.transport = sinon.stub() 10 | this.slash.template = sinon.stub().callsFake(() => ({ attachments: [{}, {}, {}]})) 11 | this.slash.command = 'foo' 12 | }) 13 | 14 | test("Normal view - attach session Id to each attachment's callback", async () => { 15 | await this.slash.render({ test: { modal: false } }) 16 | const renderedMessage = this.slash.transport.lastCall.lastArg 17 | renderedMessage.attachments.forEach(attachment => expect(attachment.callback_id).toBe(this.slash.command)) 18 | }) 19 | 20 | test("Modal view - attach session Id to the message's callback", async () => { 21 | await this.slash.render({ test: { modal: true } }) 22 | const renderedMessage = this.slash.transport.lastCall.lastArg 23 | expect(renderedMessage.callback_id).toBe(this.slash.command) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/lib/normalizeObject.test.js: -------------------------------------------------------------------------------- 1 | const normalizeObject = require('../../lib/utils/normalizeObject') 2 | 3 | describe('Object normalizer', () => { 4 | it('should camelCase the keys of a simple object', () => { 5 | const input = { 6 | snakey_casey: 1, 7 | OldieCasie: 1, 8 | 'kebabie-casie': 1, 9 | anaRchYcaSe: 1, 10 | } 11 | expect(normalizeObject(input)).toMatchObject({ 12 | snakeyCasey: 1, 13 | oldieCasie: 1, 14 | kebabieCasie: 1, 15 | anaRchYcaSe: 1, 16 | }) 17 | }) 18 | 19 | it('should camelCase the keys of any children objects', () => { 20 | const input = { 21 | first_level: 1, 22 | first_child: { second_level: 2 }, 23 | second_child: { 24 | second_level: 2, 25 | third_child: { 'third-level': 3 }, 26 | }, 27 | } 28 | expect(normalizeObject(input)).toMatchObject({ 29 | firstLevel: 1, 30 | firstChild: { secondLevel: 2 }, 31 | secondChild: { 32 | secondLevel: 2, 33 | thirdChild: { thirdLevel: 3 }, 34 | }, 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /examples/polls/database/option.js: -------------------------------------------------------------------------------- 1 | const { Model } = require('objection') 2 | 3 | class Option extends Model { 4 | static get tableName() { 5 | return 'option' 6 | } 7 | 8 | static get idColumn() { 9 | return 'id' 10 | } 11 | 12 | static get jsonSchema() { 13 | return { 14 | type: 'object', 15 | required: ['pollId', 'text'], 16 | 17 | properties: { 18 | pollId: { type: 'integer' }, 19 | text: { type: 'string' }, 20 | }, 21 | } 22 | } 23 | 24 | static get relationMappings() { 25 | const Poll = require('./poll') 26 | const Vote = require('./vote') 27 | return { 28 | poll: { 29 | relation: Model.BelongsToOneRelation, 30 | modelClass: Poll, 31 | join: { 32 | from: 'poll.id', 33 | to: 'option.pollId', 34 | }, 35 | }, 36 | votes: { 37 | relation: Model.HasManyRelation, 38 | modelClass: Vote, 39 | join: { 40 | from: 'vote.optionId', 41 | to: 'option.id', 42 | }, 43 | }, 44 | } 45 | } 46 | } 47 | 48 | module.exports = Option 49 | -------------------------------------------------------------------------------- /test/stubs.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon') 2 | const request = require('request-promise-native') 3 | const { set } = require('lodash') 4 | 5 | const logger = require('../lib/utils/logger') 6 | 7 | const slackEndpoints = ['dialog.open', 'mpim.open', 'im.open', 'chat.postEphemeral', 'chat.postMessage'] 8 | const slackStubs = slackEndpoints.reduce((stubs, stub) => { 9 | set(stubs, stub, sinon.stub()) 10 | return stubs 11 | }, {}) 12 | 13 | slackStubs.mpim.open.callsFake(() => ({ group: { id: 'CW0TM8' } })) 14 | slackStubs.im.open.callsFake(() => ({ channel: { id: 'CW0TM8' } })) 15 | 16 | const requestStub = { 17 | post: sinon.stub(request, 'post'), 18 | get: sinon.stub(request, 'get'), 19 | } 20 | 21 | const loggerStub = { 22 | info: sinon.stub(logger, 'info'), 23 | warn: sinon.stub(logger, 'warn'), 24 | error: sinon.stub(logger, 'error'), 25 | } 26 | 27 | const assertStub = sinon.stub().callsFake((assertion) => { 28 | if (!assertion) { 29 | throw new Error() 30 | } 31 | }) 32 | 33 | module.exports = { 34 | slack: slackStubs, 35 | request: requestStub, 36 | logger: loggerStub, 37 | assert: assertStub, 38 | } 39 | -------------------------------------------------------------------------------- /test/middleware/checkToken.test.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon') 2 | 3 | const { checkToken } = require('../../lib/middleware/checkToken') 4 | const { assert } = require('../stubs') 5 | 6 | const next = sinon.stub() 7 | 8 | describe('Middleware - checkToken', () => { 9 | beforeEach(() => assert.resetHistory()) 10 | 11 | test('Bad token - assertion throws an error', async () => { 12 | const ctx = { 13 | request: { body: { token: 'REQUEST_TOKEN' } }, 14 | options: { slackVerificationToken: 'CONFIG_TOKEN' }, 15 | assert, 16 | } 17 | // catch the error so that it doesn't fail the test 18 | await checkToken(ctx, next).catch(() => null) 19 | expect(ctx.assert.threw()).toBe(true) 20 | expect(next.called).toBe(false) 21 | }) 22 | 23 | test('Good token - assertion does not throw, next is called', async () => { 24 | const ctx = { 25 | request: { body: { token: 'CONFIG_TOKEN' } }, 26 | options: { slackVerificationToken: 'CONFIG_TOKEN' }, 27 | assert, 28 | } 29 | await checkToken(ctx, next) 30 | expect(ctx.assert.threw()).toBe(false) 31 | expect(next.called).toBe(true) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /examples/polls/database/poll.js: -------------------------------------------------------------------------------- 1 | const { Model } = require('objection') 2 | 3 | class Poll extends Model { 4 | static get tableName() { 5 | return 'poll' 6 | } 7 | 8 | static get idColumn() { 9 | return 'id' 10 | } 11 | 12 | static get jsonSchema() { 13 | return { 14 | type: 'object', 15 | required: ['user', 'question'], 16 | 17 | properties: { 18 | user: { type: 'string', minLength: 9, maxLength: 9 }, 19 | question: { type: 'string', minLength: 1, maxLength: 255 }, 20 | }, 21 | } 22 | } 23 | 24 | static get relationMappings() { 25 | const Vote = require('./vote') 26 | const Option = require('./option') 27 | return { 28 | options: { 29 | relation: Model.HasManyRelation, 30 | modelClass: Option, 31 | join: { 32 | from: 'option.pollId', 33 | to: 'poll.id', 34 | }, 35 | }, 36 | votes: { 37 | relation: Model.HasManyRelation, 38 | modelClass: Vote, 39 | join: { 40 | from: 'vote.pollId', 41 | to: 'poll.id', 42 | }, 43 | }, 44 | } 45 | } 46 | } 47 | 48 | module.exports = Poll 49 | -------------------------------------------------------------------------------- /lib/middleware/checkToken.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | 3 | /** 4 | * Validate that the request is coming from Slack 5 | * @param {Object} ctx 6 | * @param {Function} next 7 | */ 8 | async function checkToken (ctx, next) { 9 | // Using the old school verification token 10 | if (ctx.options.slackVerificationToken) { 11 | const { token } = ctx.request.body 12 | ctx.assert(token === ctx.options.slackVerificationToken, 500, 'Verification token invalid') 13 | } 14 | // Using the new signing secrets 15 | if (ctx.options.signingSecret) { 16 | const unparsedBody = ctx.request.body[Symbol.for('unparsedBody')] 17 | const { 18 | 'x-slack-request-timestamp': timestamp, 19 | 'x-slack-signature': signature, 20 | } = ctx.request.headers 21 | 22 | const signatureString = `v0:${timestamp}:${unparsedBody}` 23 | const hmac = crypto.createHmac('sha256', ctx.options.signingSecret) 24 | hmac.update(signatureString) 25 | const verificationSignature = `v0=${hmac.digest('hex')}` 26 | ctx.assert(verificationSignature === signature, 500, 'Signature invalid') 27 | } 28 | 29 | await next() 30 | } 31 | 32 | module.exports = { checkToken } 33 | -------------------------------------------------------------------------------- /examples/polls/database/vote.js: -------------------------------------------------------------------------------- 1 | const { Model } = require('objection') 2 | 3 | class Vote extends Model { 4 | static get tableName() { 5 | return 'vote' 6 | } 7 | 8 | static get idColumn() { 9 | return ['pollId', 'user'] 10 | } 11 | 12 | static get jsonSchema() { 13 | return { 14 | type: 'object', 15 | required: ['pollId', 'user', 'optionId'], 16 | 17 | properties: { 18 | pollId: { type: 'integer' }, 19 | optionId: { type: 'integer' }, 20 | user: { type: 'string', minLength: 9, maxLength: 9 }, 21 | }, 22 | } 23 | } 24 | 25 | static get relationMappings() { 26 | const Poll = require('./poll') 27 | const Option = require('./option') 28 | return { 29 | poll: { 30 | relation: Model.BelongsToOneRelation, 31 | modelClass: Poll, 32 | join: { 33 | from: 'poll.id', 34 | to: 'vote.pollId', 35 | }, 36 | }, 37 | option: { 38 | relation: Model.BelongsToOneRelation, 39 | modelClass: Option, 40 | join: { 41 | from: 'option.id', 42 | to: 'vote.optionId', 43 | }, 44 | }, 45 | } 46 | } 47 | } 48 | 49 | module.exports = Vote 50 | -------------------------------------------------------------------------------- /examples/polls/views/editPoll.pug: -------------------------------------------------------------------------------- 1 | body(class="modal") 2 | section 3 | form(action="createPoll") 4 | header Create a poll 5 | label Question 6 | input( 7 | type="text" 8 | name="question" 9 | value=state.poll.question 10 | placeholder="Which team is the best team..." 11 | required 12 | ) 13 | label Option 1 14 | input( 15 | type="text" 16 | name="option1" 17 | value=state.poll.options && state.poll.options[0] && state.poll.options[0].text 18 | placeholder="Backend..." 19 | required 20 | ) 21 | label Option 2 22 | input( 23 | type="text" 24 | name="option2" 25 | value=state.poll.options && state.poll.options[1] && state.poll.options[1].text 26 | placeholder="Frontend..." 27 | required 28 | ) 29 | label Option 3 30 | input( 31 | type="text" 32 | name="option3" 33 | value=state.poll.options && state.poll.options[2] && state.poll.options[2].text 34 | placeholder="Data..." 35 | ) 36 | label Option 4 37 | input( 38 | type="text" 39 | name="option4" 40 | value=state.poll.options && state.poll.options[3] && state.poll.options[3].text 41 | placeholder="Product..." 42 | ) 43 | input(type="submit" value="Create") 44 | -------------------------------------------------------------------------------- /lib/middleware/initSlash.js: -------------------------------------------------------------------------------- 1 | const { isFunction } = require('lodash') 2 | 3 | const logger = require('../utils/logger') 4 | 5 | /** 6 | * Checks the slash exists and initializes it 7 | * @param {Object} ctx 8 | * @param {Function} next 9 | */ 10 | async function initSlash(ctx, next) { 11 | const { slash: command, channel } = ctx.trigger 12 | ctx.assert(!!ctx.slash[command], 404, `Command ${command} is not configured on this server`) 13 | const slash = new ctx.slash[command]( 14 | ctx.trigger, 15 | ctx.request.body.responseUrl, 16 | ctx.request.body.triggerId, 17 | channel, 18 | ctx.options, 19 | ctx.bot, 20 | ctx.database, 21 | ) 22 | await slash.initDb() 23 | await slash.init() 24 | ctx.slash = slash 25 | await next() 26 | try { 27 | if (!ctx.trigger.action) { 28 | // Slash command 29 | await slash.handleText() 30 | } else { 31 | // Action 32 | ctx.assert( 33 | slash[ctx.trigger.action] && isFunction(slash[ctx.trigger.action]), 34 | 404, `Command ${command} is not configured on this server` 35 | ) 36 | if (ctx.trigger.action) { 37 | await slash.preAction() 38 | await slash[ctx.trigger.action](ctx.trigger.args) 39 | await slash.postAction() 40 | } 41 | } 42 | await slash.persistState() 43 | await slash.render(ctx.views) 44 | } catch (e) { 45 | logger.error(e) 46 | } 47 | } 48 | 49 | module.exports = { initSlash } 50 | -------------------------------------------------------------------------------- /examples/polls/plugins/polls.js: -------------------------------------------------------------------------------- 1 | const models = require('../database') 2 | 3 | /** 4 | * Polls plugin - handles all interactions with the polls database 5 | */ 6 | function pluginFactory() { 7 | /** 8 | * Augments the Slash class with additional methods 9 | * @param {Slash} oldSlash 10 | */ 11 | function slashFactory(oldSlash) { 12 | class Slash extends oldSlash { 13 | async persistPoll() { 14 | // Persists the poll in database 15 | return this.database.Poll.query().upsertGraph({ 16 | user: this.user.slackId, 17 | ...this.state.poll, 18 | }) 19 | } 20 | async persistVote(pollId, optionId) { 21 | await this.database.Vote.query().upsertGraph({ 22 | optionId, 23 | pollId, 24 | user: this.user.slackId, 25 | }, { insertMissing: true }) 26 | const [poll] = await this.database.Poll.query().where({ id: pollId }).eager({ options: { votes: true }}) 27 | return poll 28 | } 29 | } 30 | return Slash 31 | } 32 | /** 33 | * Add plugin specific tables to the DB 34 | * @param {Object} database 35 | */ 36 | async function extendDb(database) { 37 | const { createTable, Model } = database 38 | const { Poll, Vote, Options } = await models.init(createTable, Model) 39 | database.Poll = Poll 40 | database.Vote = Vote 41 | database.Options = Options 42 | } 43 | return { 44 | name: 'polls', 45 | slashFactory, 46 | extendDb, 47 | } 48 | } 49 | 50 | module.exports = pluginFactory 51 | -------------------------------------------------------------------------------- /lib/database/index.js: -------------------------------------------------------------------------------- 1 | const { Model } = require('objection') 2 | const Knex = require('knex') 3 | const { isPlainObject } = require('lodash') 4 | 5 | const createTableFactory = require('./createTable') 6 | const Session = require('./session') 7 | const Oauth = require('./oauth') 8 | 9 | /** 10 | * Initializes the database and creates the tables if needed 11 | * @param {String|Object} persistentStorage - the path to the sqlite database or a Knex compatible connection object 12 | */ 13 | async function database(persistentStorage) { 14 | // If persistentStorage is an object, it should be a Knex compatible connection object 15 | // If it is a string it is considered to be the absolute path to the SQLite database 16 | const clientOptions = isPlainObject(persistentStorage) 17 | ? persistentStorage 18 | : { 19 | client: 'sqlite3', 20 | useNullAsDefault: true, 21 | connection: { 22 | filename: persistentStorage, 23 | }, 24 | } 25 | 26 | // Start Knex client 27 | const knex = Knex(clientOptions) 28 | // Bind to Objection 29 | Model.knex(knex) 30 | const createTable = createTableFactory(knex) 31 | 32 | // Create session table 33 | await createTable('session', (table) => { 34 | table.string('id').primary() 35 | table.string('user').index() 36 | table.json('state') 37 | }) 38 | 39 | // Apply any needed migrations 40 | await knex.migrate.latest({ directory: `${__dirname}/migrations` }) 41 | 42 | return { 43 | knex, 44 | Model, 45 | createTable, 46 | 47 | Oauth, 48 | Session, 49 | } 50 | } 51 | 52 | module.exports = database 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aido", 3 | "version": "0.3.5", 4 | "description": "Slack Applications Made Simple", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "todo": "nodemon --inspect=0.0.0.0:9300 --signal SIGINT --ext js,json,pug examples/todo dev", 8 | "polls": "nodemon --inspect=0.0.0.0:9300 --signal SIGINT --ext js,json,pug examples/polls dev", 9 | "number": "nodemon --inspect=0.0.0.0:9300 --signal SIGINT --ext js,json,pug examples/number dev", 10 | "lint": "eslint --ignore-path .gitignore ./", 11 | "test": "jest --runInBand --coverage" 12 | }, 13 | "author": "Damien BUTY (https://www.npmjs.com/~dam-buty)", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "dotenv": "^8.2.0", 17 | "eslint": "^6.8.0", 18 | "jest": "^25.3.0", 19 | "nodemon": "^2.0.3", 20 | "sinon": "^9.0.2" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/aidojs/aido.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/aidojs/aido/issues" 28 | }, 29 | "dependencies": { 30 | "bluebird": "^3.5.5", 31 | "html2slack": "^1.8.0", 32 | "is-json": "^2.0.1", 33 | "knex": "^0.20.13", 34 | "koa": "^2.7.0", 35 | "koa-body": "^4.1.0", 36 | "koa-router": "^8.0.8", 37 | "lodash": "^4.17.11", 38 | "objection": "^2.1.3", 39 | "pug": "^2.0.3", 40 | "pug-lexer": "^4.1.0", 41 | "request-promise-native": "^1.0.7", 42 | "slack": "^11.0.2", 43 | "winston": "^3.2.1" 44 | }, 45 | "peerDependencies": { 46 | "sqlite3": "^4.2.0" 47 | }, 48 | "jest": { 49 | "collectCoverageFrom": [ 50 | "lib/**/*.js" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/lib/unpackView.test.js: -------------------------------------------------------------------------------- 1 | const { unpackView } = require('../../lib/utils/detectViews') 2 | 3 | const modalTemplates = [` 4 | body(class="modal") 5 | p Test paragraph 6 | `, ` 7 | head 8 | title My Site 9 | script(src='/javascripts/jquery.js') 10 | script(src='/javascripts/app.js') 11 | body(class="modal") 12 | p Test paragraph 13 | `, ` 14 | body( 15 | some-attr="foo" 16 | onfocus="alert()" 17 | class="modal" 18 | ) 19 | p Test paragraph 20 | `, ` 21 | body(class="big beautiful modal") 22 | p Test paragraph 23 | `] 24 | 25 | const nonModalTemplates = [` 26 | body 27 | p Test paragraph 28 | `, ` 29 | head 30 | title My Site 31 | script(src='/javascripts/jquery.js') 32 | script(src='/javascripts/app.js') 33 | body 34 | p Test paragraph 35 | `, ` 36 | body( 37 | some-attr="foo" 38 | onfocus="alert()" 39 | ) 40 | p Test paragraph 41 | `, ` 42 | body(class="big somemodalmisdirection beautiful") 43 | p Test paragraph 44 | `, ` 45 | body(class="big some-modal-misdirection beautiful") 46 | p Test paragraph 47 | `, ` 48 | body(class="somemodalmisdirection") 49 | p Test paragraph 50 | `, ` 51 | body(class="some-modal-misdirection") 52 | p Test paragraph 53 | `] 54 | 55 | describe('Aido library - View unpacking', () => { 56 | test('Modal templates - detect correctly', () => { 57 | modalTemplates.forEach(template => { 58 | const unpackedView = unpackView('testView', template) 59 | expect(unpackedView).toMatchObject({ 60 | name: 'testView', 61 | modal: true, 62 | template, 63 | }) 64 | }) 65 | }) 66 | 67 | test('Non modal templates - detect correctly', () => { 68 | nonModalTemplates.forEach(template => { 69 | const unpackedView = unpackView('testView', template) 70 | expect(unpackedView).toMatchObject({ 71 | name: 'testView', 72 | modal: false, 73 | template, 74 | }) 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /test/lib/plugins.test.js: -------------------------------------------------------------------------------- 1 | const { Model } = require('objection') 2 | const fs = require('fs') 3 | const sinon = require('sinon') 4 | 5 | const aido = require('../../lib') 6 | const databaseFactory = require('../../lib/database') 7 | 8 | class Example extends Model { 9 | static get tableName() { return 'example' } 10 | static get idColumn() { return 'id' } 11 | static get jsonSchema() { 12 | return { 13 | type: 'object', 14 | required: ['id'], 15 | properties: { 16 | id: { type: 'integer' }, 17 | }, 18 | } 19 | } 20 | } 21 | 22 | describe('Plugin system', () => { 23 | beforeEach(async () => { 24 | if (fs.existsSync(`${__dirname}/sessions.db`)) { 25 | fs.unlinkSync(`${__dirname}/sessions.db`) 26 | } 27 | this.database = await databaseFactory(`${__dirname}/sessions.db`) 28 | this.database.Example = Example 29 | }) 30 | 31 | test('Loads the plugin and executes the available methods', async () => { 32 | const plugin = () => ({ 33 | name: 'plugin', 34 | async extendDb(database) { 35 | await database.createTable('example', (table) => { table.integer('id') }) 36 | database.Example = Example 37 | }, 38 | async initPlugin(database) { 39 | await database.Example.query().insert({ id: 1 }) 40 | await database.Example.query().insert({ id: 2 }) 41 | }, 42 | async getHelpers() { 43 | return { helper: 'helper' } 44 | }, 45 | slashFactory(slash) { 46 | slash.additionalMethod = 'additionalMethod' 47 | return slash 48 | }, 49 | }) 50 | aido.init({ 51 | hints: false, 52 | slash: { testSlash: { } }, 53 | plugins: [plugin], 54 | }) 55 | // stub koa listener so we don't end up with a dangling promise 56 | aido.koaApp.listen = sinon.stub() 57 | await aido.start() 58 | 59 | // This test succeeds because the table has been created by extendDb and populated by initPlugin 60 | const { count } = await this.database.Example.query().count('id as count').first() 61 | expect(count).toBe(2) 62 | 63 | // Check that aido has been decorated with helpers, and the slash decorated with additional method 64 | expect(aido.helpers).toMatchObject({ plugin: { helper: 'helper' } }) 65 | expect(aido.koaApp.context.slash).toMatchObject({ 66 | testSlash: { additionalMethod: 'additionalMethod' }, 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /lib/utils/detectViews.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const lex = require('pug-lexer') 4 | 5 | const logger = require('./logger') 6 | 7 | /** 8 | * Unpacks a view : parses the template and determines whether it is modal or not 9 | * @param {String} name - The name of the view 10 | * @param {String} viewPath - The path to the view 11 | * @returns {Object} { } 12 | */ 13 | function unpackView(name, template) { 14 | // Use pug lexer to find if the body is a modal or not 15 | const tokens = lex(template) 16 | let inBody, inAttributes, finished 17 | // We want to find an attribute class 18 | const modal = tokens.some(token => { 19 | if (finished) { 20 | return false 21 | } 22 | // Detect beginning of body tag 23 | if (token.type === 'tag' && token.val === 'body') { 24 | inBody = true 25 | return false 26 | } 27 | // Ignore tokens not in body 28 | if (!inBody) { 29 | return false 30 | } 31 | // Entering another tag. Detection is finished as no relevant attribute has been found in body 32 | if (token.type === 'tag') { 33 | finished = true 34 | return false 35 | } 36 | // Detect beginning of body attributes 37 | if (token.type === 'start-attributes') { 38 | inAttributes = true 39 | return false 40 | } 41 | // Ignore tokens not in attributes 42 | if (!inAttributes) { 43 | return false 44 | } 45 | // Detect end of attributes. Detection is finished as no relevant attribute has been found in body 46 | if (token.type === 'end-attributes') { 47 | finished = true 48 | return false 49 | } 50 | // Detect class="modal" attribute 51 | if (token.type === 'attribute' && token.name === 'class' && /(\s|")modal(\s|")/.test(token.val)) { 52 | return true 53 | } 54 | }) 55 | return { 56 | name, 57 | modal, 58 | template, 59 | } 60 | } 61 | 62 | /** 63 | * Detect all views in the views folder and unpacks them 64 | * @param {String} viewsFolder 65 | * @param {String} viewsTemplateExtension 66 | * @returns {Object[]} an object containing all the views 67 | */ 68 | function detectViews(viewsFolder, viewsTemplateExtension) { 69 | if (!fs.existsSync(viewsFolder)) { 70 | logger.error('Views folder does not exist', { viewsFolder }) 71 | throw new Error('Views folder does not exist') 72 | } 73 | const viewFiles = fs.readdirSync(viewsFolder) 74 | return viewFiles 75 | .filter(viewFile => path.extname(viewFile) === `.${viewsTemplateExtension}`) 76 | .reduce((views, viewFile) => { 77 | // Load template 78 | const viewName = path.basename(viewFile,`.${viewsTemplateExtension}`) 79 | const template = fs.readFileSync(path.join(viewsFolder, viewFile), { encoding: 'utf-8' }) 80 | views[viewName] = unpackView(viewName, template) 81 | return views 82 | }, {}) 83 | } 84 | 85 | module.exports = { 86 | unpackView, 87 | detectViews, 88 | } 89 | -------------------------------------------------------------------------------- /examples/polls/slash/polack.js: -------------------------------------------------------------------------------- 1 | const Slash = require('../../../lib/slash') 2 | 3 | class Polls extends Slash { 4 | // The following methods are standard, used internally by Aido 5 | /** 6 | * Initializes the command 7 | */ 8 | async init() { 9 | /* The default view for a Slash is the name of the command, but you can override it here */ 10 | this.view = 'editPoll' 11 | } 12 | 13 | /** 14 | * Initializes the command state (bypassed if a persistent state is found for this command and user) 15 | */ 16 | initState() { 17 | return { 18 | poll: {}, 19 | } 20 | } 21 | 22 | // The following methods are custom, used by the different views of the slash command. Grow your own ! _\|/_ 23 | /** 24 | * Creates a poll in memory 25 | * @param {Object} poll 26 | * @param {String} poll.question 27 | * @param {String} poll.option1 28 | * @param {String} poll.option2 29 | * @param {String} poll.option3 30 | * @param {String} poll.option4 31 | */ 32 | createPoll({ question, option1, option2, option3, option4 }) { 33 | this.state.poll = { 34 | question, 35 | options: [option1, option2, option3, option4].filter(option => !!option).map(option => ({ text: option })), 36 | } 37 | this.view = 'viewPoll' 38 | } 39 | 40 | /** 41 | * This function just sets a flag 42 | * Notice that it is not stored in the state, so it will not be persisted 43 | */ 44 | dummy() { 45 | this.hint = true 46 | this.view = 'viewPoll' 47 | } 48 | 49 | /** 50 | * Saves a poll in memory and publishes it 51 | */ 52 | async savePoll() { 53 | this.state.poll = await this.persistPoll() 54 | // Sets the slash to non-private so the poll is visible by all users of the channel 55 | this.private = false 56 | this.view = 'poll' 57 | } 58 | 59 | /** 60 | * Vote for an option on a given poll 61 | * @param {Object} args 62 | * @param {Number} args.pollId 63 | * @param {Number} args.optionId 64 | */ 65 | async vote({ pollId, optionId }) { 66 | this.state.poll = await this.persistVote(pollId, optionId) 67 | this.view = 'poll' 68 | } 69 | 70 | /** 71 | * Returns true if at least one option has a vote 72 | */ 73 | get hasVotes() { 74 | return this.state.poll.options.some(option => option.votes && option.votes.length > 0) 75 | } 76 | 77 | // Generates an image-chart URL with the current votes on the poll 78 | get chartUrl() { 79 | const votedOptions = this.state.poll.options.filter(option => option.votes && option.votes.length > 0) 80 | const values = votedOptions.map(option => option.votes && option.votes.length).join(',') 81 | const labels = votedOptions.map(option => option.text).join('|') 82 | const totalVotes = votedOptions.reduce((total, option) => total + option.votes.length, 0) 83 | return encodeURI( 84 | `https://image-charts.com/chart?cht=pd&chs=200x200&chd=t:${values}&chl=${labels}&chli=${totalVotes}%20votes` 85 | ) 86 | } 87 | } 88 | 89 | module.exports = Polls 90 | -------------------------------------------------------------------------------- /examples/todo/slash/todo.js: -------------------------------------------------------------------------------- 1 | const Slash = require('../../../lib/slash') 2 | 3 | class Todo extends Slash { 4 | // The following methods are standard, used internally by Aido 5 | 6 | /** 7 | * Initializes the command 8 | */ 9 | init() { 10 | /* The default view for a Slash is the name of the command, but you can override it here */ 11 | this.view = 'todo' 12 | /* By default, a Slash will store the user sessions in the persistent storage specified by the application 13 | Set to false for no persistent storage. In this case, all invocations will go through the initState() method */ 14 | this.persistentState = true 15 | /* By default a Slash is private, meaning its response will only be visible to the user who invoked it. 16 | Set to false for a public Slash. In this case, the command will respond in the channel where it was invoked 17 | and be visible and interactive to all users of this channel. */ 18 | this.private = true 19 | } 20 | 21 | /** 22 | * Initializes the command state (bypassed if a persistent state is found for this command and user) 23 | */ 24 | initState() { 25 | return { 26 | items: [], 27 | } 28 | } 29 | 30 | /** 31 | * Handles the text arguments that were provided on command invocation. 32 | * Use `this.text` to get a string 33 | * Or `this.args` to get an array of strings (split by space) 34 | */ 35 | handleText() { 36 | if (this.text) { 37 | this.addItem({ label: this.text }) 38 | } 39 | } 40 | 41 | // The following methods are custom, used by the different views of the slash command. Grow your own ! _\|/_ 42 | 43 | /** 44 | * Adds an item to the list 45 | * @param {Object} args 46 | * @param {String} args.item 47 | */ 48 | addItem({ label }) { 49 | this.state.items.push({ 50 | label, 51 | done: false, 52 | }) 53 | } 54 | 55 | /** 56 | * Removes the item at item idx in the list 57 | * @param {Object} args 58 | * @param {Number} args.idx 59 | */ 60 | removeItem({ idx }) { 61 | this.state.items.splice(idx, 1) 62 | } 63 | 64 | /** 65 | * Marks an item done or not done 66 | * @param {Object} args 67 | * @param {Number} args.idx 68 | * @param {Boolean} args.done 69 | */ 70 | markDone({ idx, done = true }) { 71 | this.state.items[idx].done = done 72 | } 73 | 74 | /** 75 | * Gets the class to attribute to an item on the list 76 | * @param {Boolean} done 77 | */ 78 | getClass(done) { 79 | return done ? 'primary' : 'normal' 80 | } 81 | 82 | /** 83 | * Gets the formatted label for an item according to its done status 84 | * @param {Boolean} done 85 | * @param {String} label 86 | */ 87 | getLabel(done, label) { 88 | return done ? `☑ ~${label}~` : `☐ ${label}` 89 | } 90 | 91 | /** 92 | * Returns a section color according to an item's done status 93 | * @param {Boolean} done 94 | */ 95 | getColor(done) { 96 | return done ? 'warning' : 'good' 97 | } 98 | } 99 | 100 | module.exports = Todo 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aido 2 | 3 | _Slack applications made simple !_ 4 | 5 | Aido is a javascript framework to write Slack applications in a clean and simple way. Its intent is to take away the specificities of the Slack API, and make Slack applications code more like regular old web applications. 6 | 7 | You can think of Aido as being a basic Single Page App framework, where your app renders as Slack messages instead of a page in a browser : 8 | 9 | - Design your views using a [subset of HTML](https://github.com/aidojs/html2slack) 10 | - Write your controllers as javascript classes 11 | - Aido handles all user interactions (button clicks, slash command invocations) for you, and updates the view accordingly 12 | 13 | ## A basic example 14 | 15 | _Complete documentation can be found in the [Aido wiki](https://github.com/aidojs/aido/wiki)_ 16 | 17 | Let's create a very simple Slack application : 18 | 19 | - It handles 1 slash command, `/number` 20 | - When the user calls it, it will display a view with a Number (initialized at 0) and two buttons 21 | - When the user clicks one of the buttons, it will increment / decrement the Number and refresh the view 22 | 23 | It will render on Slack as such : 24 | 25 | ![Number slash command](docs/assets/number.png) 26 | 27 | First we describe our view using the Pug templating language 28 | ```pug 29 | # views/number.pug 30 | body 31 | section 32 | p #{state.number} 33 | section 34 | button(name="increment") Add 1 35 | button(name="decrement") Remove 1 36 | ``` 37 | 38 | Then we create our Javascript program : 39 | ```javascript 40 | // index.js 41 | const { Slash } = require('aido') 42 | 43 | // This is our controller. In Aido it is called a Slash because it represents one slash command on the Slack workspace 44 | class Number extends Slash { 45 | /** 46 | * Initializes the internal state of the application 47 | */ 48 | initState() { 49 | return { 50 | number: 0 51 | } 52 | } 53 | 54 | /** 55 | * Increments the number. This method will be called by clicking the first button. 56 | * (because it has the same `name` as the button) 57 | */ 58 | increment() { 59 | this.state.number += 1 60 | } 61 | 62 | /** 63 | * Decrements the number. This method will be called by clicking the second button. 64 | * (because it has the same `name` as the button) 65 | */ 66 | decrement() { 67 | this.state.number -= 1 68 | } 69 | 70 | /** 71 | * Bonus feature : if the user types a number next to the command (`/number 6`) 72 | * then it will be used as a start value instead of 0 73 | */ 74 | handleText() { 75 | const int = parseInt(this.args(0), 10) // arguments are passed as strings so they need to be parsed 76 | if (int) { 77 | this.state.number = int 78 | } 79 | } 80 | } 81 | 82 | // Initialize aido 83 | aido.init({ 84 | slash: { number: Number }, 85 | }) 86 | 87 | // Start the aido server (on port 3000 by default) 88 | aido.start() 89 | ``` 90 | 91 | ## Digging deeper 92 | 93 | Like what you see ? 94 | 95 | ⌨️ Jump right in the code by taking a look at the [examples folder](/examples) 96 | 97 | 📚 Or read the documentation in the [Aido wiki](https://github.com/aidojs/aido/wiki) 98 | -------------------------------------------------------------------------------- /test/lib/aido.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const sinon = require('sinon') 3 | 4 | const initSlashModule = require('../../lib/middleware/initSlash') 5 | const initSlashStub = sinon.stub(initSlashModule, 'initSlash') 6 | const aido = require('../../lib') 7 | require('../stubs') 8 | 9 | 10 | describe('Aido library - Initialization', () => { 11 | describe('Views', () => { 12 | test('Default views folder - detects and registers views', () => { 13 | aido.init() 14 | expect(aido.koaApp.context.views).toMatchObject({ 15 | testModal: { 16 | name: 'testModal', 17 | modal: true, 18 | }, 19 | testView: { 20 | name: 'testView', 21 | modal: false, 22 | }, 23 | }) 24 | }) 25 | 26 | test('Custom views folder - detects and registers views', () => { 27 | aido.init({ viewsFolder: path.join(__dirname, 'customViews') }) 28 | expect(aido.koaApp.context.views).toMatchObject({ 29 | customModal: { 30 | name: 'customModal', 31 | modal: true, 32 | }, 33 | customView: { 34 | name: 'customView', 35 | modal: false, 36 | }, 37 | }) 38 | }) 39 | }) 40 | 41 | describe('Helpers', () => { 42 | beforeEach(() => { 43 | initSlashStub.resetHistory() 44 | }) 45 | 46 | test('Register additional slash and view', () => { 47 | aido.init() 48 | aido.registerSlash('testSlash', {}) 49 | aido.registerView('additionalModal', 'body(class="modal")') 50 | aido.registerView('additionalView', 'body') 51 | expect(aido.koaApp.context.views).toMatchObject({ 52 | additionalModal: { 53 | name: 'additionalModal', 54 | modal: true, 55 | }, 56 | additionalView: { 57 | name: 'additionalView', 58 | modal: false, 59 | }, 60 | }) 61 | expect(aido.koaApp.context.slash).toMatchObject({ 62 | testSlash: {}, 63 | }) 64 | }) 65 | 66 | test('Manually emit slash', async () => { 67 | const slash = { testSlash: {} } 68 | aido.init({ slash }) 69 | await aido.emitSlash('UW0TM8', 'testSlash', 'some text', { 70 | channel: 'CW0TM8', 71 | conversationWith: ['UW0TM7'], 72 | conversationAs: 'bot', 73 | }) 74 | const [ctx] = initSlashStub.firstCall.args 75 | expect(ctx.slash).toMatchObject(slash) 76 | expect(ctx.trigger).toMatchObject({ 77 | slash: 'testSlash', 78 | text: 'some text', 79 | args: null, 80 | channel: 'CW0TM8', 81 | conversationWith: ['UW0TM7'], 82 | conversationAs: 'bot', 83 | }) 84 | }) 85 | 86 | test('Manually emit action', async () => { 87 | const slash = { testSlash: { someAction: () => null } } 88 | aido.init({ slash }) 89 | await aido.emitAction('UW0TM8', 'testSlash', 'someAction', { foo: 'bar' }, { 90 | channel: 'CW0TM8', 91 | conversationWith: ['UW0TM7'], 92 | conversationAs: 'bot', 93 | sessionId: 'SW0TM8', 94 | }) 95 | const [ctx] = initSlashStub.firstCall.args 96 | expect(ctx.slash).toMatchObject(slash) 97 | expect(ctx.trigger).toMatchObject({ 98 | slash: 'testSlash', 99 | text: null, 100 | args: { foo: 'bar' }, 101 | channel: 'CW0TM8', 102 | conversationWith: ['UW0TM7'], 103 | conversationAs: 'bot', 104 | sessionId: 'SW0TM8', 105 | }) 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /test/slash/transport.test.js: -------------------------------------------------------------------------------- 1 | const Slash = require('../../lib/slash') 2 | const stubs = require('../stubs') 3 | 4 | describe('Slash class - transport method', () => { 5 | beforeEach(() => { 6 | this.slash = new Slash() 7 | this.slash.view = { modal: false } 8 | this.slash._bot = stubs.slack 9 | this.slash.options = {} 10 | this.slash.user = { slackId: 'UW0TM8' } 11 | }) 12 | 13 | describe('Modal dialogs', () => { 14 | test('no trigger id - should not send', async () => { 15 | this.slash.view.modal = true 16 | try { 17 | await this.slash.transport('testMessage') 18 | } catch (e) { 19 | expect(e.message).toBe("Can't open a dialog without a user trigger") 20 | } 21 | expect(stubs.slack.dialog.open.called).toBe(false) 22 | }) 23 | 24 | test('trigger id provided - open dialog with trigger id', async () => { 25 | this.slash.view.modal = true 26 | this.slash.triggerId = 'foo' 27 | await this.slash.transport('testMessage') 28 | expect(stubs.slack.dialog.open.calledWithMatch({ 29 | dialog: 'testMessage', 30 | trigger_id: this.slash.triggerId, 31 | })).toBe(true) 32 | }) 33 | }) 34 | 35 | describe('One user conversations', () => { 36 | test('Response URL provided - send directly to it', async () => { 37 | this.slash.private = true 38 | this.slash.responseUrl = 'https://dummy.url.io' 39 | await this.slash.transport('testMessage') 40 | expect(stubs.request.post.calledWithMatch({ 41 | uri: this.slash.responseUrl, 42 | body: 'testMessage', 43 | json: true, 44 | })).toBe(true) 45 | }) 46 | 47 | test('No response URL provided - open an IM and send to it', async () => { 48 | const attachments = [] 49 | await this.slash.transport({ attachments }) 50 | expect(stubs.slack.im.open.calledWithMatch({ 51 | user: this.slash.user.slackId, 52 | })).toBe(true) 53 | expect(stubs.slack.chat.postMessage.calledWithMatch({ 54 | attachments, 55 | text: '', 56 | })).toBe(true) 57 | }) 58 | }) 59 | 60 | describe('Multi user conversations as bot', () => { 61 | beforeEach(() => { 62 | this.slash.private = false 63 | this.slash.conversationAs = 'bot' 64 | this.slash.isMultiConversation = true 65 | this.slash.conversationWith = ['UW0TM9'] 66 | }) 67 | 68 | test('No bot token - should not send', async () => { 69 | try { 70 | await this.slash.transport('testMessage') 71 | } catch (e) { 72 | expect(e.message).toBe("Can't open a conversation as bot without a bot token") 73 | } 74 | }) 75 | 76 | test('Public message - open a group conversation and send using the bot token', async () => { 77 | this.slash.options.botToken = 'bar' 78 | const attachments = [] 79 | await this.slash.transport({ attachments }) 80 | expect(stubs.slack.mpim.open.calledWithMatch({ 81 | users: [ 82 | this.slash.user.slackId, 83 | ...this.slash.conversationWith, 84 | ].join(','), 85 | token: this.slash.options.botToken, 86 | })).toBe(true) 87 | expect(stubs.slack.chat.postMessage.calledWithMatch({ 88 | channel: 'CW0TM8', 89 | attachments, 90 | text: '', 91 | token: this.slash.options.botToken, 92 | })).toBe(true) 93 | }) 94 | 95 | test('Private message - send ephemeral using the bot token', async () => { 96 | this.slash.private = true 97 | this.slash.options.botToken = 'bar' 98 | const attachments = [] 99 | await this.slash.transport({ attachments }) 100 | expect(stubs.slack.chat.postEphemeral.calledWithMatch({ 101 | channel: 'CW0TM8', 102 | attachments, 103 | text: '', 104 | token: this.slash.options.botToken, 105 | user: this.slash.user.slackId, 106 | })).toBe(true) 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test/middleware/initSlash.test.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon') 2 | 3 | const { initSlash } = require('../../lib/middleware/initSlash') 4 | const { assert } = require('../stubs') 5 | 6 | const next = sinon.stub() 7 | 8 | const constructorStub = sinon.stub() 9 | const handleTextStub = sinon.stub() 10 | const initDbStub = sinon.stub() 11 | const initStub = sinon.stub() 12 | const preActionStub = sinon.stub() 13 | const actionStub = sinon.stub() 14 | const postActionStub = sinon.stub() 15 | const persistStateStub = sinon.stub() 16 | const renderStub = sinon.stub() 17 | 18 | class TestSlash { 19 | constructor() { 20 | constructorStub() 21 | } 22 | handleText() { 23 | handleTextStub() 24 | } 25 | initDb() { 26 | initDbStub() 27 | } 28 | init() { 29 | initStub() 30 | } 31 | preAction() { 32 | preActionStub() 33 | } 34 | action() { 35 | actionStub() 36 | } 37 | postAction() { 38 | postActionStub() 39 | } 40 | persistState() { 41 | persistStateStub() 42 | } 43 | render() { 44 | renderStub() 45 | } 46 | } 47 | 48 | describe('Middleware - initSlash', () => { 49 | beforeEach(() => { 50 | assert.resetHistory() 51 | next.resetHistory() 52 | constructorStub.resetHistory() 53 | handleTextStub.resetHistory() 54 | initDbStub.resetHistory() 55 | initStub.resetHistory() 56 | preActionStub.resetHistory() 57 | actionStub.resetHistory() 58 | postActionStub.resetHistory() 59 | persistStateStub.resetHistory() 60 | renderStub.resetHistory() 61 | }) 62 | 63 | test('Wrong slash command - Throws an error', async () => { 64 | const ctx = { 65 | slash: {}, 66 | trigger: { slash: 'testSlash' }, 67 | assert, 68 | } 69 | // Catch error so that it doesn't fail the test 70 | await initSlash(ctx, next).catch(() => null) 71 | expect(ctx.assert.threw()).toBe(true) 72 | expect(next.called).toBe(false) 73 | }) 74 | 75 | test('Valid slash command - executes the chain of functions', async () => { 76 | const ctx = { 77 | slash: { testSlash: TestSlash }, 78 | trigger: { slash: 'testSlash', channel: 'CW0TM8' }, 79 | assert, 80 | request: { body: {} }, 81 | views: { foo: 'bar' }, 82 | } 83 | await initSlash(ctx, next) 84 | expect(assert.threw()).toBe(false) 85 | expect(constructorStub.called).toBe(true) 86 | expect(handleTextStub.called).toBe(true) 87 | expect(initDbStub.called).toBe(true) 88 | expect(initStub.called).toBe(true) 89 | expect(next.called).toBe(true) 90 | expect(preActionStub.called).toBe(false) 91 | expect(actionStub.called).toBe(false) 92 | expect(postActionStub.called).toBe(false) 93 | expect(persistStateStub.called).toBe(true) 94 | expect(renderStub.called).toBe(true) 95 | }) 96 | 97 | test('Wrong action - Throws an error', async () => { 98 | const ctx = { 99 | slash: { testSlash: TestSlash }, 100 | trigger: { slash: 'testSlash', channel: 'CW0TM8', action: 'wrongAction' }, 101 | assert, 102 | request: { body: {} }, 103 | views: { foo: 'bar' }, 104 | } 105 | await initSlash(ctx, next).catch(() => null) 106 | expect(ctx.assert.threw()).toBe(true) 107 | }) 108 | 109 | test('Valid action - executes the chain of functions', async () => { 110 | const ctx = { 111 | slash: { testSlash: TestSlash }, 112 | trigger: { slash: 'testSlash', channel: 'CW0TM8', action: 'action' }, 113 | assert, 114 | request: { body: {} }, 115 | views: { foo: 'bar' }, 116 | } 117 | await initSlash(ctx, next) 118 | expect(assert.threw()).toBe(false) 119 | expect(constructorStub.called).toBe(true) 120 | expect(handleTextStub.called).toBe(false) 121 | expect(initDbStub.called).toBe(true) 122 | expect(initStub.called).toBe(true) 123 | expect(next.called).toBe(true) 124 | expect(preActionStub.called).toBe(true) 125 | expect(actionStub.called).toBe(true) 126 | expect(postActionStub.called).toBe(true) 127 | expect(persistStateStub.called).toBe(true) 128 | expect(renderStub.called).toBe(true) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # Aido API Reference 2 | 3 | ## Views 4 | 5 | Aido view templates are written using the [Pug templating language](https://pugjs.org), and then converted to Slack attachments using html2slack. For information on supported HTML tags, and how they translate into Slack attachments, please refer to the [html2slack documentation](https://www.npmjs.com/package/html2slack). 6 | 7 | If you're not a fan of Pug, you can write plain HTML and just use pug's syntax for loops, conditions, variable interpolation etc... 8 | 9 | ### Local scope 10 | 11 | The pug template is rendered using the current instance of the Slash commands as scope ("locals"), giving you access to the state, trigger and user informations, etc... You can also use methods present on the class. 12 | 13 | ```pug 14 | body 15 | section 16 | p User always contains the user's Slack ID : #{user.slackId} 17 | p You can use methods on the Slash class : #{capitalize('hello world')} 18 | p And access the state directly : #{state.score} 19 | ``` 20 | 21 | ```javascript 22 | class App extends Slash { 23 | // ... 24 | capitalize(text) { 25 | return text.toUpperCase() 26 | } 27 | // ... 28 | } 29 | ``` 30 | 31 | ## Interactive components 32 | 33 | ### Buttons 34 | 35 | Buttons are identified by their `name` attribute. When a user clicks a button, the corresponding method in the Slash class is executed. You can additionally provide a `value`, which will be passed as an argument to the method. The value can be a string or a javascript object. 36 | 37 | ```pug 38 | body 39 | section 40 | button(name="doSomething") Without argument 41 | button(name="doOtherThing" value={ name: 'Jack' }) With argument 42 | ``` 43 | 44 | ```javascript 45 | class App extends Slash { 46 | // ... 47 | doSomething() { 48 | // No argument provided 49 | } 50 | doOtherThing(arg) { 51 | console.log(arg.name) 52 | } 53 | // ... 54 | } 55 | ``` 56 | 57 | ### Dialogs 58 | 59 | Forms are identified by their `action` attribute, and the inputs are identified by their `name`. The content of the inputs is passed as an argument to the corresponding method on the Slash class. *Please refer to the html2slack documentation for complete field reference.* 60 | 61 | ```pug 62 | body(class="modal") 63 | section 64 | form(action="save") 65 | header My form 66 | label A text input 67 | input(type="text" name="textInput") 68 | label A select 69 | select(name="selectInput") 70 | option(value="foo") Foo 71 | option(value="bar") Bar 72 | option(value="baz") Baz 73 | input(type="submit" value="Enregistrer") 74 | ``` 75 | 76 | ```javascript 77 | class App extends Slash { 78 | // ... 79 | save(arg) { 80 | console.log(arg.textInput) 81 | console.log(arg.selectInput) 82 | } 83 | // ... 84 | } 85 | ``` 86 | 87 | ### Custom templaters 88 | If you'd like to code your views totally differently, you can pass a custom templater on aido initialization. If your custom templater outputs HTML, you can use `next` to send it through html2slack for conversion. Below is the commented code for the default Pug templater : 89 | 90 | ```javascript 91 | /** 92 | * Renders a pug view and converts it with html2slack 93 | * @param {Object} view - An aido view 94 | * @param {String} view.name - The name of the view 95 | * @param {Boolean} view.modal - True if the view should be rendered as a Slack Dialog 96 | * @param {String} view.template - The view template in Pug 97 | * @param {Object} locals - The current Slash instance is used as context for rendering the view 98 | * @param {Object} locals.state - The state of the session with the current user 99 | * @param {Function} next - This simply sends the templated HTML through html2slack 100 | * @returns {Object} 101 | */ 102 | async function pugTemplater(view, locals, next) { 103 | const templatedHtml = pug.render(view.template ,locals) 104 | return next(templatedHtml) 105 | } 106 | 107 | // We pass our custom templater when initializing our aido application 108 | aido.init({ template: pugTemplater }) 109 | ``` 110 | -------------------------------------------------------------------------------- /test/slash/session.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const sinon = require('sinon') 3 | 4 | const Slash = require('../../lib/slash') 5 | const databaseFactory = require('../../lib/database') 6 | 7 | describe('Slash class - Session management', () => { 8 | beforeEach(() => { 9 | this.slash = new Slash() 10 | this.slash.command = 'foo' 11 | this.slash.user = { slackId: 'UW0TM8888' } 12 | }) 13 | 14 | describe('Session ID', () => { 15 | test('Simple conversation - Session ID is the command name', () => { 16 | expect(this.slash.sessionId).toBe(this.slash.command) 17 | }) 18 | 19 | test('Multi conversation - Session ID is the command name suffixed with the user IDs', () => { 20 | this.slash.conversationWith = ['UW0TM8', 'UW0TM9'] 21 | const expectedSessionId = [this.slash.command, this.slash.user.slackId, ...this.slash.conversationWith].join('-') 22 | expect(this.slash.sessionId).toBe(expectedSessionId) 23 | }) 24 | 25 | test('Multi conversation with a session ID - just return it', () => { 26 | this.slash.conversationWith = ['UW0TM8', 'UW0TM9'] 27 | this.slash.trigger = { sessionId: 'foo' } 28 | expect(this.slash.sessionId).toBe(this.slash.trigger.sessionId) 29 | }) 30 | }) 31 | 32 | describe('Session state retrieval', () => { 33 | beforeEach(() => { 34 | this.initialState = 'initialState' 35 | this.memoryState = 'memoryState' 36 | this.persistentState = 'persistentState' 37 | this.slash.initState = sinon.stub().callsFake(() => this.initialState) 38 | }) 39 | 40 | test('Session is present in memory - return it', async () => { 41 | sinon.stub(this.slash, 'inMemorySession').get(() => ({ state: this.memoryState })) 42 | await this.slash.setUser('UW0TM1') 43 | expect(this.slash.state).toBe(this.memoryState) 44 | }) 45 | 46 | test('No session in memory - find it in database and cache it in memory', async () => { 47 | sinon.stub(this.slash, 'getPersistedSession').callsFake(() => ({ state: this.persistentState })) 48 | await this.slash.setUser('UW0TM2') 49 | expect(this.slash.state).toBe(this.persistentState) 50 | // Session should now be cached in memory 51 | expect(this.slash.inMemorySession).toMatchObject({ state: this.persistentState }) 52 | }) 53 | 54 | test('No session in memory or db - use slash initState', async () => { 55 | sinon.stub(this.slash, 'getPersistedSession').callsFake(() => null) 56 | await this.slash.setUser('UW0TM3') 57 | expect(this.slash.initState.called).toBe(true) 58 | expect(this.slash.state).toBe(this.initialState) 59 | // Session should now be cached in memory 60 | expect(this.slash.inMemorySession).toMatchObject({ state: this.initialState }) 61 | }) 62 | 63 | test('No session in memory and no persistent storage - use slash initState', async () => { 64 | this.slash.persistentState = false 65 | const getPersistedSessionStub = sinon.stub(this.slash, 'getPersistedSession').callsFake(() => ({ state: this.persistentState })) 66 | await this.slash.setUser('UW0TM4') 67 | expect(this.slash.initState.called).toBe(true) 68 | expect(getPersistedSessionStub.called).toBe(false) 69 | expect(this.slash.state).toBe(this.initialState) 70 | // Session should now be cached in memory 71 | expect(this.slash.inMemorySession).toMatchObject({ state: this.initialState }) 72 | }) 73 | }) 74 | 75 | describe('Session persistence', () => { 76 | beforeEach(async () => { 77 | if (fs.existsSync(`${__dirname}/test.db`)) { 78 | fs.unlinkSync(`${__dirname}/test.db`) 79 | } 80 | this.database = await databaseFactory(`${__dirname}/test.db`) 81 | await this.database.Session.query().truncate() 82 | this.slash.database = this.database 83 | }) 84 | 85 | test('No persistent storage - nothing is written to the database', async () => { 86 | this.slash.persistentState = false 87 | await this.slash.persistState() 88 | const sessions = await this.database.Session.query() 89 | expect(sessions.length).toBe(0) 90 | }) 91 | 92 | test('No existing session in database - insert', async () => { 93 | this.slash.state = { foo: 'bar' } 94 | await this.slash.persistState() 95 | const [session] = await this.database.Session.query() 96 | expect(session).not.toBeNull() 97 | expect(session.state).toEqual(this.slash.state) 98 | }) 99 | 100 | test('Existing session in database - update', async () => { 101 | this.slash.state = { foo: 'bar' } 102 | await this.slash.persistState() 103 | this.slash.state = { foo: 'baz' } 104 | await this.slash.persistState() 105 | // Check that there is only one session in database, and that its state corresponds to the last one persisted 106 | const sessions = await this.database.Session.query() 107 | expect(sessions.length).toBe(1) 108 | const [session] = sessions 109 | expect(session.state).toEqual(this.slash.state) 110 | }) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /lib/routes.js: -------------------------------------------------------------------------------- 1 | const koaBody = require('koa-body') 2 | const { set, isFunction } = require('lodash') 3 | const isJson = require('is-json') 4 | 5 | const { checkToken } = require('./middleware/checkToken') 6 | const { normalizeBody } = require('./middleware/normalizeBody') 7 | const { initSlash } = require('./middleware/initSlash') 8 | const { emptyResponse } = require('./middleware/emptyResponse') 9 | const customApi = require('./utils/customApi') 10 | 11 | /** 12 | * Initializes the Koa app with the Aido middlewares 13 | * @param {Object} app - The Koa app 14 | * @param {Object} router - The Koa router 15 | */ 16 | function applyRoutes(app, router) { 17 | // Parse request body 18 | app.use(koaBody({ includeUnparsed: true })) 19 | 20 | router.get('', '/health', (ctx) => { ctx.body = '🍪'}) 21 | 22 | // These routes are requests from Slack webhooks 23 | router.post( 24 | 'slash', 25 | '/slash', 26 | checkToken, 27 | normalizeBody, 28 | // Identify slash command 29 | async (ctx, next) => { 30 | const { command, text, channelId, teamId } = ctx.request.body 31 | set(ctx, 'trigger.team', teamId) 32 | set(ctx, 'trigger.slash', command.replace(/\//g, '')) 33 | set(ctx, 'trigger.text', text) 34 | set(ctx, 'trigger.channel', channelId) 35 | await next() 36 | }, 37 | initSlash, 38 | // Get user's ID from payload 39 | async (ctx, next) => { 40 | const slackId = ctx.request.body.userId 41 | await next() 42 | // Once the request has been responded to, load the user profile & session 43 | await ctx.slash.setUser(slackId) 44 | }, 45 | emptyResponse, 46 | ) 47 | 48 | router.post( 49 | 'action', 50 | '/action', 51 | // Decode the JSON payload 52 | async (ctx, next) => { 53 | ctx.request.body = { 54 | ...JSON.parse(ctx.request.body.payload), 55 | [Symbol.for('unparsedBody')]: ctx.request.body[Symbol.for('unparsedBody')], 56 | } 57 | await next() 58 | }, 59 | checkToken, 60 | normalizeBody, 61 | // Identify the slash command and text from the payload 62 | async (ctx, next) => { 63 | const sessionId = ctx.request.body.callbackId 64 | const { team } = ctx.request.body 65 | const [slash, originator, ...conversationWith] = sessionId.split('-') 66 | set(ctx, 'trigger.team', team.id) 67 | set(ctx, 'trigger.channel', ctx.request.body.channel.id) 68 | set(ctx, 'trigger.slash', slash) 69 | set(ctx, 'trigger.conversationWith', conversationWith.length > 0 ? conversationWith : null) 70 | set(ctx, 'trigger.originator', originator) 71 | set(ctx, 'trigger.sessionId', conversationWith.length > 0 ? sessionId : null) 72 | set(ctx, 'trigger.text', null) 73 | await next() 74 | }, 75 | initSlash, 76 | // Get user ID from payload 77 | async (ctx, next) => { 78 | const slackId = ctx.request.body.user.id 79 | await next() 80 | // Once the request has been responded to, load the user profile & session 81 | await ctx.slash.setUser(slackId) 82 | }, 83 | // Identify the action 84 | async (ctx, next) => { 85 | if (ctx.request.body.type === 'interactive_message') { 86 | const [action] = ctx.request.body.actions 87 | const { name, value } = action 88 | if (name === 'view') { 89 | ctx.assert(!!ctx.views[value], 404, `View ${value} is not configured on this server`) 90 | ctx.slash.setView(value) 91 | } else { 92 | set(ctx, 'trigger.action', name) 93 | set(ctx, 'trigger.args', isJson.strict(value) ? JSON.parse(value) : value) 94 | } 95 | } 96 | if (ctx.request.body.type === 'dialog_submission') { 97 | const action = ctx.request.body.state 98 | const args = ctx.request.body.submission 99 | ctx.assert( 100 | ctx.slash[action] && isFunction(ctx.slash[action]), 101 | 404, `Action ${action} is not configured on the command ${ctx.slash.command}`, 102 | ) 103 | set(ctx, 'trigger.action', action) 104 | set(ctx, 'trigger.args', args) 105 | } 106 | await next() 107 | }, 108 | emptyResponse, 109 | ) 110 | 111 | // This route is the last step of the oauth token exchange 112 | router.get( 113 | 'authorize', 114 | '/authorize', 115 | async (ctx) => { 116 | const { code } = ctx.request.query 117 | const res = await customApi('oauth.v2.access', { 118 | code, 119 | client_id: ctx.options.clientId, 120 | client_secret: ctx.options.clientSecret, 121 | }) 122 | ctx.assert(res.ok, 401, `Installation failed : ${res.error}`) 123 | const { team, access_token } = res 124 | await ctx.database.Oauth.query().insert({ 125 | team: team.id, 126 | token: access_token, 127 | profile: res, 128 | }) 129 | ctx.body = 'Congratulations ! Installation was successfull 🎉' 130 | }, 131 | ) 132 | 133 | // This route is the landing page when users install your app from the Slack Apps Directory 134 | router.get( 135 | 'install', 136 | '/install', 137 | async (ctx) => { 138 | const url = `https://slack.com/oauth/v2/authorize?client_id=${ctx.options.clientId}&scope=chat:write,commands` 139 | ctx.redirect(url) 140 | }, 141 | ) 142 | 143 | app.use(router.routes()) 144 | return router 145 | } 146 | 147 | module.exports = applyRoutes 148 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa') 2 | const Router = require('koa-router') 3 | const path = require('path') 4 | const Slack = require('slack') 5 | const Promise = require('bluebird') 6 | const { mapValues, isFunction } = require('lodash') 7 | 8 | const pugTemplater = require('./templaters/pug') 9 | const applyRoutes = require('./routes') 10 | let Slash = require('./slash') 11 | const { initSlash } = require('./middleware/initSlash') 12 | const { checkToken } = require('./middleware/checkToken') 13 | const logger = require('./utils/logger') 14 | const { detectViews, unpackView } = require('./utils/detectViews') 15 | 16 | const databaseFactory = require('./database') 17 | 18 | const app = new Koa() 19 | const router = new Router() 20 | const helpers = {} 21 | 22 | /** 23 | * Configure global Aido application 24 | * @param {Object} options 25 | * @param {Object} options.slash - The list of slashes configured on this server 26 | * @param {Object[]} options.plugins - An array of plugins used by this server. They will be initialized in order. 27 | * @param {Boolean} options.getSlackProfile - If true, all invocations of the app will query the Slack API for the 28 | * user's full profile 29 | * @param {Function} options.template - A custom HTML templater can be provided (see templaters/pug.js) 30 | * @param {String} options.persistentStorage - the path to the sqlite database containing the user sessions 31 | * @param {String} options.viewsFolder - the path to the folder containing the views 32 | * @param {String} options.viewsTemplateExtension - the extension of the view templates 33 | */ 34 | function init(options = {}) { 35 | // Detect folder of the calling module 36 | const callingFolder = path.dirname(require.main.filename) 37 | app.context.options = { 38 | getSlackProfile: false, 39 | template: pugTemplater, 40 | hints: true, 41 | viewsFolder: path.join(callingFolder, 'views'), 42 | viewsTemplateExtension: 'pug', 43 | persistentStorage: path.join(callingFolder, 'sessions.db'), 44 | ...options, 45 | } 46 | app.context.slash = options.slash || {} 47 | app.context.views = detectViews(app.context.options.viewsFolder, app.context.options.viewsTemplateExtension) 48 | app.context.plugins = options.plugins || [] 49 | } 50 | 51 | /** 52 | * Registers an additional Slash command 53 | * @param {String} name - the name of the command 54 | * @param {Slash} slash - the class of the command (must be derived from the Slash class) 55 | */ 56 | function registerSlash(name, slash) { 57 | app.context.slash[name] = slash 58 | } 59 | 60 | /** 61 | * Unpacks and registers an additional view 62 | * @param {String} name 63 | * @param {String} template 64 | */ 65 | function registerView(name, template) { 66 | app.context.views[name] = unpackView(name, template) 67 | } 68 | 69 | async function applyPlugins() { 70 | const koa = { app, router } 71 | const middleware = { checkToken, initSlash } 72 | const utils = { registerSlash, registerView, emitSlash, emitAction, helpers, middleware } 73 | // Initialize all the plugins 74 | await Promise.mapSeries(app.context.plugins, async (pluginFactory) => { 75 | // Build the plugin with the utils 76 | const plugin = pluginFactory(koa, utils) 77 | // Extend the Aido DB with the plugin tables 78 | if (isFunction(plugin.extendDb)) { 79 | await plugin.extendDb(app.context.database) 80 | } 81 | // Initialize the plugin 82 | if (isFunction(plugin.initPlugin)) { 83 | await plugin.initPlugin(app.context.database) 84 | } 85 | // Register the plugin helpers 86 | if (isFunction(plugin.getHelpers)) { 87 | const pluginHelpers = await plugin.getHelpers(app.context.database) 88 | helpers[plugin.name] = pluginHelpers 89 | } 90 | // Extend the slashes with the plugin methods 91 | if (isFunction(plugin.slashFactory)) { 92 | // Extend all the existing slashes 93 | app.context.slash = mapValues(app.context.slash, slash => plugin.slashFactory(slash)) 94 | } 95 | }) 96 | } 97 | 98 | /** 99 | * Starts the Koa application and listens on the given port 100 | * @param {Number} port 101 | */ 102 | async function start(port = 3000) { 103 | // Init the database connection 104 | app.context.database = await databaseFactory(app.context.options.persistentStorage) 105 | // Connect the Slack client 106 | if (app.context.options.appToken) { 107 | // Single tenant bot token 108 | app.context.bot = new Slack({ token: app.context.options.appToken }) 109 | } else { 110 | const teams = await app.context.database.Oauth.query() 111 | .select('team', 'token') 112 | app.context.bot = {} 113 | teams.forEach(({ team, token }) => { 114 | app.context.bot[team] = new Slack({ token }) 115 | }) 116 | } 117 | await applyPlugins() 118 | applyRoutes(app, router) 119 | app.listen(port) 120 | } 121 | 122 | /** 123 | * Replacement of the koa ctx.assert method 124 | * @param {Boolean} value 125 | * @param {Number} status 126 | * @param {String} msg 127 | */ 128 | function assert(value, status, msg) { 129 | if (value) return 130 | throw new Error(msg) 131 | } 132 | 133 | /** 134 | * Emit a slash command 135 | * @param {String} user 136 | * @param {String} command 137 | * @param {String} text 138 | * @param {Object} args 139 | * @param {Object} transportOptions 140 | * @param {String?} transportOptions.channel 141 | * @param {String?} transportOptions.conversationWith 142 | * @param {String?} transportOptions.conversationAs 143 | */ 144 | async function emitSlash(user, command, text, transportOptions = {}) { 145 | const { slash, views, options, bot, database } = app.context 146 | const ctx = { 147 | slash, 148 | views, 149 | assert, 150 | options, 151 | bot, 152 | database, 153 | request: { body: { } }, 154 | trigger: { 155 | slash: command, 156 | text: text, 157 | args: null, 158 | channel: transportOptions.channel, 159 | conversationWith: transportOptions.conversationWith, 160 | conversationAs: transportOptions.conversationAs, 161 | }, 162 | } 163 | await initSlash(ctx, () => ctx.slash.setUser(user)) 164 | logger.info(`Command ${command} emitted for user ${user}`) 165 | } 166 | 167 | /** 168 | * Emit an action on a slash command 169 | * @param {String} user 170 | * @param {String} command 171 | * @param {String} action 172 | * @param {Object} args 173 | * @param {Object} transportOptions 174 | * @param {String?} transportOptions.channel 175 | * @param {String?} transportOptions.conversationWith 176 | * @param {String?} transportOptions.conversationAs 177 | * @param {String?} transportOptions.sessionId 178 | */ 179 | async function emitAction(user, command, action, args = {}, transportOptions = {}) { 180 | const { slash, views, options, bot, database } = app.context 181 | const ctx = { 182 | slash, 183 | views, 184 | assert, 185 | options, 186 | bot, 187 | database, 188 | request: { body: { } }, 189 | trigger: { 190 | slash: command, 191 | action, 192 | args, 193 | text: null, 194 | channel: transportOptions.channel, 195 | conversationWith: transportOptions.conversationWith, 196 | conversationAs: transportOptions.conversationAs, 197 | sessionId: transportOptions.sessionId, 198 | }, 199 | } 200 | await initSlash(ctx, () => ctx.slash.setUser(user)) 201 | logger.info(`Action ${action} on command ${command} emitted for user ${user}`) 202 | } 203 | 204 | module.exports = { 205 | Slash, 206 | 207 | init, 208 | start, 209 | 210 | helpers, 211 | registerSlash, 212 | registerView, 213 | emitSlash, 214 | emitAction, 215 | 216 | koaApp: app, 217 | koaRouter: router, 218 | } 219 | -------------------------------------------------------------------------------- /lib/slash.js: -------------------------------------------------------------------------------- 1 | const { isArray, each, set, forIn, isObject } = require('lodash') 2 | const html2slack = require('html2slack') 3 | const request = require('request-promise-native') 4 | 5 | const normalizeObject = require('./utils/normalizeObject') 6 | 7 | const sessions = [] 8 | 9 | class Slash { 10 | /** 11 | * Construct the Slash 12 | * @param {Object} trigger - The invocation context 13 | * @param {String} trigger.slash - The invoked Slash command 14 | * @param {String} trigger.text - The text accompanying the Slash command 15 | * @param {String} trigger.conversationWith - An array of users with whom the conversation should take place 16 | * @param {String} trigger.conversationAs - the kind of token to use ('bot' or 'user') 17 | * @param {String} trigger.sessionId - The session ID from the callback_id of the Slack payload 18 | * @param {String} responseUrl - The Slack webhook where to send the response 19 | * @param {String} triggerId - Trigger ID used to create dialogs 20 | * @param {String} channel - The ID of the channel where the command was invoked 21 | * @param {Object} options - The general app options 22 | * @param {Object} bot - The Slack API client 23 | * @param {Object} database - The opened database 24 | */ 25 | constructor(trigger = {}, responseUrl, triggerId, channel, options = {}, bot, database) { 26 | const { slash: command, text, conversationWith, conversationAs } = trigger 27 | // General app options 28 | this.options = options 29 | this.template = options.template 30 | this.getSlackProfile = options.getSlackProfile 31 | this._bot = bot 32 | this.database = database 33 | // Properties of the slash command invocation 34 | this.command = command 35 | this.text = text 36 | this.responseUrl = responseUrl 37 | this.triggerId = triggerId 38 | this.channel = channel 39 | this.conversationWith = conversationWith 40 | this.conversationAs = conversationAs 41 | this.trigger = trigger 42 | // Command specific options 43 | this.private = true 44 | this.callable = true 45 | this.multiUsers = false 46 | this.persistentState = true 47 | this.user = null 48 | this.view = command 49 | this.noView = false 50 | // Command internal state 51 | this._state = {} 52 | } 53 | 54 | /** 55 | * Assigns a value to the state without losing the reference 56 | * @param {Object} newState 57 | */ 58 | set state(newState) { 59 | if (!isObject(newState)) { 60 | throw new Error('State can only contain objects') 61 | } 62 | forIn(this._state, (v, key) => { delete this._state[key] }) 63 | forIn(newState, (value, key) => { this._state[key] = value }) 64 | } 65 | 66 | /** 67 | * Getter for _state 68 | */ 69 | get state() { 70 | return this._state 71 | } 72 | 73 | /** 74 | * Helper that determines if this is a multi-tenant installation 75 | */ 76 | get isMultitenant() { 77 | return this.trigger.team && !!this._bot[this.trigger.team] 78 | } 79 | 80 | /** 81 | * Getter for _bot. Returns a Slack instance initialized with the correct token 82 | */ 83 | get bot() { 84 | if (this.isMultitenant) { 85 | return this._bot[this.trigger.team] 86 | } 87 | return this._bot 88 | } 89 | 90 | /** 91 | * Checks that we have the relevant tokens to send a multi-conversation 92 | */ 93 | get canSendMultiConversation() { 94 | if (!this.conversationAs === 'bot') { 95 | return true 96 | } 97 | if (this.isMultitenant) { 98 | return true 99 | } 100 | if (this.options.appToken.startsWith('xoxb')) { 101 | return true 102 | } 103 | if (this.options.botToken) { 104 | return true 105 | } 106 | return false 107 | } 108 | 109 | /** 110 | * Bogus init function. Should be overridden by a custom one. 111 | */ 112 | async init() { 113 | return true 114 | } 115 | 116 | /** 117 | * This function can be used to extend the database in a plugin or slash 118 | */ 119 | async initDb() { 120 | return true 121 | } 122 | 123 | /** 124 | * Bogus initState function. Can be overridden by a custom one 125 | */ 126 | async initState() { 127 | return {} 128 | } 129 | 130 | /** 131 | * Bogus converseWith function. Can be overridden by a custom one 132 | */ 133 | async converseWith() { 134 | return true 135 | } 136 | 137 | /** 138 | * Bogus preAction function. Can be overridden by a custom one 139 | */ 140 | async preAction() { 141 | return true 142 | } 143 | 144 | /** 145 | * Bogus handleText function. Can be overridden by a custom one 146 | */ 147 | async handleText() { 148 | return true 149 | } 150 | 151 | /** 152 | * Bogus postAction function. Can be overridden by a custom one 153 | */ 154 | async postAction() { 155 | return true 156 | } 157 | 158 | /** 159 | * Gets the text accompanying the command invocation as an array of strings 160 | */ 161 | get args() { 162 | return this.text.split(' ') 163 | } 164 | 165 | /** 166 | * Convenience method to get the first argument 167 | */ 168 | get subCommand() { 169 | if (this.args.length === 0) { 170 | return '' 171 | } 172 | return this.args[0] 173 | } 174 | 175 | /** 176 | * Returns true if this conversation is transported to a multi part IM 177 | * @returns {Boolean} 178 | */ 179 | get isMultiConversation() { 180 | return isArray(this.conversationWith) && this.conversationWith.length > 0 181 | } 182 | 183 | /** 184 | * Returns true if the user making an action is the originator of the command 185 | * @returns {Boolean} 186 | */ 187 | get isOriginator() { 188 | if (!this.isMultiConversation) { 189 | return true 190 | } 191 | return this.user.slackId === this.trigger.originator 192 | } 193 | 194 | /** 195 | * The session ID is the command name, optionnally suffixed with the conversation users, in the following format : 196 | * - command 197 | * - command-UXXXXXX-UXXXXXX 198 | * @returns {String} 199 | */ 200 | get sessionId() { 201 | if (!this.isMultiConversation) { 202 | return this.command 203 | } 204 | if (this.trigger.sessionId) { 205 | return this.trigger.sessionId 206 | } 207 | return [ 208 | this.command, 209 | this.user.slackId, 210 | ...this.conversationWith, 211 | ].join('-') 212 | } 213 | 214 | /** 215 | * Forgets the response URL so Aido has to create a new view and request a new transport 216 | */ 217 | requestNewView() { 218 | this.responseUrl = null 219 | } 220 | 221 | /** 222 | * Sets the user from the application's context 223 | * @param {Object} user 224 | */ 225 | async setUser(slackId) { 226 | this.user = { slackId } 227 | 228 | if (this.getSlackProfile) { 229 | const { user } = await this.bot.users.info({ user: slackId }) 230 | this.user.slackProfile = normalizeObject(user) 231 | } 232 | await this.getOrCreateSession() 233 | } 234 | 235 | /** 236 | * Sets the view to display 237 | * @param {String} view 238 | */ 239 | setView(view) { 240 | this.view = view 241 | } 242 | 243 | /** 244 | * Generates a callback id for this action with these arguments 245 | * @param {String} action 246 | * @param {Object} args 247 | */ 248 | $button(action, args) { 249 | return `action:${action}:${JSON.stringify(args)}` 250 | } 251 | 252 | /** 253 | * Generates a callback id for this view 254 | * @param {String} view 255 | */ 256 | $view(view) { 257 | return `view:${view}` 258 | } 259 | 260 | /** 261 | * Generates a callback ID for an input 262 | * @param {String} action 263 | */ 264 | $input(action) { 265 | return `input:${action}` 266 | } 267 | 268 | /** 269 | * Gets the session from memory cache. Sessions are indexed : 270 | * - by command and user (single conversation) 271 | * - by session ID (multi conversation) 272 | * @returns {Object} 273 | */ 274 | get inMemorySession() { 275 | if (this.isMultiConversation) { 276 | return sessions.find(session => session.id === this.sessionId) 277 | } 278 | return sessions.find(session => ( 279 | (session.user === this.user.slackId && session.id === this.sessionId) 280 | && (session.team === null || session.team === this.trigger.team) 281 | )) 282 | } 283 | 284 | /** 285 | * Gets the session from database. Sessions are indexed : 286 | * - by command and user (single conversation) 287 | * - by session ID (multi conversation) 288 | */ 289 | async getPersistedSession() { 290 | if (this.isMultiConversation) { 291 | return this.database.Session.query().findOne('id', this.sessionId) 292 | } 293 | const { team } = this.trigger 294 | const { slackId } = this.user 295 | if (!team) { 296 | // Get older sessions which don't have a team 297 | return this.database.Session.query() 298 | .first() 299 | .whereNull('team').where('user', slackId) 300 | } 301 | // Get newer session with a team 302 | return this.database.Session.query() 303 | .first() 304 | .where('team', team).where('user', slackId) 305 | } 306 | 307 | /** 308 | * Tries to find an active session for this user and this slash 309 | * - First in memory 310 | * - Then in persistent storage if the command allows it 311 | * If no session is found, one is created and initialized with the slash's init function 312 | */ 313 | async getOrCreateSession() { 314 | // First try to find a session in memory 315 | const { inMemorySession } = this 316 | if (inMemorySession) { 317 | return this._state = inMemorySession.state 318 | } 319 | 320 | // Otherwise fetch one from the persistent storage 321 | if (this.persistentState) { 322 | const persistentSession = await this.getPersistedSession() 323 | if (persistentSession) { 324 | // Copy the session in memory for subsequent use 325 | sessions.push({ 326 | id: this.sessionId, 327 | team: this.trigger.team, 328 | user: this.user.slackId, 329 | state: persistentSession.state, 330 | }) 331 | return this._state = persistentSession.state 332 | } 333 | } 334 | 335 | // Otherwise create one with the command's init function, store it in memory and return the state 336 | // The session will be persisted in persistState 337 | const initialState = await this.initState() 338 | this._state = initialState 339 | return sessions.push({ 340 | id: this.sessionId, 341 | user: this.user.slackId, 342 | state: this._state, 343 | }) 344 | } 345 | 346 | /** 347 | * Persists the current state of the slash (if needed) 348 | */ 349 | async persistState() { 350 | if (this.persistentState) { 351 | const persistentSession = await this.getPersistedSession() 352 | if (persistentSession) { 353 | return persistentSession.$query().patch({ team: this.trigger.team, state: this._state }) 354 | } 355 | return this.database.Session.query().insert({ 356 | id: this.sessionId, 357 | team: this.trigger.team, 358 | user: this.user.slackId, 359 | state: this._state, 360 | }) 361 | } 362 | } 363 | 364 | /** 365 | * Executes the Slash command with the information present in the context 366 | * @param {Object} views - the application's views collection 367 | */ 368 | async render(views) { 369 | if (!this.noView) { 370 | // Replace the view name with the actual view configuration 371 | this.view = views[this.view] 372 | // Render the view using the templater 373 | const message = await this.template(this.view, this, html => html2slack(html)) 374 | if (this.view.modal) { 375 | // Patch command name in the callback ID 376 | message.callback_id = this.sessionId 377 | } else { 378 | // Patch all attachments with a callback id so the buttons work 379 | each(message.attachments, attachment => set(attachment, 'callback_id', this.sessionId)) 380 | } 381 | await this.transport(message) 382 | } 383 | } 384 | 385 | /** 386 | * Transports the view to the user 387 | * @param {Object} message 388 | */ 389 | async transport(message) { 390 | if (this.view.modal) { 391 | if (!this.triggerId) { 392 | throw new Error("Can't open a dialog without a user trigger") 393 | } 394 | return this.bot.dialog.open({ dialog: message, trigger_id: this.triggerId }) 395 | } 396 | if (this.private && this.responseUrl) { 397 | return request.post({ 398 | uri: this.responseUrl, 399 | body: message, 400 | json: true, 401 | }) 402 | } 403 | // If this is a multipart conversation that doesn't have a channel yet, open the conversation 404 | if (this.isMultiConversation) { 405 | if (!this.canSendMultiConversation) { 406 | throw new Error("Can't open a conversation as bot without a bot token") 407 | } 408 | if (!this.channel) { 409 | const { group } = await this.bot.mpim.open({ 410 | users: [ 411 | this.user.slackId, 412 | ...this.conversationWith, 413 | ].join(','), 414 | ...this.conversationAs === 'bot' && { token: this.options.botToken }, 415 | }) 416 | this.channel = group.id 417 | } 418 | } 419 | // If no channel is readily available, open a DM with the user 420 | if (!this.channel) { 421 | const { channel } = await this.bot.im.open({ 422 | user: this.user.slackId, 423 | }) 424 | this.channel = channel.id 425 | } 426 | const payload = { 427 | channel: this.channel, 428 | attachments: message.attachments, 429 | text: '', 430 | ...this.conversationAs === 'bot' && { token: this.options.botToken }, 431 | } 432 | // A private message in a multi-party conversation should be sent as an ephemeral 433 | if (this.isMultiConversation && this.private) { 434 | return this.bot.chat.postEphemeral({ 435 | ...payload, 436 | user: this.user.slackId, 437 | }) 438 | } 439 | return this.bot.chat.postMessage(payload) 440 | } 441 | } 442 | 443 | module.exports = Slash 444 | --------------------------------------------------------------------------------