├── .gitignore ├── .jshintrc ├── pagerduty-icon.png ├── event.json ├── event.prod.json ├── .jscsrc ├── recurse-event.json ├── .editorconfig ├── configure-swapfile.sh ├── serverless.env.yml ├── Vagrantfile ├── test ├── index.test.js ├── slack.test.js ├── pagerduty.test.js └── commands.test.js ├── package.json ├── LICENSE ├── slack.js ├── serverless.yml ├── README.md ├── pagerduty.js ├── index.js └── commands.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .serverless/ 3 | .vagrant/ 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 6, 3 | "node": true 4 | } 5 | -------------------------------------------------------------------------------- /pagerduty-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/section/pagerduty-oncall-slack-bot/HEAD/pagerduty-icon.png -------------------------------------------------------------------------------- /event.json: -------------------------------------------------------------------------------- 1 | { 2 | "stage": "dev", 3 | "body": "token=FLOOP&user_name=bob&command=/oncall&channel_name=general&text=now" 4 | } 5 | -------------------------------------------------------------------------------- /event.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "stage": "prod", 3 | "body": "token=FLOOP&user_name=bob&command=/oncall&channel_name=general&text=now" 4 | } 5 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "crockford", 3 | "disallowTrailingComma": false, 4 | "requireMultipleVarDecl": false, 5 | "requireVarDeclFirst": false 6 | } 7 | -------------------------------------------------------------------------------- /recurse-event.json: -------------------------------------------------------------------------------- 1 | { 2 | "stage": "dev", 3 | "hasRecursed": "true", 4 | "commandName": "delayedNowResponse", 5 | "commandArgument": { 6 | "responseUrl": "http://127.0.0.1:7/" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.js*] 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [*.yml] 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /configure-swapfile.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o errexit -o xtrace 3 | 4 | [ 0 -eq "${EUID}" ] || { 5 | echo root required >&2 6 | exit 1 7 | } 8 | 9 | swapon --summary | grep ^/swapfile && { 10 | echo swap configured 11 | free --mega 12 | exit 0 13 | } 14 | 15 | test -a /swapfile || 16 | dd if=/dev/zero of=/swapfile bs=1M count=1K # 1GiB 17 | 18 | blkid /swapfile || 19 | mkswap /swapfile 20 | 21 | swapon /swapfile 22 | -------------------------------------------------------------------------------- /serverless.env.yml: -------------------------------------------------------------------------------- 1 | # This is the Serverless Environment File 2 | # 3 | # It contains listing of your stages and their regions 4 | # It also manages serverless variables at 3 levels: 5 | # - common variables: variables that apply to all stages/regions 6 | # - stage variables: variables that apply to a specific stage 7 | # - region variables: variables that apply to a specific region 8 | 9 | vars: 10 | stages: 11 | dev: 12 | regions: 13 | us-east-1: 14 | prod: 15 | regions: 16 | us-east-1: 17 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | config.vm.box = "ubuntu/trusty64" 6 | 7 | config.vm.provider "virtualbox" do |v| 8 | # `Serverless: Zipping service...` may get killed due to out-of-memory 9 | # TODO v.memory = 1024 10 | # or 11 | # sudo apt-get purge --assume-yes puppet chef 12 | # sudo /vagrant/configure-swapfile.sh 13 | end 14 | 15 | config.vm.provision "shell", inline: <<-SHELL 16 | curl --silent --location https://deb.nodesource.com/setup_4.x | bash - 17 | apt-get install --assume-yes nodejs python-pip 18 | npm install --global serverless 19 | pip install awscli 20 | SHELL 21 | end 22 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect; 4 | 5 | var index = require('../index'); 6 | 7 | describe('index', function () { 8 | 9 | describe('handler', function () { 10 | 11 | var anEvent = { 12 | "body": "token=FLOOP&user_name=fry&command=morbotron&channel_name=general&text=good%20news%20everyone" 13 | }; 14 | 15 | var context = {}; 16 | 17 | it('should need a token', function (done) { 18 | 19 | index.handler(anEvent, context, function (err) { 20 | if (err) { 21 | return done(); 22 | } 23 | return done(new Error('unexpected success')); 24 | }); 25 | 26 | }); 27 | 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pagerduty-oncall-slack-bot", 3 | "version": "1.0.0", 4 | "description": "PagerDuty OnCall Slack bot", 5 | "main": "index.js", 6 | "repository": "https://github.com/section-io/pagerduty-oncall-slack-bot", 7 | "scripts": { 8 | "test": "jshint *.js && jscs *.js && mocha test --recursive" 9 | }, 10 | "author": "Jason Stangroome", 11 | "license": "MIT", 12 | "dependencies": { 13 | "moment": "^2.14.1", 14 | "moment-timezone": "^0.5.5", 15 | "qs": "^6.2.1", 16 | "request": "^2.74.0", 17 | "request-promise": "^4.1.1" 18 | }, 19 | "devDependencies": { 20 | "chai": "^3.5.0", 21 | "jscs": "^3.0.7", 22 | "jshint": "^2.9.3", 23 | "mocha": "^3.0.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 section.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/slack.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect; 4 | 5 | var Slack = require('../slack'); 6 | 7 | describe('slack', function () { 8 | 9 | this.timeout(8000); 10 | const SLACK_API_TOKEN = '_TODO_'; 11 | 12 | describe('getUserInfo', function () { 13 | 14 | it('should reject unknown user ids', function () { 15 | 16 | var slack = new Slack(SLACK_API_TOKEN); 17 | 18 | var promise = slack.getUserInfo('_NOBODY_'); 19 | return promise.then(function () { 20 | return Promise.reject(new Error('unexpected success')); 21 | }, function (err) { 22 | expect(err).ok; 23 | return Promise.resolve(); 24 | }); 25 | 26 | }); 27 | 28 | it('should return a user with timezone properties', function () { 29 | 30 | var slack = new Slack(SLACK_API_TOKEN); 31 | 32 | var promise = slack.getUserInfo('U27766RHC'); 33 | return promise.then(function (user) { 34 | expect(user.realName).ok; 35 | expect(user.tz).ok; 36 | expect(user.timezoneLabel).ok; 37 | expect(user.timezoneOffsetSeconds).is.a('Number'); 38 | }); 39 | 40 | }); 41 | 42 | }); 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /slack.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var rp = require('request-promise'); 4 | 5 | module.exports = function Slack(apiToken) { 6 | 7 | const SLACK_API_BASE_URL = 'https://slack.com/api'; 8 | 9 | this.respond = function slackRespond(url, body) { 10 | // https://api.slack.com/slash-commands#responding_to_a_command 11 | return rp({ 12 | method: 'POST', 13 | url: url, 14 | body: body, 15 | json: true 16 | }); 17 | }; 18 | 19 | this.getUserInfo = function slackGetUserInfo(userId) { 20 | // https://api.slack.com/methods/users.info 21 | var options = { 22 | method: 'GET', 23 | url: `${SLACK_API_BASE_URL}/users.info`, 24 | qs: { 25 | token: apiToken, 26 | user: userId, 27 | }, 28 | json: true, 29 | }; 30 | 31 | return rp(options).then(function (json) { 32 | if (!json.ok) { 33 | return Promise.reject(new Error(json.error)); 34 | } 35 | 36 | var u = json.user; 37 | return { 38 | id: u.id, 39 | team_id: u.team_id, 40 | name: u.name, 41 | realName: u.real_name, 42 | tz: u.tz, // eg `Australia/Canberra`, ie "tz database" name 43 | timezoneLabel: u.tz_label, // eg `Australian Eastern Standard Time` 44 | timezoneOffsetSeconds: u.tz_offset, // eg `36000`, ie UTC offset in seconds 45 | }; 46 | }); 47 | }; 48 | 49 | // TODO get all users timezones, and all users in channel to find the most common timezones 50 | 51 | }; 52 | -------------------------------------------------------------------------------- /test/pagerduty.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect; 4 | 5 | var PagerDuty = require('../pagerduty'); 6 | 7 | describe('pagerduty', function () { 8 | 9 | this.timeout(8000); 10 | const PAGERDUTY_WEBDEMO_TOKEN = 'w_8PcNuhHa-y3xYdmc1x'; 11 | 12 | describe('getOnCalls', function () { 13 | 14 | it('should return more than the first page of results', function () { 15 | 16 | var pagerDuty = new PagerDuty(PAGERDUTY_WEBDEMO_TOKEN); 17 | 18 | var promise = pagerDuty.getOnCalls(); 19 | return promise.then(function (onCalls) { 20 | expect(onCalls.length).at.least(26); 21 | expect(onCalls[0].policyId).ok; 22 | }); 23 | 24 | }); 25 | 26 | it('should query different times', function () { 27 | 28 | var pagerDuty = new PagerDuty(PAGERDUTY_WEBDEMO_TOKEN); 29 | 30 | var testDate = new Date(); 31 | testDate.setDate(testDate.getDate() + 3); 32 | var promise = pagerDuty.getOnCalls(testDate.toISOString()); 33 | return promise.then(function (onCalls) { 34 | expect(onCalls.length).at.least(1); 35 | expect(onCalls[0].policyId).ok; 36 | }); 37 | 38 | }); 39 | 40 | }); 41 | 42 | describe('getEscalationPolicies', function () { 43 | 44 | it('should return policies', function () { 45 | 46 | var pagerDuty = new PagerDuty(PAGERDUTY_WEBDEMO_TOKEN); 47 | 48 | var promise = pagerDuty.getEscalationPolicies(); 49 | return promise.then(function (policies) { 50 | expect(policies.length).at.least(1); 51 | expect(policies[0].policyId).ok; 52 | expect(policies[0].policyName).ok; 53 | expect(policies[0].policyUrl).ok; 54 | expect(policies[0]).property('policyDescription'); 55 | }); 56 | 57 | }); 58 | 59 | }); 60 | 61 | }); 62 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Serverless! 2 | # 3 | # This file is the main config file for your service. 4 | # It's very minimal at this point and uses default values. 5 | # You can always add more config options for more control. 6 | # We've included some commented out config examples here. 7 | # Just uncomment any of them to get that config option. 8 | # 9 | # For full config options, check the docs: 10 | # v1.docs.serverless.com 11 | # 12 | # Happy Coding! 13 | 14 | service: pagerduty-oncall-slack-bot 15 | 16 | provider: 17 | name: aws 18 | runtime: nodejs4.3 19 | iamRoleStatements: 20 | - Effect: "Allow" 21 | Action: 22 | - "lambda:invokeFunction" 23 | Resource: 24 | Fn::GetAtt: 25 | - "slack" 26 | - "Arn" 27 | 28 | # you can overwrite defaults here 29 | #defaults: 30 | # stage: dev 31 | # region: us-east-1 32 | 33 | # you can add packaging information here 34 | package: 35 | # include: 36 | # - node_modules 37 | exclude: 38 | - vagrant 39 | - node_modules/chai/ 40 | - node_modules/jscs/ 41 | - node_modules/jshint/ 42 | - node_modules/mocha/ 43 | # artifact: my-service-code.zip 44 | 45 | functions: 46 | slack: 47 | handler: index.handler 48 | memorySize: 128 49 | timeout: 10 50 | events: 51 | - http: 52 | path: slack 53 | method: post 54 | # you can add any of the following events 55 | # events: 56 | # - http: 57 | # path: users/create 58 | # method: get 59 | # - s3: ${bucket} 60 | # - schedule: rate(10 minutes) 61 | # - sns: greeter-topic 62 | 63 | # you can add CloudFormation resource templates here 64 | resources: 65 | Resources: 66 | PostMethodApigEventSlackSlack: 67 | Properties: 68 | Integration: 69 | RequestTemplates: 70 | application/x-www-form-urlencoded: "{ \"body\": $input.json(\"$\"), \"stage\": \"$context.stage\" }" 71 | # NewResource: 72 | # Type: AWS::S3::Bucket 73 | # Properties: 74 | # BucketName: my-new-bucket 75 | # Outputs: 76 | # NewOutput: 77 | # Description: "Description for the output" 78 | # Value: "Some output value" 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PagerDuty OnCall Slack bot 2 | 3 | ## Local development 4 | 5 | 1. `vagrant up` 6 | 1. `vagrant ssh` 7 | 1. `cd /vagrant` 8 | 1. `npm install` 9 | 1. `npm test` 10 | 11 | ## Deploying 12 | 13 | Follow these steps to configure the slash command in Slack: 14 | 15 | 1. Navigate to https://.slack.com/services/new 16 | 1. Search for and select "Slash Commands". 17 | 1. Enter a name for your command and click "Add Slash Command Integration". 18 | 1. Copy the token string from the integration settings and use it in the next section. 19 | 1. After you complete the deployment to AWS, enter the provided API endpoint URL in the URL field. 20 | 21 | Follow these steps to encrypt your Slack token for use in this function: 22 | 23 | 1. Create a KMS key - http://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html. 24 | 1. Give your function's role the permission for the kms:Decrypt action. 25 | 1. Encrypt the Slack token using the AWS CLI: `$ aws kms encrypt --region --key-id alias/ --plaintext ""` 26 | 1. Copy the base-64 encoded, encrypted key (CiphertextBlob) to the relevant `kmsEncyptedSlackToken` configuration key value in `index.js`. 27 | 1. Obtain a read-only PagerDuty API V2 key - https://support.pagerduty.com/hc/en-us/articles/202829310-Generating-an-API-Key 28 | 1. Encrypt the PagerDuty API key using the AWS CLI: `$ aws kms encrypt --region --key-id alias/ --plaintext ""` 29 | 1. Copy the base-64 encoded, encrypted key (CiphertextBlob) to the relevate `kmsEncryptedPagerDutyApiToken` configuration key value in `index.js`. 30 | 1. Obtain a Slack API token for testing and development: https://api.slack.com/docs/oauth-test-tokens 31 | 1. Encrypt the Slack API token using the AWS CLI: `$ aws kms encrypt --region --key-id alias/ --plaintext ""` 32 | 1. Copy the base-64 encoded, encrypted token (CiphertextBlob) to the relevate `kmsEncryptedSlackApiToken` configuration key value in `index.js`. 33 | 34 | Example role permission: 35 | ```json 36 | { 37 | "Version": "2012-10-17", 38 | "Statement": [ 39 | { 40 | "Effect": "Allow", 41 | "Action": [ 42 | "kms:Decrypt" 43 | ], 44 | "Resource": [ 45 | "" 46 | ] 47 | } 48 | ] 49 | } 50 | ``` 51 | 52 | Follow these steps to deploy the AWS Lambda function: 53 | 54 | 1. `aws configure` 55 | 1. `serverless deploy` 56 | 1. Update the URL for your Slack slash command with the invocation URL for the created API resource in the prod stage. 57 | 1. `serverless invoke --function slack --path event.json` 58 | 59 | ## Todo 60 | 61 | * allow filtering by policy 62 | * allow filtering by escalation level 63 | -------------------------------------------------------------------------------- /pagerduty.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var rp = require('request-promise'); 4 | 5 | function PagerDuty(token) { 6 | 7 | this.getEscalationPolicies = function () { 8 | 9 | var options = { 10 | url: 'https://api.pagerduty.com/escalation_policies', 11 | qs: { 12 | sort_by: 'name:asc', 13 | }, 14 | headers: { 15 | Authorization: `Token token=${token}`, 16 | Accept: 'application/vnd.pagerduty+json;version=2', 17 | }, 18 | json: true, 19 | }; 20 | 21 | var allResults = []; 22 | function handleResponse(json) { 23 | var pageResults = json.escalation_policies.map(policy => { 24 | return { 25 | policyId: policy.id, 26 | policyName: policy.name, 27 | policyDescription: policy.description, 28 | policyUrl: policy.html_url, 29 | }; 30 | }); 31 | allResults = allResults.concat(pageResults); 32 | if (json.more) { 33 | options.qs.offset = json.offset + json.limit; 34 | return rp(options).then(handleResponse); 35 | } 36 | return allResults; 37 | } 38 | 39 | return rp(options).then(handleResponse); 40 | 41 | }; 42 | 43 | this.getOnCalls = function (sinceISO8601) { 44 | 45 | var options = { 46 | url: 'https://api.pagerduty.com/oncalls', 47 | qs: { 48 | time_zone: 'UTC', 49 | earliest: true, 50 | sort_by: 'escalation_level:asc', 51 | }, 52 | headers: { 53 | Authorization: `Token token=${token}`, 54 | Accept: 'application/vnd.pagerduty+json;version=2', 55 | }, 56 | json: true, 57 | }; 58 | 59 | if (sinceISO8601) { 60 | options.qs.since = sinceISO8601; 61 | options.qs.until = sinceISO8601; 62 | } 63 | 64 | var allResults = []; 65 | function handleResponse(json) { 66 | var pageResults = json.oncalls.map(oncall => { 67 | return { 68 | end: oncall.end, 69 | escalationLevel: oncall.escalation_level, 70 | policyId: oncall.escalation_policy.id, 71 | policyName: oncall.escalation_policy.summary, 72 | policyUrl: oncall.escalation_policy.html_url, 73 | scheduleName: !oncall.schedule ? undefined : oncall.schedule.summary, 74 | scheduleUrl: !oncall.schedule ? undefined : oncall.schedule.html_url, 75 | userName: oncall.user.summary, 76 | userUrl: oncall.user.html_url, 77 | }; 78 | }); 79 | allResults = allResults.concat(pageResults); 80 | if (json.more) { 81 | options.qs.offset = json.offset + json.limit; 82 | return rp(options).then(handleResponse); 83 | } 84 | return allResults; 85 | } 86 | 87 | return rp(options).then(handleResponse); 88 | 89 | }; 90 | 91 | } 92 | 93 | module.exports = PagerDuty; 94 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var kms; 4 | var qs = require('qs'); 5 | var Commands = require('./commands'); 6 | var PagerDuty = require('./pagerduty'); 7 | var Slack = require('./slack'); 8 | 9 | var expectedSlackToken; 10 | var expectedStage; 11 | var commands; 12 | 13 | const configurations = { 14 | dev: { 15 | kmsEncryptedSlackToken: 'AQECAHhUn6wKENLiOqxMUc4/sLItOcFx7tVRblgKtD0D9dIFYgAAAHYwdAYJKoZIhvcNAQcGoGcwZQIBADBgBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDFD58+o5yu4L04lu5wIBEIAzOqLZJKM0ZfU44hgPxf4350eflkysYArUWEInVzLXSpvZw0QFGpvbshlnT3shlEBhkhJb', 16 | kmsEncryptedPagerDutyApiToken: 'AQECAHhUn6wKENLiOqxMUc4/sLItOcFx7tVRblgKtD0D9dIFYgAAAHIwcAYJKoZIhvcNAQcGoGMwYQIBADBcBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDMFCTzw6XUbb/kSldwIBEIAv+dvB02nXz+HUZPz9l63yvLWf2LEUXWLBrRUOND2NFnIVqvhXUzWH+4XdAYW+Seg=', 17 | kmsEncryptedSlackApiToken: 'AQECAHhUn6wKENLiOqxMUc4/sLItOcFx7tVRblgKtD0D9dIFYgAAAJQwgZEGCSqGSIb3DQEHBqCBgzCBgAIBADB7BgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDLmxSsdkTpIMZK7GXQIBEIBOFUrRe5LEj+XplKEoMl7jwFCjNmigKxCmgTikvPcxTF55/1yR2RLbL2igEKKJiGrVOMJMZUSXuwGFvh/rzzOMna5G3g4bV9BhD9tLlvdr', 18 | }, 19 | prod: { 20 | kmsEncryptedSlackToken: 'AQECAHhUn6wKENLiOqxMUc4/sLItOcFx7tVRblgKtD0D9dIFYgAAAHYwdAYJKoZIhvcNAQcGoGcwZQIBADBgBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDDqr/ncrV6LlZ4vFogIBEIAzSM1CVwZ0LVuPLrWaLXqNqLoXLokhzNxKhGssXtfxW3xuvoI9F4Hsd3YPDuQReIiQcAm0', 21 | kmsEncryptedPagerDutyApiToken: 'AQECAHhUn6wKENLiOqxMUc4/sLItOcFx7tVRblgKtD0D9dIFYgAAAHIwcAYJKoZIhvcNAQcGoGMwYQIBADBcBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDIOevGLdXkHnRHOo1wIBEIAvWbNsvZy6nVPfu/8L0lMJvonVuUJMg+9mR7ahk6dO7FLguCDOvD1rfLFpQ1zB2rE=', 22 | kmsEncryptedSlackApiToken: 'AQECAHhUn6wKENLiOqxMUc4/sLItOcFx7tVRblgKtD0D9dIFYgAAAJEwgY4GCSqGSIb3DQEHBqCBgDB+AgEAMHkGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQMFNxOnDzoXpTIlnVfAgEQgExGWdLo1NSW+2QRG2kTD38XLygw22wskIEe8hNiEWn4ibD83lcKxvP7KvRluI2vLhBRRlTCMo4skiFIGUSI9jETC55stGcbnBKWTpvJ', 23 | }, 24 | }; 25 | 26 | function kmsDecrypt(encryptedBase64String) { 27 | 28 | if (!kms) { 29 | try { 30 | var AWS = require('aws-sdk'); 31 | kms = new AWS.KMS(); 32 | } catch (err) { 33 | return Promise.reject(err); 34 | } 35 | } 36 | 37 | var encryptedBuffer = new Buffer(encryptedBase64String, 'base64'); 38 | var cipherText = { CiphertextBlob: encryptedBuffer }; 39 | 40 | return new Promise(function (resolve, reject) { 41 | kms.decrypt(cipherText, function (err, data) { 42 | if (err) { 43 | return reject(new Error('Decrypt error: ' + err)); 44 | } else { 45 | var decryptedString = data.Plaintext.toString('ascii'); 46 | return resolve(decryptedString); 47 | } 48 | }); 49 | }); 50 | } 51 | 52 | function createRecurseFunction(lambdaContext, event) { 53 | return function recurse(commandName, commandArgument) { 54 | var AWS = require('aws-sdk'); 55 | var lambda = new AWS.Lambda(); 56 | 57 | var payload = { 58 | stage: event.stage, 59 | hasRecursed: true, 60 | commandName: commandName, 61 | commandArgument: commandArgument, 62 | }; 63 | 64 | return new Promise(function (resolve, reject) { 65 | 66 | lambda.invoke({ 67 | FunctionName: lambdaContext.functionName, 68 | Qualifier: lambdaContext.functionVersion, 69 | InvocationType: 'Event', 70 | Payload: JSON.stringify(payload), 71 | }, function (err, data) { 72 | if (err) { 73 | return reject(err); 74 | } 75 | return resolve(data); 76 | }); 77 | 78 | }); 79 | 80 | }; 81 | } 82 | 83 | exports.handler = function (event, context, callback) { 84 | if (expectedSlackToken && commands && expectedStage === event.stage) { 85 | // Container reuse, simply process the event with the key in memory 86 | return processEvent(event, callback); 87 | } 88 | 89 | if (!event.stage || !configurations.hasOwnProperty(event.stage)) { 90 | return callback(new Error(`Invalid stage "${event.stage}".`)); 91 | } 92 | expectedStage = event.stage; 93 | var config = configurations[event.stage]; 94 | 95 | var promises = [Promise.resolve()]; 96 | 97 | if (!expectedSlackToken) { 98 | promises.push( 99 | kmsDecrypt(config.kmsEncryptedSlackToken) 100 | .then(function (result) { 101 | expectedSlackToken = result; 102 | }) 103 | ); 104 | } 105 | 106 | if (!commands) { 107 | promises.push( 108 | Promise.all([ 109 | kmsDecrypt(config.kmsEncryptedPagerDutyApiToken), 110 | kmsDecrypt(config.kmsEncryptedSlackApiToken), 111 | ]) 112 | .then(function (results) { 113 | var pagerDutyApiToken = results[0]; 114 | var slackApiToken = results[1]; 115 | var recurseFunction = createRecurseFunction(context, event); 116 | var pagerDuty = new PagerDuty(pagerDutyApiToken); 117 | var slack = new Slack(slackApiToken); 118 | commands = new Commands(pagerDuty, slack, recurseFunction); 119 | }) 120 | ); 121 | } 122 | 123 | Promise.all(promises) 124 | .then(function () { 125 | return processEvent(event, callback); 126 | }) 127 | .catch(function (err) { 128 | return callback(err); 129 | }); 130 | 131 | }; 132 | 133 | function processEvent(event, callback) { 134 | 135 | if (event.hasRecursed && event.commandName && event.commandArgument) { 136 | if (!commands.hasOwnProperty(event.commandName)) { 137 | return callback(new Error(`Invalid command name "${event.commandName}".`)); 138 | } 139 | commands[event.commandName](event.commandArgument); 140 | return callback(); 141 | } 142 | 143 | var body = event.body; 144 | var params = qs.parse(body); 145 | var requestToken = params.token; 146 | if (requestToken !== expectedSlackToken) { 147 | console.error("Request token (" + requestToken + ") does not match expected"); 148 | return callback(new Error('Invalid request token')); 149 | } 150 | 151 | commands.processCommand(params) 152 | .then(function (result) { 153 | return callback(null, result); 154 | }, function (err) { 155 | return callback(err); 156 | }); 157 | } 158 | exports.processEvent = processEvent; 159 | -------------------------------------------------------------------------------- /test/commands.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect; 4 | var moment = require('moment'); 5 | 6 | var Commands = require('../commands'); 7 | 8 | describe('commands', function () { 9 | 10 | describe('processCommand', function () { 11 | 12 | it('should succeed with slack response for `now`', function () { 13 | 14 | var params = { 15 | text: 'now', 16 | response_url: '_RESPONSE_URL_', 17 | }; 18 | 19 | var pagerDuty = { 20 | getOnCalls: function () { 21 | return Promise.resolve([ 22 | { 23 | end: '2016-08-26T16:00:00Z', 24 | escalationLevel: 2, 25 | policyId: '_POLICY_ID_1_', 26 | policyName: 'Operations', 27 | policyUrl: '_POLICY_URL_1_', 28 | scheduleName: 'Second Line', 29 | scheduleUrl: '_SCHEDULE_URL_2', 30 | userName: 'Philip', 31 | userUrl: '_USER_URL_1' 32 | }, 33 | { 34 | end: null, 35 | escalationLevel: 2, 36 | policyId: '_POLICY_ID_1_', 37 | policyName: 'Operations', 38 | policyUrl: '_POLICY_URL_1_', 39 | scheduleName: null, 40 | scheduleUrl: null, 41 | userName: 'Amy', 42 | userUrl: '_USER_URL_2' 43 | }, 44 | { 45 | end: '2016-08-26T17:00:00Z', 46 | escalationLevel: 1, 47 | policyId: '_POLICY_ID_1_', 48 | policyName: 'Operations', 49 | policyUrl: '_POLICY_URL_1_', 50 | scheduleName: 'Front Line', 51 | scheduleUrl: '_SCHEDULE_URL_1', 52 | userName: 'Hubert', 53 | userUrl: '_USER_URL_3' 54 | }, 55 | { 56 | end: '2016-08-26T17:00:00Z', 57 | escalationLevel: 1, 58 | policyId: '_POLICY_ID_2_', 59 | policyName: 'Customers', 60 | policyUrl: '_POLICY_URL_2_', 61 | scheduleName: 'Front Line', 62 | scheduleUrl: '_SCHEDULE_URL_A', 63 | userName: 'Hermes', 64 | userUrl: '_USER_URL_4' 65 | } 66 | ]); 67 | }, 68 | }; 69 | 70 | var slack = { 71 | getUserInfo: function () { 72 | return Promise.resolve({ 73 | id: 'hubie', 74 | tz: 'America/New_York', 75 | }); 76 | }, 77 | }; 78 | 79 | var slackPromise = new Promise(function (resolve) { 80 | slack.respond = function (url, message) { 81 | expect(url).to.eq('_RESPONSE_URL_'); 82 | expect(message.response_type).to.equal('in_channel'); 83 | expect(message.text).to.contain('Current PagerDuty on call roster'); 84 | expect(message.text).to.contain('hubie'); 85 | expect(message.text).to.contain('-04:00'); 86 | expect(message.attachments).ok; 87 | expect(message.attachments.length).to.equal(3); 88 | expect(message.attachments[0].title).to.equal('Operations - Level 1'); 89 | expect(message.attachments[0].title_link).to.equal('_POLICY_URL_1_'); 90 | expect(message.attachments[0].text).to.equal('• <_USER_URL_3|Hubert> - until 1:00pm Fri 26th Aug (<_SCHEDULE_URL_1|Front Line>)'); 91 | return resolve(); 92 | }; 93 | }) 94 | 95 | var commands; 96 | 97 | var recurseFunction = function (commandName, commandArgument) { 98 | commands[commandName](commandArgument); 99 | return Promise.resolve(); 100 | }; 101 | 102 | commands = new Commands(pagerDuty, slack, recurseFunction); 103 | var commandPromise = commands.processCommand(params) 104 | .then(function (message) { 105 | expect(message.response_type).equal('in_channel'); 106 | }); 107 | 108 | return Promise.all([commandPromise, slackPromise]); 109 | 110 | }); 111 | 112 | it('should succeed with slack response for `at time date`', function () { 113 | 114 | var atISO8601 = moment().add(3, 'days').format('ha MMM Do'); 115 | 116 | var params = { 117 | text: `at ${atISO8601}`, 118 | response_url: '_RESPONSE_URL_', 119 | }; 120 | 121 | var pagerDuty = { 122 | getOnCalls: function () { 123 | return Promise.resolve([ 124 | { 125 | end: null, 126 | escalationLevel: 2, 127 | policyId: '_POLICY_ID_1_', 128 | policyName: 'Operations', 129 | policyUrl: '_POLICY_URL_1_', 130 | scheduleName: null, 131 | scheduleUrl: null, 132 | userName: 'Amy', 133 | userUrl: '_USER_URL_2' 134 | }, 135 | ]); 136 | }, 137 | }; 138 | 139 | var slack = { 140 | getUserInfo: function () { 141 | return Promise.resolve({ 142 | id: 'hubie', 143 | tz: 'America/New_York', 144 | }); 145 | }, 146 | }; 147 | 148 | var slackPromise = new Promise(function (resolve) { 149 | slack.respond = function (url, message) { 150 | expect(url).to.eq('_RESPONSE_URL_'); 151 | expect(message.response_type).to.equal('in_channel'); 152 | expect(message.text).to.contain('PagerDuty on call roster as at'); 153 | expect(message.text).to.contain('hubie'); 154 | expect(message.text).to.contain('-04:00'); 155 | expect(message.attachments).ok; 156 | expect(message.attachments.length).at.least(1); 157 | return resolve(); 158 | }; 159 | }) 160 | 161 | var commands; 162 | 163 | var recurseFunction = function (commandName, commandArgument) { 164 | commands[commandName](commandArgument); 165 | return Promise.resolve(); 166 | }; 167 | 168 | commands = new Commands(pagerDuty, slack, recurseFunction); 169 | var commandPromise = commands.processCommand(params) 170 | .then(function (message) { 171 | expect(message.response_type).equal('in_channel'); 172 | }); 173 | 174 | return Promise.all([commandPromise, slackPromise]); 175 | 176 | }); 177 | 178 | }); 179 | 180 | }); 181 | -------------------------------------------------------------------------------- /commands.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var moment = require('moment-timezone'); 4 | 5 | module.exports = function Commands(pagerduty, slack, recurseFunction) { 6 | 7 | const TIME_PARSE_FORMATS = ['HH:mm', 'h:mma', 'ha']; 8 | const DATE_PARSE_FORMATS = ['MMM Do', 'MMM D', 'Do MMM', 'D MMM']; 9 | const MAX_SLACK_ATTACHMENTS = 20; 10 | const MAX_ESCALATION_LEVEL = 2; 11 | 12 | function sendOnCallsResponse(user, onCalls, responseUrl, atMoment) { 13 | 14 | var byPolicyIdAndLevel = {}; 15 | onCalls 16 | .filter(o => o.escalationLevel <= MAX_ESCALATION_LEVEL) 17 | .forEach(function (onCall) { 18 | // TODO 0-pad escalation level in keys for better sorting 19 | var key = `${onCall.policyId}|${onCall.escalationLevel}`; 20 | if (!byPolicyIdAndLevel.hasOwnProperty(key)) { 21 | byPolicyIdAndLevel[key] = []; 22 | } 23 | byPolicyIdAndLevel[key].push(onCall); 24 | }); 25 | 26 | function formatOnCalls(onCalls) { 27 | // TODO escape control sequences (ie `&`, `<`, `>`, maybe `|`) 28 | return onCalls.map(function (onCall) { 29 | var until = 'indefinitely'; 30 | if (onCall.end && onCall.scheduleName && onCall.scheduleUrl) { 31 | var end = moment(onCall.end).tz(user.tz).format('h:mma ddd Do MMM'); 32 | until = `until ${end} (<${onCall.scheduleUrl}|${onCall.scheduleName}>)`; 33 | } 34 | return `• <${onCall.userUrl}|${onCall.userName}> - ${until}`; 35 | }).join('\n'); 36 | } 37 | 38 | var keys = Object.keys(byPolicyIdAndLevel).sort(); 39 | var truncatedPolicyLevelCount = 0; 40 | if (keys.length > MAX_SLACK_ATTACHMENTS) { 41 | truncatedPolicyLevelCount = keys.length - MAX_SLACK_ATTACHMENTS - 1; 42 | keys.slice(0, MAX_SLACK_ATTACHMENTS - 1); 43 | } 44 | 45 | var attachments = keys.map(function (key) { 46 | // https://api.slack.com/docs/message-attachments 47 | var entries = byPolicyIdAndLevel[key]; 48 | var first = entries[0]; 49 | return { 50 | title: `${first.policyName} - Level ${first.escalationLevel}`, 51 | title_link: first.policyUrl, 52 | text: formatOnCalls(entries), 53 | }; 54 | }); 55 | 56 | if (truncatedPolicyLevelCount) { 57 | attachments.push({ 58 | title: 'More...', 59 | text: `${truncatedPolicyLevelCount} more policy-level combinations were omitted.` 60 | }); 61 | } 62 | 63 | var timezone = moment.tz(user.tz).format('Z'); 64 | 65 | var messageText = 'Current PagerDuty on call roster'; 66 | if (atMoment) { 67 | messageText = 'PagerDuty on call roster as at ' + atMoment.format('h:mma ddd Do MMM'); 68 | } 69 | messageText = `${messageText}, using <@${user.id}>'s time zone (${timezone}):`; 70 | 71 | return slack.respond(responseUrl, { 72 | response_type: 'in_channel', 73 | text: messageText, 74 | attachments: attachments, 75 | }); 76 | 77 | } 78 | 79 | this.delayedNowResponse = function delayedNowResponse(commandArgument) { 80 | 81 | var promises = [ 82 | slack.getUserInfo(commandArgument.userId), 83 | pagerduty.getOnCalls(), 84 | ]; 85 | 86 | Promise.all(promises) 87 | .then(function (results) { 88 | var user = results[0]; 89 | var onCalls = results[1]; 90 | 91 | return sendOnCallsResponse(user, onCalls, commandArgument.responseUrl); 92 | }) 93 | .catch(function (err) { 94 | console.error(err); 95 | }); 96 | }; 97 | 98 | this.delayedAtResponse = function delayedAtResponse(commandArgument) { 99 | 100 | slack.getUserInfo(commandArgument.userId) 101 | .then(function (user) { 102 | var time = moment.tz(commandArgument.timeText, TIME_PARSE_FORMATS, user.tz); 103 | 104 | var now = moment.tz(user.tz); 105 | var date = now.clone(); 106 | if (commandArgument.dateText) { 107 | date = moment.tz(commandArgument.dateText, DATE_PARSE_FORMATS, user.tz); 108 | } 109 | date.hour(time.hour()); 110 | date.minute(time.minute()); 111 | if (date.isBefore(now)) { 112 | date.add(1, 'year'); 113 | } 114 | 115 | pagerduty.getOnCalls(date.format()) 116 | .then(function (onCalls) { 117 | return sendOnCallsResponse(user, onCalls, commandArgument.responseUrl, date); 118 | }) 119 | .catch(function (err) { 120 | console.error(err); 121 | }); 122 | }); 123 | 124 | }; 125 | 126 | this.delayedPoliciesResponse = function delayedPoliciesResponse(commandArgument) { 127 | pagerduty.getEscalationPolicies() 128 | .then(function (policies) { 129 | 130 | if (policies.length > MAX_SLACK_ATTACHMENTS) { 131 | var truncatedPolicyCount = policies.length - MAX_SLACK_ATTACHMENTS - 1; 132 | policies = policies.slice(0, MAX_SLACK_ATTACHMENTS - 1); 133 | policies.push({ 134 | policyName: 'More...', 135 | policyDescription: `${truncatedPolicyCount} more escalation policies were omitted.`, 136 | }); 137 | } 138 | 139 | return slack.respond(commandArgument.responseUrl, { 140 | response_type: 'ephemeral', 141 | text: 'PagerDuty escalation policies:', 142 | attachments: policies.map(function (policy) { 143 | var mrkdwnIn = !policy.policyDescription ? ['text'] : undefined; 144 | return { 145 | title: policy.policyName, 146 | title_link: policy.policyUrl, 147 | text: policy.policyDescription || '_no description_', 148 | mrkdwn_in: mrkdwnIn, 149 | }; 150 | }), 151 | }); 152 | 153 | }) 154 | .catch(function (err) { 155 | console.error(err); 156 | }); 157 | }; 158 | 159 | function processNow(responseUrl, userId) { 160 | 161 | recurseFunction('delayedNowResponse', { 162 | responseUrl: responseUrl, 163 | userId: userId, 164 | }).catch(function (err) { 165 | console.error(err); 166 | }); 167 | 168 | return Promise.resolve({ 169 | response_type: 'in_channel', 170 | }); 171 | } 172 | 173 | function processAt(paramText, responseUrl, userId) { 174 | 175 | function usage() { 176 | return Promise.resolve({ 177 | response_type: 'ephemeral', 178 | text: [ 179 | 'Example usages of `at`:', 180 | '• `at 9pm`', 181 | '• `at 11:30am Sep 15`', 182 | '• `at 08:00 Oct 3rd`', 183 | '• `at 14:30 11th Apr`', 184 | '• `at 22:00 22 Jan`', 185 | ].join('\n'), 186 | }); 187 | } 188 | 189 | var match = /^([^ ]+) *(.*)/.exec(paramText); 190 | if (!match) { 191 | return usage(); 192 | } 193 | 194 | var timeText = match[1]; 195 | var dateText = match[2]; 196 | 197 | var time = moment(timeText, TIME_PARSE_FORMATS, true); 198 | if (!time.isValid()) { 199 | return usage(); 200 | } 201 | 202 | if (dateText) { 203 | var date = moment(dateText, DATE_PARSE_FORMATS); 204 | if (!date.isValid()) { 205 | return usage(); 206 | } 207 | } 208 | 209 | recurseFunction('delayedAtResponse', { 210 | timeText: timeText, 211 | dateText: dateText, 212 | responseUrl: responseUrl, 213 | userId: userId, 214 | }).catch(function (err) { 215 | console.error(err); 216 | }); 217 | 218 | return Promise.resolve({ 219 | response_type: 'in_channel', 220 | }); 221 | } 222 | 223 | function processPolicies(responseUrl) { 224 | 225 | recurseFunction('delayedPoliciesResponse', { 226 | responseUrl: responseUrl, 227 | }).catch(function (err) { 228 | console.error(err); 229 | }); 230 | 231 | return Promise.resolve({ 232 | response_type: 'ephemeral', 233 | text: "Retrieving escalation policies...", 234 | }); 235 | 236 | } 237 | 238 | this.processCommand = function processCommand(params) { 239 | 240 | var commandText = params.text; 241 | var responseUrl = params.response_url; 242 | var userId = params.user_id; 243 | var match; 244 | 245 | match = /^now *$/.exec(commandText); 246 | if (match) { 247 | // TODO filter by policy 248 | return processNow(responseUrl, userId); 249 | } 250 | 251 | match = /^at +(.+)$/.exec(commandText); 252 | if (match) { 253 | return processAt(match[1], responseUrl, userId); 254 | } 255 | 256 | match = /^policies *$/.exec(commandText); 257 | if (match) { 258 | return processPolicies(responseUrl); 259 | } 260 | 261 | return Promise.resolve({ 262 | response_type: 'ephemeral', 263 | text: [ 264 | 'Usage:', 265 | `• \`${params.command} now\` - Post the current on call roster to this channel.`, 266 | `• \`${params.command} at