Repository: dgurkaynak/slack-poker-planner Branch: master Commit: 5c8ce18cf4eb Files: 37 Total size: 140.9 KB Directory structure: gitextract_gdlvwbjy/ ├── .dockerignore ├── .editorconfig ├── .gitignore ├── .nvmrc ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── assets/ │ └── logo.psd ├── migrations/ │ ├── 001-initial-schema.sql │ ├── 002-custom-points.sql │ ├── 003-channel-settings.sql │ ├── 004-change-default-point-0.5.sql │ └── 005-add-dates.sql ├── package.json ├── src/ │ ├── app.ts │ ├── lib/ │ │ ├── logger.ts │ │ ├── olay.ts │ │ ├── redis.ts │ │ ├── sqlite.ts │ │ ├── string-match-all.ts │ │ └── to.ts │ ├── public/ │ │ └── styles.css │ ├── routes/ │ │ ├── interactivity.ts │ │ ├── oauth.ts │ │ └── pp-command.ts │ ├── session/ │ │ ├── isession.ts │ │ ├── session-controller.ts │ │ └── session-model.ts │ ├── team/ │ │ └── team-model.ts │ ├── vendor/ │ │ ├── olay-node-client.ts │ │ └── slack-api-interfaces.ts │ └── views/ │ ├── index.html │ ├── oauth-success.html │ └── privacy.html ├── tsconfig.json └── webpack.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ node_modules npm-debug.log assets dist ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space indent_size = 2 ================================================ FILE: .gitignore ================================================ /node_modules db.sqlite dist .env ================================================ FILE: .nvmrc ================================================ v22.11.0 ================================================ FILE: .prettierrc ================================================ { "singleQuote": true } ================================================ FILE: Dockerfile ================================================ FROM node:18-alpine3.20 LABEL maintainer="Deniz Gurkaynak " WORKDIR /app ADD . . RUN npm ci RUN npm run build ENV NODE_ENV=production CMD ["node", "dist/slack-poker-planner.js"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Deniz Gurkaynak Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Poker Planner for Slack This project lets you make estimations with planning poker technique (or scrum poker) directly in Slack, without any need of external software. It can be a useful tool for agile remote teams. Slack App Directory: https://slack.com/apps/A57FFS3QE-poker-planner [![Demonstration](./assets/demo.gif)](https://deniz.co/slack-poker-planner/) ## Installation & Add to your Slack Team - Go to website: https://deniz.co/slack-poker-planner/ - Click **"Add to Slack"** button - Select the team you want to install Poker Planner from the dropdown top right - Click **Allow** button ## Usage For detailed usage documentation, please visit [website](https://deniz.co/slack-poker-planner/#usage). ## Self-hosting If you want to host your own app, follow this steps: ### Creating Slack App & Getting Credentials - Create a new Slack app [from here](https://api.slack.com/apps). - Interactivity & Shortcuts - **Turn on** "Interactivity" - Set request url: `http://my.awesome.project.url/slack/interactivity` - Slash Commands - Create a new command `/pp` (or any command you want) and set request url as `http://my.awesome.project.url/slack/pp-slash-command` - Make sure that "Escape channels, users, and links sent to your app" option is **turned on** - OAuth & Permissions - Add a new OAuth Redirect URL: `http://my.awesome.project.url/oauth` - Required bot permission scopes: `commands`, `chat:write` - Required user permission scopes: None - User ID Translation - **Turn off** "Translate Global IDs" - Tokens - Client ID, Secret and Verification token can be found on Basic Information page - Installation - Go to Manage Distribution, click "Add to Slack" and grant permissions ### Running via Docker - Clone the repo & `cd` into it - Build docker image: `docker build -t dgurkaynak/slack-poker-planner .` - Start container: ```sh docker run -d \ --restart=unless-stopped \ -p 3000:3000 \ -e SLACK_CLIENT_ID=xxx \ -e SLACK_CLIENT_SECRET=xxx \ -e SLACK_VERIFICATION_TOKEN=xxx \ -e SLACK_APP_ID=xxx \ -e DATA_FOLDER=/data \ -e ENABLE_JSONL_LOGGING=true \ -v /host/data/folder/slack-poker-planner:/data \ --name slack-poker-planner \ dgurkaynak/slack-poker-planner ``` - *(optional)* If you wanna persist poker sessions, you can provide a Redis server. ```sh docker run -d \ --restart=unless-stopped \ -p 3000:3000 \ -e SLACK_CLIENT_ID=xxx \ -e SLACK_CLIENT_SECRET=xxx \ -e SLACK_VERIFICATION_TOKEN=xxx \ -e SLACK_APP_ID=xxx \ -e DATA_FOLDER=/data \ -e ENABLE_JSONL_LOGGING=true \ -v /host/data/folder/slack-poker-planner:/data \ -e USE_REDIS=true \ -e REDIS_URL="redis://X.X.X.X:6379" \ --name slack-poker-planner \ dgurkaynak/slack-poker-planner ``` > Check out [.env.default](https://github.com/dgurkaynak/slack-poker-planner/blob/master/.env.default) file for the complete list of environment variables. ### Running Manually Node.js requirement `>= 22.11.0` - Clone this repo - Install dependencies: `npm i` - Build: `npm run build` - Start the app: `npm start` ================================================ FILE: migrations/001-initial-schema.sql ================================================ -------------------------------------------------------------------------------- -- Up -------------------------------------------------------------------------------- CREATE TABLE team ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, access_token VARCHAR(255) NOT NULL, scope VARCHAR(255) NOT NULL, user_id VARCHAR(255) NOT NULL ); -------------------------------------------------------------------------------- -- Down -------------------------------------------------------------------------------- DROP TABLE team; ================================================ FILE: migrations/002-custom-points.sql ================================================ -------------------------------------------------------------------------------- -- Up -------------------------------------------------------------------------------- ALTER TABLE team ADD COLUMN custom_points TEXT; -------------------------------------------------------------------------------- -- Down -------------------------------------------------------------------------------- -- SQLite does not support deleting column, we need to create a new table, copy the data and drop old tabke -- https://stackoverflow.com/questions/5938048/delete-column-from-sqlite-table/5987838#5987838 BEGIN TRANSACTION; CREATE TEMPORARY TABLE team_backup(id, name, access_token, scope, user_id); INSERT INTO team_backup SELECT id, name, access_token, scope, user_id FROM team; DROP TABLE team; CREATE TABLE team ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, access_token VARCHAR(255) NOT NULL, scope VARCHAR(255) NOT NULL, user_id VARCHAR(255) NOT NULL ); INSERT INTO team SELECT id, name, access_token, scope, user_id FROM team_backup; DROP TABLE team_backup; COMMIT; ================================================ FILE: migrations/003-channel-settings.sql ================================================ -------------------------------------------------------------------------------- -- Up -------------------------------------------------------------------------------- CREATE TABLE channel_settings ( team_id VARCHAR(255), channel_id VARCHAR(255), setting_key VARCHAR(255) NOT NULL, setting_value TEXT, FOREIGN KEY(team_id) REFERENCES team(id), UNIQUE(team_id, channel_id, setting_key) ); CREATE INDEX idx_channel_settings ON channel_settings (team_id, channel_id); -------------------------------------------------------------------------------- -- Down -------------------------------------------------------------------------------- DROP INDEX idx_channel_settings; DROP TABLE channel_settings; ================================================ FILE: migrations/004-change-default-point-0.5.sql ================================================ -------------------------------------------------------------------------------- -- Up -------------------------------------------------------------------------------- UPDATE channel_settings SET setting_value = '0 0.5 1 2 3 5 8 13 20 40 100 ∞ ?' WHERE setting_key = 'points' AND setting_value = '0 1/2 1 2 3 5 8 13 20 40 100 ∞ ?'; -------------------------------------------------------------------------------- -- Down -------------------------------------------------------------------------------- UPDATE channel_settings SET setting_value = '0 1/2 1 2 3 5 8 13 20 40 100 ∞ ?' WHERE setting_key = 'points' AND setting_value = '0 0.5 1 2 3 5 8 13 20 40 100 ∞ ?'; ================================================ FILE: migrations/005-add-dates.sql ================================================ -------------------------------------------------------------------------------- -- Up -------------------------------------------------------------------------------- ALTER TABLE team ADD COLUMN created_at INTEGER; ALTER TABLE team ADD COLUMN updated_at INTEGER; ALTER TABLE channel_settings ADD COLUMN created_at INTEGER; ALTER TABLE channel_settings ADD COLUMN updated_at INTEGER; -------------------------------------------------------------------------------- -- Down -------------------------------------------------------------------------------- ALTER TABLE team DROP COLUMN created_at; ALTER TABLE team DROP COLUMN updated_at; ALTER TABLE channel_settings DROP COLUMN created_at; ALTER TABLE channel_settings DROP COLUMN updated_at; ================================================ FILE: package.json ================================================ { "name": "slack-poker-planner", "version": "2.1.1", "description": "Poker planning app for slack", "scripts": { "build": "webpack --mode development", "test": "echo \"Error: no test specified\" && exit 1", "start": "node dist/slack-poker-planner.js", "watch": "webpack --mode development --watch" }, "repository": { "type": "git", "url": "git+https://github.com/dgurkaynak/slack-poker-planner.git" }, "keywords": [ "slack", "slack bot", "planning poker", "poker planner", "scrum poker" ], "author": "Deniz Gurkaynak ", "license": "MIT", "bugs": { "url": "https://github.com/dgurkaynak/slack-poker-planner/issues" }, "homepage": "https://github.com/dgurkaynak/slack-poker-planner#readme", "dependencies": { "@slack/web-api": "^7.8.0", "body-parser": "^1.20.3", "chalk": "^4.1.2", "dotenv": "^16.4.7", "express": "^4.21.2", "express-handlebars": "^8.0.1", "get-urls": "^10.0.1", "html-entities": "^2.5.2", "lodash": "^4.17.21", "parse-duration": "^1.1.2", "pretty-ms": "^7.0.1", "quoted-string-space-split": "^1.1.1", "redis": "^4.7.0", "shortid": "^2.2.17", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "ws": "^8.18.0" }, "devDependencies": { "@types/express": "^4.17.21", "@types/lodash": "^4.17.15", "@types/shortid": "2.2.0", "@types/sqlite3": "^3.1.11", "@types/ws": "^8.5.14", "prettier": "2.7.1", "ts-loader": "^9.5.2", "typescript": "^5.7.3", "webpack": "^5.97.1", "webpack-cli": "^6.0.1", "webpack-node-externals": "^3.0.0" } } ================================================ FILE: src/app.ts ================================================ require('dotenv').config(); import { logger } from './lib/logger'; import * as sqlite from './lib/sqlite'; import * as redis from './lib/redis'; import * as express from 'express'; import * as bodyParser from 'body-parser'; import * as exphbs from 'express-handlebars'; import { OAuthRoute } from './routes/oauth'; import { PPCommandRoute } from './routes/pp-command'; import { InteractivityRoute } from './routes/interactivity'; import * as SessionStore from './session/session-model'; import { getOlay } from './lib/olay'; const PORT = process.env.PORT || 3000; const BASE_PATH = process.env.BASE_PATH || '/'; const SLACK_SCOPE = 'commands,chat:write'; const USE_REDIS = process.env.USE_REDIS?.toLowerCase() === 'true' || false; async function main() { logger.init(); await sqlite.init(); if (USE_REDIS) { await redis.init(); await SessionStore.restore(); } await initServer(); // If olay env variables exists, init olay client if (process.env.OLAY_WS_URL && process.env.OLAY_WS_PROJECT) { logger.info({ msg: `Initing olay`, url: process.env.OLAY_WS_URL, project: process.env.OLAY_WS_PROJECT, }); const _olay = getOlay(); } logger.info({ msg: 'Boot successful!' }); } async function initServer(): Promise { const server = express(); // Setup handlebars server.engine('html', exphbs.engine({ extname: '.html' })); server.set('view engine', 'html'); server.set('views', 'src/views'); // relative to process.cwd // Parse body server.use(bodyParser.urlencoded({ extended: false })); server.use(bodyParser.json()); // Serve static files server.use(BASE_PATH, express.static('src/public')); // relative to process.cwd // Setup routes initRoutes(server); return new Promise((resolve) => { server.listen(PORT, () => { logger.info({ msg: `Server running`, port: PORT }); resolve(); }); }); } function initRoutes(server: express.Express) { const router = express.Router(); router.get('/', (req, res, next) => { res.render('index', { layout: false, data: { SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID, SLACK_SCOPE, SLACK_APP_ID: process.env.SLACK_APP_ID, }, }); }); router.get('/privacy', (req, res, next) => { res.render('privacy', { layout: false, data: { SLACK_APP_ID: process.env.SLACK_APP_ID, }, }); }); router.get('/oauth', OAuthRoute.handle); router.post('/slack/pp-command', PPCommandRoute.handle); router.post('/slack/pp-slash-command', PPCommandRoute.handle); router.post('/slack/action-endpoint', InteractivityRoute.handle); router.post('/slack/interactivity', InteractivityRoute.handle); router.get('/slack/direct-install', (req, res, next) => { const url = `https://slack.com/oauth/v2/authorize?client_id=${process.env.SLACK_CLIENT_ID}&scope=${SLACK_SCOPE}`; res.status(302).redirect(url); }); // Serve under specified base path server.use(BASE_PATH, router); } main().catch((err) => { logger.error({ msg: 'Could not boot', err }); process.exit(1); }); ================================================ FILE: src/lib/logger.ts ================================================ import chalk from 'chalk'; import isEmpty from 'lodash/isEmpty'; import omit from 'lodash/omit'; export type Severity = 'info' | 'warning' | 'error'; export type Log = { msg: string; err?: Error; [key: string]: unknown; }; class Logger { private isJSONLLoggingEnabled = false; public init() { this.isJSONLLoggingEnabled = process.env['ENABLE_JSONL_LOGGING']?.toLowerCase() === 'true'; } public info(log: Log) { this.log({ ...log, level: 'info' }); } public warning(log: Log) { this.log({ ...log, level: 'warning' }); } public error(log: Log) { this.log({ ...log, level: 'error' }); } private log(log: Log & { level: Severity }) { let logFn = console.log.bind(console); if (log.level === 'info') { logFn = console.info.bind(console); } else if (log.level === 'warning') { logFn = console.warn.bind(console); } else if (log.level === 'error') { logFn = console.error.bind(console); } if (this.isJSONLLoggingEnabled) { logFn(JSON.stringify({ ...log, ts: new Date().toISOString() }, replacer)); return; } ///////////////////////////////////////////// // Now, let's log in human readable format // ///////////////////////////////////////////// const logLevelStyled = log.level === 'error' ? chalk.bgRed(`[${log.level}]`) : log.level === 'warning' ? chalk.bgYellow(`[${log.level}]`) : chalk.inverse(`[${log.level}]`); const extraPayload = omit(log, ['msg', 'level']); if (isEmpty(extraPayload)) { logFn( `${chalk.dim(new Date().toISOString())} - ${logLevelStyled} ${log.msg}` ); } else { logFn( `${chalk.dim(new Date().toISOString())} - ${logLevelStyled} ${log.msg}`, JSON.stringify(extraPayload, replacer, 4) ); } } } /** * A replacer function for JSON.stringify that handles Error objects properly. * Derived from: https://stackoverflow.com/a/18391400 */ function replacer(_key: string | Symbol, value: unknown) { if (value instanceof Error) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const err: any = { name: value.name }; Object.getOwnPropertyNames(value).forEach((propName) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any err[propName] = (value as any)[propName]; }); return err; } return value; } export const logger = new Logger(); ================================================ FILE: src/lib/olay.ts ================================================ import { NodeClient } from '../vendor/olay-node-client'; let olay: NodeClient | undefined; export function getOlay() { if (olay) { return olay; } if (!process.env.OLAY_WS_URL || !process.env.OLAY_WS_PROJECT) { return undefined; } olay = new NodeClient({ wsRoot: process.env.OLAY_WS_URL, project: process.env.OLAY_WS_PROJECT, }); return olay; } ================================================ FILE: src/lib/redis.ts ================================================ import * as redis from 'redis'; import { logger } from './logger'; let client: redis.RedisClientType; export async function init() { logger.info({ msg: `Creating redis client...` }); client = redis.createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379', }); await client.connect(); client.on('error', (err) => { logger.error({ msg: `Unexpected redis error`, err, }); }); return client; } export function getSingleton() { return client; } ================================================ FILE: src/lib/sqlite.ts ================================================ import * as path from 'path'; import * as sqlite3 from 'sqlite3'; import { open, Database } from 'sqlite'; import { logger } from './logger'; let db: Database; export async function init() { if (db) { logger.warning({ msg: `Trying to init sqlite multiple times!` }); return db; } logger.info({ msg: `Opening sqlite...` }); db = await open({ filename: path.join(process.env.DATA_FOLDER || './', 'db.sqlite'), driver: sqlite3.Database, }); const version = await db.get('SELECT sqlite_version()'); logger.info({ msg: `Connected to SQLite`, version: version['sqlite_version()'], }); logger.info({ msg: `Migrating sqlite...` }); await db.migrate(); return db; } export function getSingleton() { return db; } ================================================ FILE: src/lib/string-match-all.ts ================================================ export function matchAll(str: string, regex: RegExp) { const res: string[] = []; let m: RegExpExecArray; if (regex.global) { while ((m = regex.exec(str))) { res.push(m[1]); } } else { if ((m = regex.exec(str))) { res.push(m[1]); } } return res; } ================================================ FILE: src/lib/to.ts ================================================ /** * Inspired by * https://medium.com/javascript-in-plain-english/how-to-avoid-try-catch-statements-nesting-chaining-in-javascript-a79028b325c5 */ export async function to(promise: Promise): Promise<[Error, T]> { try { return [undefined, await promise]; } catch (err) { return [err, undefined]; } } ================================================ FILE: src/public/styles.css ================================================ /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}legend,td,th{padding:0} /* Skeleton */ .column,.columns,.container{width:100%;box-sizing:border-box}.container{position:relative;max-width:960px;margin:0 auto;padding:0 20px}.column,.columns{float:left}@media (min-width:400px){.container{width:85%;padding:0}}@media (min-width:550px){.container{width:80%}.column,.columns{margin-left:4%}.column:first-child,.columns:first-child{margin-left:0}.one.column,.one.columns{width:4.66666666667%}.two.columns{width:13.3333333333%}.three.columns{width:22%}.four.columns{width:30.6666666667%}.five.columns{width:39.3333333333%}.six.columns{width:48%}.seven.columns{width:56.6666666667%}.eight.columns{width:65.3333333333%}.nine.columns{width:74%}.ten.columns{width:82.6666666667%}.eleven.columns{width:91.3333333333%}.twelve.columns{width:100%;margin-left:0}.one-third.column{width:30.6666666667%}.two-thirds.column{width:65.3333333333%}.one-half.column{width:48%}.offset-by-one.column,.offset-by-one.columns{margin-left:8.66666666667%}.offset-by-two.column,.offset-by-two.columns{margin-left:17.3333333333%}.offset-by-three.column,.offset-by-three.columns{margin-left:26%}.offset-by-four.column,.offset-by-four.columns{margin-left:34.6666666667%}.offset-by-five.column,.offset-by-five.columns{margin-left:43.3333333333%}.offset-by-six.column,.offset-by-six.columns{margin-left:52%}.offset-by-seven.column,.offset-by-seven.columns{margin-left:60.6666666667%}.offset-by-eight.column,.offset-by-eight.columns{margin-left:69.3333333333%}.offset-by-nine.column,.offset-by-nine.columns{margin-left:78%}.offset-by-ten.column,.offset-by-ten.columns{margin-left:86.6666666667%}.offset-by-eleven.column,.offset-by-eleven.columns{margin-left:95.3333333333%}.offset-by-one-third.column,.offset-by-one-third.columns{margin-left:34.6666666667%}.offset-by-two-thirds.column,.offset-by-two-thirds.columns{margin-left:69.3333333333%}.offset-by-one-half.column,.offset-by-one-half.columns{margin-left:52%}}html{font-size:62.5%}body,h6{line-height:1.6}body{font-size:1.5em;font-weight:400;font-family:"Raleway","HelveticaNeue","Helvetica Neue",Helvetica,Arial,sans-serif;color:#222}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:2rem;font-weight:300}h1,h2,h3{font-size:4rem;line-height:1.2;letter-spacing:-.1rem}h2,h3{font-size:3.6rem;line-height:1.25}h3{font-size:3rem;line-height:1.3}h4{font-size:2.4rem;line-height:1.35;letter-spacing:-.08rem}h5{font-size:1.8rem;line-height:1.5;letter-spacing:-.05rem}h6{font-size:1.5rem;letter-spacing:0}@media (min-width:550px){h1{font-size:5rem}h2{font-size:4.2rem}h3{font-size:3.6rem}h4{font-size:3rem}h5{font-size:2.4rem}h6{font-size:1.5rem}}p{margin-top:0}a{color:#1eaedb}a:hover{color:#0fa0ce}.button,button,input[type=button],input[type=reset],input[type=submit]{display:inline-block;height:38px;padding:0 30px;color:#555;text-align:center;font-size:11px;font-weight:600;line-height:38px;letter-spacing:.1rem;text-transform:uppercase;text-decoration:none;white-space:nowrap;background-color:transparent;border-radius:4px;border:1px solid #bbb;cursor:pointer;box-sizing:border-box}.button:focus,.button:hover,button:focus,button:hover,input[type=button]:focus,input[type=button]:hover,input[type=reset]:focus,input[type=reset]:hover,input[type=submit]:focus,input[type=submit]:hover{color:#333;border-color:#888;outline:0}.button.button-primary,button.button-primary,input[type=button].button-primary,input[type=reset].button-primary,input[type=submit].button-primary{color:#fff;background-color:#33c3f0;border-color:#33c3f0}.button.button-primary:focus,.button.button-primary:hover,button.button-primary:focus,button.button-primary:hover,input[type=button].button-primary:focus,input[type=button].button-primary:hover,input[type=reset].button-primary:focus,input[type=reset].button-primary:hover,input[type=submit].button-primary:focus,input[type=submit].button-primary:hover{color:#fff;background-color:#1eaedb;border-color:#1eaedb}input[type=email],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=url],select,textarea{height:38px;padding:6px 10px;background-color:#fff;border:1px solid #d1d1d1;border-radius:4px;box-shadow:none;box-sizing:border-box}input[type=email],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=url],textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none}textarea{min-height:65px;padding-top:6px;padding-bottom:6px}input[type=email]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=url]:focus,select:focus,textarea:focus{border:1px solid #33c3f0;outline:0}label,legend{display:block;margin-bottom:.5rem;font-weight:600}fieldset{padding:0;border-width:0}input[type=checkbox],input[type=radio]{display:inline}label>.label-body{display:inline-block;margin-left:.5rem;font-weight:400}ul{list-style:circle inside}ol{list-style:decimal inside}ol,ul{padding-left:0;margin-top:0}code,ol ol,ol ul,ul ol,ul ul{margin:1.5rem 0 1.5rem 3rem;font-size:90%}.button,button,li{margin-bottom:1rem}code{padding:.2rem .5rem;margin:0 .2rem;white-space:nowrap;background:#f1f1f1;border:1px solid #e1e1e1;border-radius:4px}pre>code{display:block;padding:1rem 1.5rem;white-space:pre}td,th{padding:12px 15px;text-align:left;border-bottom:1px solid #e1e1e1}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}.u-full-width{width:100%;box-sizing:border-box}.u-max-full-width{max-width:100%;box-sizing:border-box}.u-pull-right{float:right}.u-pull-left{float:left}hr{margin-top:3rem;margin-bottom:3.5rem;border-width:0;border-top:1px solid #e1e1e1}.container:after,.row:after,.u-cf{content:"";display:table;clear:both} /* Own */ .screenshot-image { box-sizing: border-box; width: 100%; max-width: 480px; padding: 5px; background: #fff; border: 5px solid #4ba3eb; border-radius: 10px; } ================================================ FILE: src/routes/interactivity.ts ================================================ import * as express from 'express'; import { logger } from '../lib/logger'; import { generate as generateId } from 'shortid'; import { to } from '../lib/to'; import { TeamStore, ITeam, ChannelSettingKey } from '../team/team-model'; import * as SessionStore from '../session/session-model'; import { ISession } from '../session/isession'; import { SessionController, SessionControllerErrorCode, DEFAULT_POINTS, } from '../session/session-controller'; import isEmpty from 'lodash/isEmpty'; import { IInteractiveMessageActionPayload, IViewSubmissionActionPayload, } from '../vendor/slack-api-interfaces'; import uniq from 'lodash/uniq'; import find from 'lodash/find'; import get from 'lodash/get'; import isObject from 'lodash/isObject'; import splitSpacesExcludeQuotes from 'quoted-string-space-split'; import parseDuration from 'parse-duration'; import prettyMilliseconds from 'pretty-ms'; import { decode } from 'html-entities'; import { getOlay } from '../lib/olay'; const APP_INSTALL_LINK = process.env.APP_INSTALL_LINK || 'https://deniz.co/slack-poker-planner'; const ISSUES_LINK = process.env.ISSUES_LINK || 'https://github.com/dgurkaynak/slack-poker-planner/issues'; const MAX_VOTING_DURATION = Number(process.env.MAX_VOTING_DURATION) || 604800000; export class InteractivityRoute { /** * POST /slack/action-endpoint * POST /slack/interactivity * https://api.slack.com/interactivity/handling#payloads */ static async handle(req: express.Request, res: express.Response) { let payload: | IInteractiveMessageActionPayload | IViewSubmissionActionPayload; try { payload = JSON.parse(req.body.payload); } catch (err) { const errorId = generateId(); logger.error({ msg: `Could not parse action payload`, errorId, body: req.body, }); return res.json({ text: `Unexpected slack action payload (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } if (payload.token != process.env.SLACK_VERIFICATION_TOKEN) { logger.error({ msg: `Could not process action, invalid verification token`, payload, }); return res.json({ text: `Invalid slack verification token, please get in touch with the maintainer`, response_type: 'ephemeral', replace_original: false, }); } switch (payload.type) { case 'interactive_message': { await InteractivityRoute.interactiveMessage({ payload, res }); return; } case 'view_submission': { await InteractivityRoute.viewSubmission({ payload, res }); return; } default: { const errorId = generateId(); logger.error({ msg: `Unexpected interactive-message action callbackId`, errorId, payload, }); return res.json({ text: `Unexpected payload type (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } } } /** * A user clicks on a button on message */ static async interactiveMessage({ payload, // action request payload res, }: { payload: IInteractiveMessageActionPayload; res: express.Response; }) { const parts = payload.callback_id.split(':'); if (parts.length != 2) { const errorId = generateId(); logger.error({ msg: `Unexpected interactive message callback id`, errorId, payload, }); return res.json({ text: `Unexpected interactive message callback id (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } const [action, sessionId] = parts; // Get team const [teamErr, team] = await to(TeamStore.findById(payload.team.id)); if (teamErr) { const errorId = generateId(); logger.error({ msg: `Could not get team`, errorId, err: teamErr, payload, }); return res.json({ text: `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } if (!team) { return res.json({ text: `Your Slack team (${payload.team.domain}) could not be found, please reinstall Poker Planner on <${APP_INSTALL_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } switch (action) { /** * A user clicked session actions button: * - Reveal * - Cancel */ case 'action': { const session = SessionStore.findById(sessionId); if (!session) { return res.json({ text: `Ooops, could not find the session, it may be expired or cancelled`, response_type: 'ephemeral', replace_original: false, }); } const sessionAction = payload.actions[0].value; if (sessionAction == 'reveal') { await InteractivityRoute.revealSession({ payload, team, session, res, }); } else if (sessionAction == 'cancel') { await InteractivityRoute.cancelSession({ payload, team, session, res, }); } else { const errorId = generateId(); logger.error({ msg: `Unexpected action button clicked`, errorId, sessionAction, payload, }); res.json({ text: `Unexpected action button (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } return; } /** * A user clicked vote point button */ case 'vote': { const session = SessionStore.findById(sessionId); if (!session) { return res.json({ text: `Ooops, could not find the session, it may be expired or cancelled`, response_type: 'ephemeral', replace_original: false, }); } await InteractivityRoute.vote({ payload, team, session, res }); return; } /** * A user clicked ended session actions button: * - Restart voting * - Delete message */ case 'end_action': { const buttonPayloadStr = payload.actions[0].value; let buttonPayload: any; try { buttonPayload = JSON.parse(buttonPayloadStr); } catch (err) { const errorId = generateId(); logger.error({ msg: `Unexpected button payload`, errorId, buttonPayloadStr, payload, }); res.json({ text: `Unexpected button payload (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); return; } //////////////////// // Restart voting // //////////////////// if (buttonPayload.b === 0) { const { vd: votingDuration, pa: participants } = buttonPayload; const title = decode(buttonPayload.ti); const points = buttonPayload.po.map(decode); const newSession: ISession = { id: generateId(), votingDuration: votingDuration, endsAt: Date.now() + votingDuration, title, points, votes: {}, state: 'active', teamId: team.id, channelId: payload.channel.id, userId: payload.user.id, participants, rawPostMessageResponse: undefined, protected: buttonPayload.pr === 1 ? true : false, average: buttonPayload.av === 1 ? true : false, }; logger.info({ msg: `Restarting session`, originalSessionId: sessionId, team: { id: team.id, name: team.name, }, user: { id: payload.user.id, name: payload.user.name, }, channelId: payload.channel.id, sessionId: newSession.id, }); try { const postMessageResponse = await SessionController.postMessage( newSession, team ); newSession.rawPostMessageResponse = postMessageResponse as any; SessionStore.upsert(newSession); res.send(); getOlay()?.addEvent(sessionId, 'restarted', { userId: payload.user.id, }); getOlay()?.updateMetadata(newSession.id, { sessionId: newSession.id, teamId: team.id, userId: payload.user.id, channelId: newSession.channelId, votingDurationMs: newSession.votingDuration, points: newSession.points, participantsCount: newSession.participants.length, isProtected: newSession.protected, calculateAverage: newSession.average, bulkCount: 1, }); } catch (err) { const errorId = generateId(); let shouldLog = true; let logLevel: 'info' | 'warn' | 'error' = 'error'; let errorMessage = `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`; const slackErrorCode = err.data && err.data.error; if (slackErrorCode) { errorMessage = `Unexpected Slack API Error: "*${slackErrorCode}*" (error code: ${errorId})\n\n` + `If you think this is an issue, please report to <${ISSUES_LINK}>`; } /** * Slack API platform errors */ if (slackErrorCode == 'not_in_channel') { shouldLog = false; errorMessage = `Poker Planner app is not added to this channel. ` + `Please try again after adding it. ` + `You can simply add the app just by mentioning it, like "*@poker_planner*".`; } else if (slackErrorCode == 'channel_not_found') { shouldLog = false; errorMessage = `Oops, we couldn't find this channel. ` + `Are you sure that Poker Planner app is added to this channel/conversation? ` + `You can simply add the app by mentioning it, like "*@poker_planner*". ` + `However this may not work in Group DMs, you need to explicitly add it as a ` + `member from conversation details menu. Please try again after adding it.`; } else if (slackErrorCode == 'token_revoked') { logLevel = 'info'; errorMessage = `Poker Planner's access has been revoked for this workspace. ` + `In order to use it, you need to install the app again on ` + `<${APP_INSTALL_LINK}>`; } else if ( slackErrorCode == 'method_not_supported_for_channel_type' ) { logLevel = 'info'; errorMessage = `Poker Planner cannot be used in this type of conversations.`; } if (shouldLog) { logger[logLevel]({ msg: `Could not restart session`, sessionId, errorId, err, payload, }); } res.json({ text: errorMessage, response_type: 'ephemeral', replace_original: false, }); } return; } //////////////////// // Delete message // //////////////////// else if (buttonPayload.b === 1) { logger.info({ msg: `Deleting session message`, team: { id: team.id, name: team.name, }, user: { id: payload.user.id, name: payload.user.name, }, channelId: payload.channel.id, sessionId: sessionId, }); try { await SessionController.deleteMessage( team, payload.channel.id, payload.message_ts ); res.send(); getOlay()?.addEvent(sessionId, 'deleted', { userId: payload.user.id, }); } catch (err) { const errorId = generateId(); let errorMessage = `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`; const slackErrorCode = err.data && err.data.error; if (slackErrorCode) { errorMessage = `Unexpected Slack API Error: "*${slackErrorCode}*" (error code: ${errorId})\n\n` + `If you think this is an issue, please report to <${ISSUES_LINK}>`; } logger.error({ msg: `Could not delete session message`, sessionId, errorId, err, payload, }); res.json({ text: errorMessage, response_type: 'ephemeral', replace_original: false, }); } return; } //////////////////// // Unknown button // //////////////////// else { const errorId = generateId(); logger.error({ msg: `Unexpected button type`, errorId, buttonPayloadStr, payload, }); res.json({ text: `Unexpected button type (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); return; } return; } /** * Unexpected action */ default: { const errorId = generateId(); logger.error({ msg: `Unexpected action`, errorId, action, payload, }); return res.json({ text: `Unexpected action (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } } } /** * A user clicks a submit button a view */ static async viewSubmission({ payload, // action request payload res, }: { payload: IViewSubmissionActionPayload; res: express.Response; }) { const [teamGetErr, team] = await to(TeamStore.findById(payload.team.id)); if (teamGetErr) { const errorId = generateId(); logger.error({ msg: `Could not create session, could not get the team from db`, errorId, err: teamGetErr, payload, }); return res.json({ text: `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } if (!team) { logger.info({ msg: `Could not create session, team could not be found`, payload, }); return res.json({ text: `Your Slack team (${payload.team.domain}) could not be found, please reinstall Poker Planner on <${APP_INSTALL_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } const callbackId = payload.view.callback_id; switch (callbackId) { case 'newSessionModal:submit': { return InteractivityRoute.submitNewSessionModal({ payload, team, res }); } default: { const errorId = generateId(); logger.error({ msg: `Unexpected view-submission action callbackId`, errorId, callbackId, payload, }); return res.json({ text: `Unexpected view-submission callback id (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } } } /** * A user submits the `new session` modal. */ static async submitNewSessionModal({ payload, // action request payload team, res, }: { payload: IViewSubmissionActionPayload; team: ITeam; res: express.Response; }) { const errorId = generateId(); try { //////////////////////// // Get the channel id // //////////////////////// let channelId: string; try { const privateMetadata = JSON.parse(payload.view.private_metadata); channelId = privateMetadata.channelId; } catch (err) { logger.error({ msg: 'Could not create session: Cannot parse private_metadata', errorId, err, payload, }); throw new Error(SessionControllerErrorCode.UNEXPECTED_PAYLOAD); } if (!channelId) { logger.error({ msg: 'Could not create session: Missing channelId', errorId, payload, }); throw new Error(SessionControllerErrorCode.UNEXPECTED_PAYLOAD); } /////////////////////////// // Get the session title // /////////////////////////// const titleInputState = get(payload, 'view.state.values.title'); if (!isObject(titleInputState) || isEmpty(titleInputState)) { logger.error({ msg: 'Could not create session: Title is not an object or empty', errorId, payload, }); throw new Error(SessionControllerErrorCode.TITLE_REQUIRED); } const rawTitle = (titleInputState as any)[Object.keys(titleInputState)[0]] .value; if (typeof rawTitle !== 'string') { throw new Error(SessionControllerErrorCode.TITLE_REQUIRED); } const titles: string[] = []; rawTitle.split(/\r?\n/).forEach((rawLine) => { const trimmed = rawLine.trim(); if (trimmed.length === 0) return; titles.push(trimmed); }); if (titles.length === 0) { throw new Error(SessionControllerErrorCode.TITLE_REQUIRED); } if (titles.length > 10) { throw new Error(SessionControllerErrorCode.MAX_TITLE_LIMIT_EXCEEDED); } ////////////////////////// // Get the participants // ////////////////////////// const participantsInputState = get( payload, 'view.state.values.participants' ); if ( !isObject(participantsInputState) || isEmpty(participantsInputState) ) { logger.error({ msg: 'Could not create session: Participants is not an object or empty', errorId, payload, }); throw new Error(SessionControllerErrorCode.NO_PARTICIPANTS); } const participants = (participantsInputState as any)[ Object.keys(participantsInputState)[0] ].selected_users; if (participants.length == 0) { throw new Error(SessionControllerErrorCode.NO_PARTICIPANTS); } //////////////////// // Get the points // //////////////////// const pointsInputState = get(payload, 'view.state.values.points'); if (!isObject(pointsInputState) || isEmpty(pointsInputState)) { logger.error({ msg: 'Could not create session: Points is not an object or empty', errorId, payload, }); throw new Error(SessionControllerErrorCode.INVALID_POINTS); } const pointsStr = (pointsInputState as any)[Object.keys(pointsInputState)[0]].value || ''; let points: string[] = uniq(splitSpacesExcludeQuotes(pointsStr)) || []; if (points.length == 1 && points[0] == 'reset') { points = DEFAULT_POINTS; } if (points.length < 2 || points.length > 25) { throw new Error(SessionControllerErrorCode.INVALID_POINTS); } ///////////////////////////// // Get the voting duration // ///////////////////////////// const votingDurationInputState = get( payload, 'view.state.values.votingDuration' ); if ( !isObject(votingDurationInputState) || isEmpty(votingDurationInputState) ) { logger.error({ msg: 'Could not create session: Voting duration is not an object or empty', errorId, payload, }); throw new Error(SessionControllerErrorCode.INVALID_VOTING_DURATION); } const votingDurationStr = (votingDurationInputState as any)[ Object.keys(votingDurationInputState)[0] ].value; if (!votingDurationStr || votingDurationStr.trim().length == 0) { throw new Error(SessionControllerErrorCode.INVALID_VOTING_DURATION); } const votingDurationMs = parseDuration(votingDurationStr); if (typeof votingDurationMs !== 'number') { throw new Error(SessionControllerErrorCode.INVALID_VOTING_DURATION); } if (votingDurationMs < 60000 || votingDurationMs > MAX_VOTING_DURATION) { throw new Error(SessionControllerErrorCode.INVALID_VOTING_DURATION); } //////////////////////////// // Get "other" checkboxes // //////////////////////////// const otherCheckboxesState = get(payload, 'view.state.values.other'); const selectedOptions = isObject(otherCheckboxesState) && !isEmpty(otherCheckboxesState) ? (otherCheckboxesState as any)[Object.keys(otherCheckboxesState)[0]] .selected_options : []; const isProtected = !!find( selectedOptions, (option) => option.value == 'protected' ); const calculateAverage = !!find( selectedOptions, (option) => option.value == 'average' ); //Implementing this as the order is getting mixed up by the promise.all() for (const title of titles) { const session: ISession = { id: generateId(), votingDuration: votingDurationMs, endsAt: Date.now() + votingDurationMs, title, points, votes: {}, state: 'active', teamId: team.id, channelId, userId: payload.user.id, participants, rawPostMessageResponse: undefined, protected: isProtected, average: calculateAverage, }; logger.info({ msg: `Creating a new session`, team: { id: team.id, name: team.name, }, user: { id: payload.user.id, name: payload.user.name, }, channelId, sessionId: session.id, bulkCount: titles.length, }); const postMessageResponse = await SessionController.postMessage( session, team ); session.rawPostMessageResponse = postMessageResponse as any; SessionStore.upsert(session); // Some analytics getOlay()?.updateMetadata(session.id, { sessionId: session.id, teamId: team.id, userId: payload.user.id, channelId, votingDurationMs, points, participantsCount: participants.length, isProtected, calculateAverage, bulkCount: titles.length, // Beware, this number will be repeated for each session. So, in order to properly analyze the bulk count, we need to divide the total bulk count by the number of sessions. }); } // Once all tasks are done sequentially, send the response res.send(); // Save the channel settings const [upsertSettingErr] = await to( TeamStore.upsertSettings(team.id, channelId, { [ChannelSettingKey.PARTICIPANTS]: participants.join(' '), [ChannelSettingKey.POINTS]: points .map((point) => { if (!point.includes(' ')) return point; if (point.includes(`"`)) return `'${point}'`; return `"${point}"`; }) .join(' '), [ChannelSettingKey.PROTECTED]: JSON.stringify(isProtected), [ChannelSettingKey.AVERAGE]: JSON.stringify(calculateAverage), [ChannelSettingKey.VOTING_DURATION]: prettyMilliseconds(votingDurationMs), }) ); if (upsertSettingErr) { logger.error({ msg: `Could not upsert settings after creating new session`, teamId: team.id, channelId, err: upsertSettingErr, }); } } catch (err) { let shouldLog = true; let logLevel: 'info' | 'warn' | 'error' = 'error'; let errorMessage = `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`; let modalErrors: { [key: string]: string } = {}; const slackErrorCode = err.data && err.data.error; if (slackErrorCode) { errorMessage = `Unexpected Slack API Error: "*${slackErrorCode}*" (error code: ${errorId})\n\n` + `If you think this is an issue, please report to <${ISSUES_LINK}>`; } /** * Slack API platform errors */ if (slackErrorCode == 'not_in_channel') { shouldLog = false; errorMessage = `Poker Planner app is not added to this channel. ` + `Please try again after adding it. ` + `You can simply add the app just by mentioning it, like "*@poker_planner*".`; } else if (slackErrorCode == 'channel_not_found') { shouldLog = false; errorMessage = `Oops, we couldn't find this channel. ` + `Are you sure that Poker Planner app is added to this channel/conversation? ` + `You can simply add the app by mentioning it, like "*@poker_planner*". ` + `However this may not work in Group DMs, you need to explicitly add it as a ` + `member from conversation details menu. Please try again after adding it.`; } else if (slackErrorCode == 'token_revoked') { logLevel = 'info'; errorMessage = `Poker Planner's access has been revoked for this workspace. ` + `In order to use it, you need to install the app again on ` + `<${APP_INSTALL_LINK}>`; } else if (slackErrorCode == 'method_not_supported_for_channel_type') { logLevel = 'info'; errorMessage = `Poker Planner cannot be used in this type of conversations.`; } else if (slackErrorCode == 'missing_scope') { if (err.data.needed == 'mpim:read') { logLevel = 'info'; errorMessage = `Poker Planner now supports Group DMs! However it requires ` + `additional permissions that we didn't obtained previously. You need to visit ` + `<${APP_INSTALL_LINK}> and reinstall the app to enable this feature.`; } else if (err.data.needed == 'usergroups:read') { logLevel = 'info'; errorMessage = `Poker Planner now supports @usergroup mentions! However it requires ` + `additional permissions that we didn't obtained previously. You need to visit ` + `<${APP_INSTALL_LINK}> and reinstall the app to enable this feature.`; } } else if ( /** * Internal errors */ err.message == SessionControllerErrorCode.NO_PARTICIPANTS ) { shouldLog = false; errorMessage = `You must add at least 1 person.`; modalErrors = { participants: errorMessage, }; } else if (err.message == SessionControllerErrorCode.TITLE_REQUIRED) { shouldLog = false; errorMessage = `At least one title is required`; modalErrors = { title: errorMessage, }; } else if ( err.message == SessionControllerErrorCode.MAX_TITLE_LIMIT_EXCEEDED ) { shouldLog = false; errorMessage = `You can bulk-create up to 10 sessions`; modalErrors = { title: errorMessage, }; } else if (err.message == SessionControllerErrorCode.INVALID_POINTS) { shouldLog = false; errorMessage = `You must provide at least 2 poker points seperated by space, ` + `the maximum is 25.`; modalErrors = { points: errorMessage, }; } else if (err.message == SessionControllerErrorCode.UNEXPECTED_PAYLOAD) { shouldLog = false; errorMessage = `Oops, Slack API sends a payload that we don't expect. Please try again.\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}> ` + `with following error code: ${errorId}`; } else if ( err.message == SessionControllerErrorCode.INVALID_VOTING_DURATION ) { shouldLog = false; errorMessage = `Voting window must be between 1m and ${prettyMilliseconds( MAX_VOTING_DURATION )}`; modalErrors = { votingDuration: errorMessage, }; } if (shouldLog) { logger[logLevel]({ msg: `Could not create session(s)`, teamId: team.id, errorId, err, payload, }); } // Show the generic errors on a new modal if (isEmpty(modalErrors)) { return res.json({ response_action: 'push', view: { type: 'modal', title: { type: 'plain_text', text: 'Poker Planner', emoji: true, }, close: { type: 'plain_text', text: 'Close', emoji: true, }, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `:x: ${errorMessage}`, }, }, ], }, }); } // Show error on form elements return res.json({ response_action: 'errors', errors: modalErrors, }); } } /** * A user clicks on a vote button. */ static async vote({ payload, // action request payload team, session, res, }: { payload: IInteractiveMessageActionPayload; team: ITeam; session: ISession; res: express.Response; }) { const point = payload.actions[0].value; const [voteErr] = await to( SessionController.vote(session, team, payload.user.id, point) ); if (voteErr) { switch (voteErr.message) { case SessionControllerErrorCode.SESSION_NOT_ACTIVE: { return res.json({ text: `You cannot vote revealed or cancelled session`, response_type: 'ephemeral', replace_original: false, }); } case SessionControllerErrorCode.ONLY_PARTICIPANTS_CAN_VOTE: { return res.json({ text: `You are not a participant of that session`, response_type: 'ephemeral', replace_original: false, }); } // Unknown error default: { const errorId = generateId(); let errorMessage = `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`; const slackErrorCode = (voteErr as any)?.data?.error; if (slackErrorCode) { errorMessage = `Unexpected Slack API Error: "*${slackErrorCode}*", please try again later (error code: ${errorId})\n\n` + `If you think this is an issue, please report to <${ISSUES_LINK}>`; } if (slackErrorCode == 'channel_not_found') { errorMessage = `Unexpected Slack API Error: "*${slackErrorCode}*". Are you using Poker Planner on a shared channel? ` + `Shared channels are not supported due to Slack API limitations.\n\n` + `If you think this is an issue, please report to <${ISSUES_LINK}> with this error code: ${errorId}`; } logger.error({ msg: `Could not vote`, sessionId: session.id, errorId, err: voteErr, payload, }); return res.json({ text: errorMessage, response_type: 'ephemeral', replace_original: false, }); } } } getOlay()?.addEvent(session.id, 'voted', { userId: payload.user.id, points: payload.actions[0].value, }); return res.send(); } /** * A user clicks reveal button. */ static async revealSession({ payload, // action request payload team, session, res, }: { payload: IInteractiveMessageActionPayload; team: ITeam; session: ISession; res: express.Response; }) { if (session.protected && session.userId != payload.user.id) { return res.json({ text: `This session is protected, only the creator can reveal it.`, response_type: 'ephemeral', replace_original: false, }); } logger.info({ msg: `Revealing votes`, sessionId: session.id, team: { id: team.id, name: team.name, }, user: { id: payload.user.id, name: payload.user.name, }, }); const [revealErr] = await to( SessionController.revealAndUpdateMessage(session, team, payload.user.id) ); if (revealErr) { const errorId = generateId(); let errorMessage = `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`; const slackErrorCode = (revealErr as any)?.data?.error; if (slackErrorCode) { errorMessage = `Unexpected Slack API Error: "*${slackErrorCode}*", please try again later (error code: ${errorId})\n\n` + `If you think this is an issue, please report to <${ISSUES_LINK}>`; } if (slackErrorCode == 'channel_not_found') { errorMessage = `Unexpected Slack API Error: "*${slackErrorCode}*". Are you using Poker Planner on a shared channel? ` + `Shared channels are not supported due to Slack API limitations.\n\n` + `If you think this is an issue, please report to <${ISSUES_LINK}> with this error code: ${errorId}`; } logger.error({ msg: `Could not manually reveal session`, sessionId: session.id, errorId, err: revealErr, payload, }); return res.json({ text: errorMessage, response_type: 'ephemeral', replace_original: false, }); } getOlay()?.addEvent(session.id, 'manuallyRevealed', { userId: payload.user.id, }); return res.send(); } /** * A user clicks cancel button. */ static async cancelSession({ payload, // action request payload team, session, res, }: { payload: IInteractiveMessageActionPayload; team: ITeam; session: ISession; res: express.Response; }) { if (session.protected && session.userId != payload.user.id) { return res.json({ text: `This session is protected, only the creator can cancel it.`, response_type: 'ephemeral', replace_original: false, }); } logger.info({ msg: `Cancelling session`, sessionId: session.id, team: { id: team.id, name: team.name, }, user: { id: payload.user.id, name: payload.user.name, }, }); const [cancelErr] = await to( SessionController.cancelAndUpdateMessage(session, team, payload.user.id) ); if (cancelErr) { const errorId = generateId(); let errorMessage = `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`; const slackErrorCode = (cancelErr as any)?.data?.error; if (slackErrorCode) { errorMessage = `Unexpected Slack API Error: "*${slackErrorCode}*", please try again later (error code: ${errorId})\n\n` + `If you think this is an issue, please report to <${ISSUES_LINK}>`; } if (slackErrorCode == 'channel_not_found') { errorMessage = `Unexpected Slack API Error: "*${slackErrorCode}*". Are you using Poker Planner on a shared channel? ` + `Shared channels are not supported due to Slack API limitations.\n\n` + `If you think this is an issue, please report to <${ISSUES_LINK}> with this error code: ${errorId}`; } logger.error({ msg: `Could not cancel session`, sessionId: session.id, errorId, err: cancelErr, payload, }); return res.json({ text: errorMessage, response_type: 'ephemeral', replace_original: false, }); } getOlay()?.addEvent(session.id, 'cancelled', { userId: payload.user.id, }); return res.send(); } } ================================================ FILE: src/routes/oauth.ts ================================================ import * as express from 'express'; import { WebClient } from '@slack/web-api'; import { logger } from '../lib/logger'; import { TeamStore } from '../team/team-model'; import { generate as generateId } from 'shortid'; import { to } from '../lib/to'; const ISSUES_LINK = process.env.ISSUES_LINK || 'https://github.com/dgurkaynak/slack-poker-planner/issues'; export class OAuthRoute { /** * GET /oauth */ static async handle(req: express.Request, res: express.Response) { // Slack-side error, display error message if (req.query.error) { logger.error({ msg: `Could not oauth`, qsError: req.query.error, }); return res.status(500).send(req.query.error); } // Installed! if (req.query.code) { const slackWebClient = new WebClient(); const [oauthErr, accessResponse] = await to( slackWebClient.oauth.v2.access({ client_id: process.env.SLACK_CLIENT_ID, client_secret: process.env.SLACK_CLIENT_SECRET, code: req.query.code as string, }) ); if (oauthErr) { const errorId = generateId(); logger.error({ msg: `Could not oauth, slack api call failed`, errorId, err: oauthErr, }); return res .status(500) .send( `Internal server error, please try again (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>` ); } const [upsertErr, team] = await to( TeamStore.upsert({ id: (accessResponse as any).team.id, name: (accessResponse as any).team.name, access_token: (accessResponse as any).access_token, scope: (accessResponse as any).scope, user_id: (accessResponse as any).authed_user.id, }) ); if (upsertErr) { const errorId = generateId(); logger.error({ msg: `Could not oauth, sqlite upsert failed`, errorId, err: upsertErr, }); res .status(500) .send( `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>` ); } logger.info({ msg: `Added to team`, team, }); return res.render('oauth-success', { layout: false, data: { SLACK_APP_ID: process.env.SLACK_APP_ID, TEAM_NAME: team.name, }, }); } // Unknown error const errorId = generateId(); logger.error({ msg: `Could not oauth, unknown error`, errorId, query: req.query, }); return res .status(500) .send( `Unknown error (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>` ); } } ================================================ FILE: src/routes/pp-command.ts ================================================ import * as express from 'express'; import { logger } from '../lib/logger'; import { TeamStore, ChannelSettingKey } from '../team/team-model'; import { generate as generateId } from 'shortid'; import { to } from '../lib/to'; import isString from 'lodash/isString'; import { ISlackCommandRequestBody } from '../vendor/slack-api-interfaces'; import { SessionController, DEFAULT_POINTS, } from '../session/session-controller'; import { splitSpacesExcludeQuotes } from 'quoted-string-space-split'; const APP_INSTALL_LINK = process.env.APP_INSTALL_LINK || 'https://deniz.co/slack-poker-planner'; const ISSUES_LINK = process.env.ISSUES_LINK || 'https://github.com/dgurkaynak/slack-poker-planner/issues'; export class PPCommandRoute { /** * POST /slack/pp-command * POST /slack/pp-slash-command */ static async handle(req: express.Request, res: express.Response) { const cmd = req.body as ISlackCommandRequestBody; if (cmd.token != process.env.SLACK_VERIFICATION_TOKEN) { logger.error({ msg: `Could not created session, slack verification token is invalid`, cmd, }); return res.json({ text: `Invalid slack verification token, please get in touch with the maintainer`, response_type: 'ephemeral', replace_original: false, }); } if (!isString(cmd.text)) { const errorId = generateId(); logger.error({ msg: `Could not created session, command.text not string`, errorId, cmd, }); return res.json({ text: `Unexpected command usage (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } const firstWord = cmd.text.trim().split(' ')[0]; switch (firstWord) { case 'help': { return PPCommandRoute.help(res); } case 'config': { return await PPCommandRoute.configure(cmd, res); } default: { return await PPCommandRoute.openNewSessionModal(cmd, res); } } } /** * `/pp some task name` */ static async openNewSessionModal( cmd: ISlackCommandRequestBody, res: express.Response ) { if (cmd.channel_name == 'directmessage') { return res.json({ text: `Poker planning cannot be started in direct messages`, response_type: 'ephemeral', replace_original: false, }); } const [teamGetErr, team] = await to(TeamStore.findById(cmd.team_id)); if (teamGetErr) { const errorId = generateId(); logger.error({ msg: `Could not created session, could not get the team from db`, errorId, err: teamGetErr, cmd, }); return res.json({ text: `Internal server error, please try again later (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } if (!team) { logger.info({ msg: `Could not created session, team could not be found`, cmd, }); return res.json({ text: `Your Slack team (${cmd.team_domain}) could not be found, please reinstall Poker Planner on <${APP_INSTALL_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } // If permissions are old, show migration message if ( team.scope == 'identify,commands,channels:read,groups:read,users:read,chat:write:bot' ) { logger.info({ msg: `Migration message`, team: { id: team.id, name: team.name, }, user: { id: cmd.user_id, name: cmd.user_name, }, }); return res.json({ text: 'Poker Planner has migrated to ' + " " + 'which adds granular permissions for better security. We now depend on bot permissions instead of ' + 'user permissions. So that you can explicitly manage which channels/conversations Poker Planner can ' + 'access. However, this requires a couple of changes:\n\n• In order to obtain new bot permissions ' + 'and drop user ones, *you need to reinstall Poker Planner* to your workspace on ' + `<${APP_INSTALL_LINK}>\n• Before using \`/pp\` command, *Poker Planner app must be ` + 'added to that channel/conversation*. You can simply add or invite it by just mentioning the app like ' + '`@poker_planner`. You can also do that from channel/converstion details menu.', response_type: 'ephemeral', replace_original: false, }); } /** * From: https://api.slack.com/legacy/interactive-messages * * Responding right away * --- * You must respond within 3 seconds. If it takes your application longer * to process the request, we recommend responding with a HTTP 200 OK * immediately, then use the response_url to respond five times within * thirty minutes. * * Responding incrementally with response_url * --- * Use the response URL provided in the post to: * - Replace the current message * - Respond with a public message in the channel * - Respond with an ephemeral message in the channel that only the * acting user will see * * You'll be able to use a response_url five times within 30 minutes. * After that, it's best to move on to new messages and new interactions. */ try { // Prepare settings (participants, points...) const [settingsFetchErr, channelSettings] = await to( TeamStore.fetchSettings(team.id, cmd.channel_id) ); const settings = { [ChannelSettingKey.PARTICIPANTS]: [] as string[], [ChannelSettingKey.POINTS]: DEFAULT_POINTS, [ChannelSettingKey.PROTECTED]: false, [ChannelSettingKey.AVERAGE]: false, [ChannelSettingKey.VOTING_DURATION]: '24h', }; if (channelSettings?.[ChannelSettingKey.PARTICIPANTS]) { settings[ChannelSettingKey.PARTICIPANTS] = channelSettings[ChannelSettingKey.PARTICIPANTS].split(' '); } if (team.custom_points) { settings[ChannelSettingKey.POINTS] = team.custom_points.split(' '); } if (channelSettings?.[ChannelSettingKey.POINTS]) { settings[ChannelSettingKey.POINTS] = splitSpacesExcludeQuotes( channelSettings[ChannelSettingKey.POINTS] ); } if (channelSettings?.[ChannelSettingKey.PROTECTED]) { settings[ChannelSettingKey.PROTECTED] = JSON.parse( channelSettings[ChannelSettingKey.PROTECTED] ); } if (channelSettings?.[ChannelSettingKey.AVERAGE]) { settings[ChannelSettingKey.AVERAGE] = JSON.parse( channelSettings[ChannelSettingKey.AVERAGE] ); } if (channelSettings?.[ChannelSettingKey.VOTING_DURATION]) { settings[ChannelSettingKey.VOTING_DURATION] = channelSettings[ChannelSettingKey.VOTING_DURATION]; } await SessionController.openModal({ triggerId: cmd.trigger_id, team, channelId: cmd.channel_id, title: SessionController.extractTitle(cmd.text), participants: settings[ChannelSettingKey.PARTICIPANTS], points: settings[ChannelSettingKey.POINTS], isProtected: settings[ChannelSettingKey.PROTECTED], calculateAverage: settings[ChannelSettingKey.AVERAGE], votingDuration: settings[ChannelSettingKey.VOTING_DURATION], }); // Send acknowledgement back to API -- HTTP 200 res.send(); } catch (err) { const errorId = generateId(); logger.error({ msg: `Could not open modal`, teamId: team.id, channelId: cmd.channel_id, errorId, err, cmd, }); return res.json({ text: `Could not open modal (error code: ${errorId})\n\n` + `If this problem is persistent, you can open an issue on <${ISSUES_LINK}>`, response_type: 'ephemeral', replace_original: false, }); } } /** * `/pp config ...` */ static async configure(cmd: ISlackCommandRequestBody, res: express.Response) { return res.json({ text: 'This command is deprecated. The session settings (points, participants, ...) ' + 'are now persisted automatically for each channel/conversation.', response_type: 'ephemeral', replace_original: false, }); } /** * `/pp help` */ static help(res: express.Response) { return res.json({ text: ``, response_type: 'ephemeral', replace_original: false, attachments: [ { color: '#3AA3E3', text: '`/pp`\n' + 'Opens a dialog to start a new poker planning session.', }, { color: '#3AA3E3', text: '`/pp some topic text`\n' + 'Opens the same dialog, however title input is automatically ' + 'filled with the value you provided.', }, ], }); } } ================================================ FILE: src/session/isession.ts ================================================ import type { ChatPostMessageResponse } from '@slack/web-api'; export interface ISession { /** * Random generated session id. */ id: string; /** * Voting duration in milliseconds. */ votingDuration: number; /** * The timestamp of vote ending. */ endsAt: number; /** * Title of the session. Mentions are excluded. */ title: string; /** * Slack Team ID. */ teamId: string; /** * Slack Channel ID. */ channelId: string; /** * Slack User ID who starts this session. */ userId: string; /** * Poker point values. */ points: string[]; /** * List of User IDs resolved from used mentions. */ participants: string[]; /** * Votes like { U2147483697: '3', U2147483698: '2' } */ votes: { [key: string]: string }; /** * Session state. */ state: 'active' | 'revealed' | 'cancelled'; /** * The result of `chat.postMessage` that sent by our bot to * the channel/conversation to /pp command used in. */ rawPostMessageResponse: ChatPostMessageResponse; /** * Whether this session is protected, which means only the owner * can cancel and reveal session. */ protected: boolean; /** * Whether to calculate the average from numeric points. */ average: boolean; } ================================================ FILE: src/session/session-controller.ts ================================================ import * as SessionStore from './session-model'; import { ISession } from './isession'; import chunk from 'lodash/chunk'; import map from 'lodash/map'; import groupBy from 'lodash/groupBy'; import { ITeam, TeamStore } from '../team/team-model'; import { WebClient } from '@slack/web-api'; import { logger } from '../lib/logger'; import getUrls from 'get-urls'; import { getOlay } from '../lib/olay'; export const DEFAULT_POINTS = [ '0', '0.5', '1', '2', '3', '5', '8', '13', '20', '40', '100', '∞', '?', ]; export enum SessionControllerErrorCode { NO_PARTICIPANTS = 'no_participants', TITLE_REQUIRED = 'title_required', MAX_TITLE_LIMIT_EXCEEDED = 'max_title_limit_exceeded', UNEXPECTED_PAYLOAD = 'unexpected_payload', INVALID_POINTS = 'invalid_points', SESSION_NOT_ACTIVE = 'session_not_active', ONLY_PARTICIPANTS_CAN_VOTE = 'only_participants_can_vote', INVALID_VOTING_DURATION = 'invalid_voting_duration', } export class SessionController { /** * Sends a message for the provided session. * CAUTION: Participants must resolved before using this method. */ static async postMessage(session: ISession, team: ITeam) { const slackWebClient = new WebClient(team.access_token); return slackWebClient.chat.postMessage({ channel: session.channelId, blocks: buildMessageBlocks(session), text: buildMessageText(session), attachments: buildMessageAttachments(session) as any, }); } static async deleteMessage( team: ITeam, channelId: string, messageTs: string ) { const slackWebClient = new WebClient(team.access_token); return slackWebClient.chat.delete({ ts: messageTs, channel: channelId, }); } /** * Opens a `new session` modal */ static async openModal({ triggerId, team, channelId, title, participants, points, isProtected, calculateAverage, votingDuration, }: { triggerId: string; team: ITeam; channelId: string; title: string; participants: string[]; points: string[]; isProtected: boolean; calculateAverage: boolean; votingDuration: string; }) { const slackWebClient = new WebClient(team.access_token); const protectedCheckboxesOption = { text: { type: 'plain_text', text: 'Protected (prevent others to cancel or reveal this session)', emoji: true, }, value: 'protected', } as any; const averageCheckboxesOption = { text: { type: 'plain_text', text: 'Calculate the average (only numeric points will be used)', emoji: true, }, value: 'average', } as any; let initialOptions = undefined; if (isProtected) { initialOptions = initialOptions || []; initialOptions.push(protectedCheckboxesOption); } if (calculateAverage) { initialOptions = initialOptions || []; initialOptions.push(averageCheckboxesOption); } await slackWebClient.views.open({ trigger_id: triggerId, view: { callback_id: `newSessionModal:submit`, private_metadata: JSON.stringify({ channelId }), type: 'modal', title: { type: 'plain_text', text: 'Poker Planner', emoji: true, }, submit: { type: 'plain_text', text: 'Start New Session(s)', emoji: true, }, close: { type: 'plain_text', text: 'Cancel', emoji: true, }, blocks: [ { type: 'input', block_id: 'title', element: { type: 'plain_text_input', multiline: true, placeholder: { type: 'plain_text', text: 'Write a topic for this voting session', emoji: true, }, initial_value: title || '', }, label: { type: 'plain_text', text: 'Title', emoji: true, }, hint: { type: 'plain_text', text: 'You can bulk-create voting sessions, every line will correspond to a new separate session (up to 10)', emoji: true, }, }, { type: 'input', block_id: 'participants', element: { type: 'multi_users_select', placeholder: { type: 'plain_text', text: 'Add users', emoji: true, }, initial_users: participants, // max_selected_items: 25, }, label: { type: 'plain_text', text: 'Participants', emoji: true, }, }, { type: 'input', block_id: 'points', element: { type: 'plain_text_input', placeholder: { type: 'plain_text', text: 'Change poker points', emoji: true, }, initial_value: points .map((point) => { if (!point.includes(' ')) return point; if (point.includes(`"`)) return `'${point}'`; return `"${point}"`; }) .join(' '), }, label: { type: 'plain_text', text: 'Points', emoji: true, }, hint: { type: 'plain_text', text: 'Enter points separated by space', emoji: true, }, }, { type: 'input', block_id: 'votingDuration', element: { type: 'plain_text_input', placeholder: { type: 'plain_text', text: 'Enter a duration like: 3d 6h 30m', emoji: true, }, initial_value: votingDuration || '', }, hint: { type: 'plain_text', text: 'After voting ends, points will be reveal automatically', emoji: true, }, label: { type: 'plain_text', text: 'Voting ends in', emoji: true, }, }, { type: 'input', block_id: 'other', optional: true, element: { type: 'checkboxes', options: [protectedCheckboxesOption, averageCheckboxesOption], initial_options: initialOptions, }, label: { type: 'plain_text', text: 'Other', emoji: true, }, }, { type: 'section', text: { type: 'mrkdwn', text: '> :bulb: These options will be *remembered* the next time you create a session *on this channel*.', }, }, ], }, }); } /** * Updates the session message as revealing all the votes. * And clean-up the session from store. */ static async revealAndUpdateMessage( session: ISession, team: ITeam, userId: string ) { session.state = 'revealed'; await SessionController.updateMessage(session, team); await SessionStore.remove(session.id); } /** * Updates the session message as cancelled. * And clean-up the session from store. */ static async cancelAndUpdateMessage( session: ISession, team: ITeam, userId: string ) { session.state = 'cancelled'; await SessionController.updateMessage(session, team); await SessionStore.remove(session.id); } /** * */ static async vote( session: ISession, team: ITeam, userId: string, point: string ) { if (session.state != 'active') { throw new Error(SessionControllerErrorCode.SESSION_NOT_ACTIVE); } if (session.participants.indexOf(userId) == -1) { throw new Error(SessionControllerErrorCode.ONLY_PARTICIPANTS_CAN_VOTE); } session.votes[userId] = point; session.state = Object.keys(session.votes).length == session.participants.length ? 'revealed' : 'active'; if (session.state == 'revealed') { await SessionController.updateMessage(session, team); // do not send userId await SessionStore.remove(session.id); logger.info({ msg: `Auto revealing votes, everyone voted`, sessionId: session.id, team: { id: team.id, name: team.name, }, }); getOlay()?.addEvent(session.id, 'autoRevealed', {}); return; } // Voting is still active await SessionController.updateMessage(session, team); SessionStore.upsert(session); } /** * Updates session message according to session state. */ static async updateMessage(session: ISession, team: ITeam) { const slackWebClient = new WebClient(team.access_token); if (session.state == 'revealed') { await slackWebClient.chat.update({ ts: session.rawPostMessageResponse.ts, channel: session.rawPostMessageResponse.channel, blocks: buildMessageBlocks(session), text: buildMessageText(session), attachments: buildMessageAttachments(session) as any, }); } else if (session.state == 'cancelled') { await slackWebClient.chat.update({ ts: session.rawPostMessageResponse.ts, channel: session.rawPostMessageResponse.channel, blocks: buildMessageBlocks(session), text: buildMessageText(session), attachments: buildMessageAttachments(session) as any, }); } else { await slackWebClient.chat.update({ ts: session.rawPostMessageResponse.ts, channel: session.rawPostMessageResponse.channel, blocks: buildMessageBlocks(session), text: buildMessageText(session), attachments: buildMessageAttachments(session) as any, }); } } /** * For given votes, calculate average point */ static getAverage(votes: { [key: string]: string }): string | boolean { const numericPoints = Object.values(votes) .filter(SessionController.isNumeric) .map(parseFloat); if (numericPoints.length < 1) return false; return ( numericPoints.reduce((a, b) => a + b) / numericPoints.length ).toFixed(1); } static isNumeric(n: any) { return !isNaN(parseFloat(n)) && isFinite(n); } /** * When `/pp {message}` is used, Slack auto-formats the {message} part. * How Slack formats can be seen at: https://api.slack.com/reference/surfaces/formatting * * We don't want that. We want: * - Strip user mentions * - Strip usergroup mentions * - Strip channel mentions * - Unformat URL links * * In Slack docs, there is a guide on how to parse formatted messages: * https://api.slack.com/reference/surfaces/formatting#retrieving-messages */ static extractTitle(formattedText: string) { const matches = formattedText.matchAll(/<(.*?)>/g); let title = formattedText; // We're gonna modify the text, in order to get the indexes right, we need to loop in reverse. const matchesReversed = Array.from(matches).reverse(); matchesReversed.forEach((match) => { const startIndex = match.index; const endIndex = startIndex + match[0].length; const innerText = match[1]; // If it starts `#C`, it's a channel link -- let's remove that if (innerText.startsWith('#C')) { title = stringReplaceRange(title, startIndex, endIndex, ''); return; } // If it starts with `@U` or `@W`, it's a user mention -- let's remove that if (innerText.startsWith('@U') || innerText.startsWith('@W')) { title = stringReplaceRange(title, startIndex, endIndex, ''); return; } // If it starts with `!`, it's a user group mention -- let's remove that if (innerText.startsWith('!')) { title = stringReplaceRange(title, startIndex, endIndex, ''); return; } // Now, according to docs (https://api.slack.com/reference/surfaces/formatting#retrieving-messages), // match should be a URL. But it can include `|` (pipe), we don't want after the pipe. const pipeIndex = innerText.indexOf('|'); if (pipeIndex > -1) { title = stringReplaceRange( title, startIndex, endIndex, innerText.substring(0, pipeIndex) ); } else { title = stringReplaceRange(title, startIndex, endIndex, innerText); } }); return title.trim(); } } /** * Set a interval that auto-reveals ended sessions */ let autoRevealEndedSessionsTimeoutId: number = setTimeout( autoRevealEndedSessions, 60000 ) as any; async function autoRevealEndedSessions() { const now = Date.now(); const sessions = SessionStore.getAllSessions(); const endedSessions = Object.values(sessions).filter((session) => { const remainingTTL = session.endsAt - now; return remainingTTL <= 0; }); const tasks = endedSessions.map(async (session) => { try { // If `teamId` doesn't exists in the session, just remove the session like before. // TODO: You can delete here after 7 days of production release if (typeof session.teamId !== 'string') { await SessionStore.remove(session.id); return; } logger.info({ msg: `Auto revealing votes, session ended`, sessionId: session.id, }); const team = await TeamStore.findById(session.teamId); session.state = 'revealed'; await SessionController.updateMessage(session, team); await SessionStore.remove(session.id); getOlay()?.addEvent(session.id, 'votingDurationEnded', {}); } catch (err) { logger.warning({ msg: `Cannot auto-reveal an ended session, removing it...`, sessionId: session.id, err, }); await SessionStore.remove(session.id); } }); await Promise.all(tasks); autoRevealEndedSessionsTimeoutId = setTimeout( autoRevealEndedSessions, 60000 ) as any; } function buildMessageBlocks(session: ISession) { // In slack's `header` block, URLs are not clickable // Extract them and put it as a normal text const urlsInTitle = getUrls(session.title, { requireSchemeOrWww: true }); const urlsText = urlsInTitle.size > 0 ? Array.from(urlsInTitle).join('\n') + '\n\n' : ''; // Remove the URLs from title let title = session.title; urlsInTitle.forEach((url) => { title = title.replaceAll(url, ''); }); // If the title is all URLs, leave it as is if (title.trim().length === 0) { title = session.title; } const requestedBy = `Requested by <@${session.userId}>`; if (session.state === 'active') { const votesText = map(session.participants.sort(), (userId) => { if (session.votes.hasOwnProperty(userId)) { return `<@${userId}>: :white_check_mark:`; } return `<@${userId}>: awaiting`; }).join('\n'); return [ { type: 'header', text: { type: 'plain_text', text: title.substring(0, 150), emoji: true, }, }, { type: 'context', elements: [ { type: 'mrkdwn', text: requestedBy, }, ], }, { type: 'context', elements: [ { type: 'mrkdwn', text: `${urlsText}${votesText}`, }, ], }, ]; } if (session.state === 'revealed') { const voteGroups = groupBy( session.participants, (userId) => session.votes[userId] || 'not-voted' ); const votesText = Object.keys(voteGroups) .sort((a, b) => session.points.indexOf(a) - session.points.indexOf(b)) .map((point) => { const votes = voteGroups[point]; const peopleText = votes.length == 1 ? `1 person` : `${votes.length} people`; const userIds = votes .sort() .map((userId) => `<@${userId}>`) .join(', '); if (point == 'not-voted') { return `${peopleText} *did not vote* (${userIds})`; } return `${peopleText} voted *${point}* (${userIds})`; }) .join('\n'); let averageText = ''; if (session.average) { const average = SessionController.getAverage(session.votes); averageText = average ? `\nAverage: ${SessionController.getAverage(session.votes)}` : ''; } return [ { type: 'header', text: { type: 'plain_text', text: title.substring(0, 150), emoji: true, }, }, { type: 'context', elements: [ { type: 'mrkdwn', text: requestedBy, }, ], }, { type: 'context', elements: [ { type: 'mrkdwn', text: `${urlsText}${votesText}${averageText}`, }, ], }, ]; } if (session.state === 'cancelled') { return [ { type: 'header', text: { type: 'plain_text', text: title.substring(0, 150), emoji: true, }, }, { type: 'context', elements: [ { type: 'mrkdwn', text: requestedBy, }, ], }, { type: 'context', elements: [ { type: 'mrkdwn', text: `${urlsText}Cancelled`, }, ], }, ]; } throw new Error(`Unknown session state: ${session.state}`); } function buildMessageText(session: ISession) { if (session.state === 'active') { const votesText = map(session.participants.sort(), (userId) => { if (session.votes.hasOwnProperty(userId)) { return `<@${userId}>: :white_check_mark:`; } return `<@${userId}>: awaiting`; }).join('\n'); return `Title: *${session.title}*\n\nVotes:\n${votesText}`; } if (session.state === 'revealed') { const voteGroups = groupBy( session.participants, (userId) => session.votes[userId] || 'not-voted' ); const votesText = Object.keys(voteGroups) .sort((a, b) => session.points.indexOf(a) - session.points.indexOf(b)) .map((point) => { const votes = voteGroups[point]; const peopleText = votes.length == 1 ? `1 person` : `${votes.length} people`; const userIds = votes .sort() .map((userId) => `<@${userId}>`) .join(', '); if (point == 'not-voted') { return `${peopleText} *did not vote* (${userIds})`; } return `${peopleText} voted *${point}* (${userIds})`; }) .join('\n'); let averageText = ''; if (session.average) { const average = SessionController.getAverage(session.votes); averageText = average ? `\nAverage: ${SessionController.getAverage(session.votes)}` : ''; } return `Title: *${session.title}*\n\nResult:\n${votesText}${averageText}`; } if (session.state === 'cancelled') { return `Title: *${session.title}* (cancelled)`; } throw new Error(`Unknown session state: ${session.state}`); } function buildMessageAttachments(session: ISession) { if (session.state === 'active') { const pointAttachments = chunk(session.points, 5).map((points) => { return { text: '', fallback: 'You are unable to vote', callback_id: `vote:${session.id}`, color: '#3AA3E3', attachment_type: 'default', actions: points.map((point) => ({ name: 'point', text: point, type: 'button', value: point, })), }; }); return [ ...pointAttachments, { text: 'Actions', fallback: 'You are unable to send action', callback_id: `action:${session.id}`, color: '#3AA3E3', attachment_type: 'default', actions: [ { name: 'action', text: 'Reveal', type: 'button', value: 'reveal', style: 'danger', }, { name: 'action', text: 'Cancel', type: 'button', value: 'cancel', style: 'danger', }, ], }, ]; } // Session is revealed or cancelled // If session is in old structure, noop // TODO: You can delete here after 7 days of production release if (typeof session.votingDuration !== 'number') { return []; } const restartButtonValue = JSON.stringify({ b: 0, // button type, 0 => restart, 1 => delete vd: session.votingDuration, ti: session.title, po: session.points, pa: session.participants, pr: session.protected ? 1 : 0, av: session.average ? 1 : 0, }); return [ { text: '', fallback: 'You are unable to send action', callback_id: `end_action:${session.id}`, color: '#3AA3E3', attachment_type: 'default', actions: [ { name: 'action', text: 'Restart voting', type: 'button', value: restartButtonValue, style: 'default', }, { name: 'action', text: 'Delete message', type: 'button', value: JSON.stringify({ b: 1 }), // button type, 0 => restart, 1 => delete style: 'danger', }, ], }, ]; } /** * Credit: https://stackoverflow.com/a/12568270 */ function stringReplaceRange( s: string, start: number, end: number, substitute: string ) { return s.substring(0, start) + substitute + s.substring(end); } ================================================ FILE: src/session/session-model.ts ================================================ import * as redis from '../lib/redis'; import { ISession } from './isession'; import { logger } from '../lib/logger'; const USE_REDIS = process.env.USE_REDIS?.toLowerCase() === 'true' || false; const REDIS_NAMESPACE = process.env.REDIS_NAMESPACE || 'pp'; /** * Redis key stuff. */ function getRedisKeyMatcher() { return `${REDIS_NAMESPACE}:session:*`; } function buildRedisKey(sessionId: string) { return `${REDIS_NAMESPACE}:session:${sessionId}`; } /** * In memory sessions object. */ let sessions: { [key: string]: ISession } = {}; /** * Simple getter by session id. */ export function findById(id: string): ISession { return sessions[id]; } /** * Restores all the sessions from redis. */ export async function restore(): Promise { if (!USE_REDIS) return; // Scan session keys in redis const client = redis.getSingleton(); for await (const key of client.scanIterator({ MATCH: getRedisKeyMatcher(), })) { const rawSession = await client.get(key); try { const session = JSON.parse(rawSession) as ISession; // Migrate `expiresAt` // TODO: You can delete here after 7 days of production release if ( typeof session.endsAt !== 'number' && typeof (session as any).expiresAt === 'number' ) { session.endsAt = (session as any).expiresAt; delete (session as any).expiresAt; } sessions[session.id] = session; } catch (err) { // NOOP } } logger.info({ msg: 'Sessions restored from redis', count: Object.keys(sessions).length, }); } /** * Holds persisting timeout ids. */ const persistTimeouts: { [key: string]: number } = {}; /** * Updates/inserts the session. This method immediately updates in-memory * database. However if redis is being used, we delay (debounce) persisting * of a session for 1 second. */ export function upsert(session: ISession) { sessions[session.id] = session; // If using redis, debounce persisting if (USE_REDIS) { if (persistTimeouts[session.id]) clearTimeout(persistTimeouts[session.id]); persistTimeouts[session.id] = setTimeout( () => persist(session.id), 1000 ) as any; } } /** * Reads a session from in-memory db, and persists to redis. */ async function persist(sessionId: string) { if (!USE_REDIS) return; // Immediately delete the timeout key delete persistTimeouts[sessionId]; // If specified session is not in in-memory db, // it must be deleted, so NOOP. const session = sessions[sessionId]; if (!session) return; // If specified session is expired, NOOP. // We expect that its redis record is/will-be deleted by its TTL. const remainingTTL = session.endsAt - Date.now(); if (remainingTTL <= 0) return; const client = redis.getSingleton(); try { await client.set(buildRedisKey(session.id), JSON.stringify(session), { PX: remainingTTL, }); } catch (err) { logger.error({ msg: 'Could not persist session', err, session, remainingTTL, }); } } /** * Deletes the session. */ export async function remove(id: string) { delete sessions[id]; if (USE_REDIS) { const client = redis.getSingleton(); await client.del(buildRedisKey(id)); } } export function getAllSessions() { return sessions; } ================================================ FILE: src/team/team-model.ts ================================================ import * as sqlite from '../lib/sqlite'; export interface ITeam { id: string; name: string; access_token: string; scope: string; user_id: string; custom_points: string; } export enum ChannelSettingKey { PARTICIPANTS = 'participants', POINTS = 'points', PROTECTED = 'protected', AVERAGE = 'average', VOTING_DURATION = 'votingDuration', } export interface IChannelSetting { team_id: string; channel_id: string; setting_key: string; setting_value: string; } export class TeamStore { static async findById(id: string): Promise { const db = sqlite.getSingleton(); return db.get('SELECT * FROM team WHERE id = ?', id); } private static async create({ id, name, access_token, scope, user_id, }: Pick) { const db = sqlite.getSingleton(); await db.run( `INSERT INTO team (id, name, access_token, scope, user_id, created_at, updated_at) VALUES ($id, $name, $access_token, $scope, $user_id, $created_at, $updated_at)`, { $id: id, $name: name, $access_token: access_token, $scope: scope, $user_id: user_id, $created_at: Date.now(), $updated_at: Date.now(), } ); } private static async update({ id, name, access_token, scope, user_id, }: Pick) { const db = sqlite.getSingleton(); await db.run( `UPDATE team SET name = $name, access_token = $access_token, scope = $scope, user_id = $user_id, updated_at = $updated_at WHERE id = $id`, { $id: id, $name: name, $access_token: access_token, $scope: scope, $user_id: user_id, $updated_at: Date.now(), } ); } static async upsert({ id, name, access_token, scope, user_id, }: Pick) { const team = await TeamStore.findById(id); if (!team) { await TeamStore.create({ id, name, access_token, scope, user_id }); } else { await TeamStore.update({ id, name, access_token, scope, user_id }); } return TeamStore.findById(id); } static async fetchSettings(teamId: string, channelId: string) { const db = sqlite.getSingleton(); const settingRows = await db.all( `SELECT setting_key, setting_value FROM channel_settings WHERE team_id = $teamId AND channel_id = $channelId;`, { $teamId: teamId, $channelId: channelId, } ); const rv: { [key: string]: string } = {}; settingRows.forEach((row: IChannelSetting) => { rv[row.setting_key] = row.setting_value; }); return rv; } static async upsertSettings( teamId: string, channelId: string, settings: { [key: string]: string } ) { const tasks = Object.keys(settings).map((settingKey) => TeamStore.upsertSetting( teamId, channelId, settingKey, settings[settingKey] ) ); await Promise.all(tasks); } static async upsertSetting( teamId: string, channelId: string, key: string, value: string ) { const db = sqlite.getSingleton(); const now = Date.now(); await db.run( `INSERT INTO channel_settings (team_id, channel_id, setting_key, setting_value, created_at, updated_at) VALUES ( $teamId, $channelId, $settingKey, $settingValue, $createdAt, $updatedAt ) ON CONFLICT(team_id, channel_id, setting_key) DO UPDATE SET setting_value = $settingValue, updated_at = $updatedAt;`, { $teamId: teamId, $channelId: channelId, $settingKey: key, $settingValue: value, $createdAt: now, $updatedAt: now, } ); } } ================================================ FILE: src/vendor/olay-node-client.ts ================================================ import * as ws from 'ws'; ///////////////////////////////////////////// ////// INTERNAL TYPES FOR OLAY TRACKER ////// ///////////////////////////////////////////// export type CustomClientAddEventMessageData = { sessionId: string; type: string; metadata: { [key: string]: unknown }; }; export type CustomClientUpdateMetadataMessageData = { sessionId: string; metadata: { [key: string]: unknown }; }; export enum WebSocketMessageType { WEB_CLIENT_INIT = 'wc_inited', WEB_CLIENT_ADD_EVENT = 'wc_add_event', CUSTOM_CLIENT_UPDATE_METADATA = 'cc_update_metadata', CUSTOM_CLIENT_ADD_EVENT = 'cc_add_event', } export type WebSocketMessage = { type: WebSocketMessageType; data: TData; }; //////////////////////////////////////////////// //////////// RECONNECTING WEBSOCKET //////////// //////////////////////////////////////////////// // I wanna keep all the client-node code in a single file // because I want it portable as much as possible. Because // I don't have any plan to distribute it via npm. enum ReconnectingWebSocketError { NOT_OPEN = 'not-open', } type ReconnectingWebSocketOptions = { url: string; minReconnectionDelay?: number; maxReconnectionDelay?: number; reconnectionDelayGrowFactor?: number; }; class ReconnectingWebSocket { private options: Required; private reconnectionDelay: number; private ws: ws.WebSocket | undefined; // User-side callback functions public onopen: (ev: ws.Event) => any = () => undefined; public onmessage: (ev: ws.MessageEvent) => any = () => undefined; public onerror: (ev: ws.ErrorEvent) => any = () => undefined; public onclose: (ev: ws.CloseEvent) => any = () => undefined; constructor(options: ReconnectingWebSocketOptions) { this.options = { minReconnectionDelay: 250, maxReconnectionDelay: 5000, reconnectionDelayGrowFactor: 1.3, ...options, }; this.reconnectionDelay = this.options.minReconnectionDelay; this.createNewWebSocket(); } private createNewWebSocket() { this.ws = new ws.WebSocket(this.options.url); this.ws.onopen = this.onWebSocketOpen.bind(this); this.ws.onmessage = this.onWebSocketMessage.bind(this); this.ws.onerror = this.onWebSocketError.bind(this); this.ws.onclose = this.onWebSocketClose.bind(this); } private onWebSocketOpen(ev: ws.Event) { this.reconnectionDelay = this.options.minReconnectionDelay; this.onopen(ev); } private onWebSocketMessage(ev: ws.MessageEvent) { this.onmessage(ev); } private onWebSocketClose(ev: ws.CloseEvent) { if (this.ws) { this.ws.onopen = null; this.ws.onmessage = null; this.ws.onerror = null; this.ws.onclose = null; this.ws = undefined; } this.reconnectionDelay = Math.min( this.reconnectionDelay * this.options.reconnectionDelayGrowFactor, this.options.maxReconnectionDelay ); setTimeout(() => this.createNewWebSocket(), this.reconnectionDelay); this.onclose(ev); } private onWebSocketError(ev: ws.ErrorEvent) { this.onerror(ev); } /** * !!! Node `ws` package's `send` method has a callback !!!! * https://github.com/websockets/ws/blob/master/doc/ws.md#websocketsenddata-options-callback * * It's different from browser's WebSocket implementation. */ async send(data: string) { if (!this.ws || this.ws.readyState !== 1) { throw new Error(ReconnectingWebSocketError.NOT_OPEN); } return new Promise((resolve, reject) => { this.ws!.send(data, (err) => { if (err) { reject(err); return; } resolve(); }); }); } } ///////////////////////////////////// //////////// CLIENT-NODE //////////// ///////////////////////////////////// export type NodeClientOptions = { wsRoot: string; project: string; }; export class NodeClient { private rws: ReconnectingWebSocket; private messagesToRetry: WebSocketMessage[] = []; constructor(options: NodeClientOptions) { const wsUrl = new URL(options.wsRoot); wsUrl.pathname = wsUrl.pathname.replace(/\/?$/, '/') + 'ws'; wsUrl.searchParams.set('project', options.project); this.rws = new ReconnectingWebSocket({ url: wsUrl.href }); this.rws.onopen = () => this.onConnected(); } private onConnected() { // Retry sending messages if (this.messagesToRetry.length > 0) { const messagesToRetry = this.messagesToRetry; this.messagesToRetry = []; messagesToRetry.forEach((message) => this.send(message)); } } private send(message: WebSocketMessage) { this.rws.send(JSON.stringify(message)).catch(() => { // TODO: Bi saniye, eger connection kopmadan send patlarsa, bu cocugu bi daha retry etmiyorum? this.messagesToRetry.push(message); if (this.messagesToRetry.length > 500) { this.messagesToRetry.splice(0, this.messagesToRetry.length - 500); } }); } updateMetadata(sessionId: string, metadata: { [key: string]: unknown } = {}) { const data: CustomClientUpdateMetadataMessageData = { sessionId, metadata, }; this.send({ type: WebSocketMessageType.CUSTOM_CLIENT_UPDATE_METADATA, data, }); } addEvent( sessionId: string, type: string, metadata: { [key: string]: unknown } = {} ) { const data: CustomClientAddEventMessageData = { sessionId, type, metadata, }; this.send({ type: WebSocketMessageType.CUSTOM_CLIENT_ADD_EVENT, data, }); } } ================================================ FILE: src/vendor/slack-api-interfaces.ts ================================================ /** * Interface of Slack API command request body * https://api.slack.com/interactivity/slash-commands#app_command_handling */ export interface ISlackCommandRequestBody { /** * This is a verification token, a deprecated feature that you shouldn't * use any more. It was used to verify that requests were legitimately * being sent by Slack to your app, but you should use the signed secrets * functionality to do this instead. */ token: string; /** * These IDs provide context about where the user was in Slack when they * triggered your app's command (eg. which workspace, Enterprise Grid, * or channel). You may need these IDs for your command response. * * The various accompanying *_name values provide you with the plain * text names for these IDs, but as always you should only rely on the * IDs as the names might change arbitrarily. * * We'll include enterprise_id and enterprise_name parameters on command * invocations when the executing workspace is part of an Enterprise Grid. */ team_id: string; team_domain: string; enterprise_id: string; enterprise_name: string; channel_id: string; channel_name: string; /** * The ID of the user who triggered the command. */ user_id: string; /** * The plain text name of the user who triggered the command. As above, * do not rely on this field as it is being phased out, use the * user_id instead. */ user_name: string; /** * The command that was typed in to trigger this request. * This value can be useful if you want to use a single Request URL to * service multiple Slash Commands, as it lets you tell them apart. */ command: string; /** * This is the part of the Slash Command after the command itself, and * it can contain absolutely anything that the user might decide to type. * It is common to use this text parameter to provide extra context for * the command. * * You can prompt users to adhere to a particular format by showing them * in the Usage Hint field when creating a command. * https://api.slack.com/interactivity/slash-commands#creating_commands */ text: string; /** * A temporary webhook URL (https://api.slack.com/messaging/webhooks) * that you can use to generate messages responses. * https://api.slack.com/interactivity/handling#message_responses */ response_url: string; /** * A short-lived ID that will let your app open a modal. * https://api.slack.com/surfaces/modals */ trigger_id: string; } /** * * Sample: * ```json * { * type: 'interactive_message', * actions: [ { name: 'point', type: 'button', value: '3' } ], * callback_id: 'vote:D-9XHLVgF', * team: { id: 'T561GTB96', domain: 'pokerplanner' }, * channel: { id: 'C561GTCJC', name: 'general' }, * user: { id: 'U564FEHQ9', name: 'dgurkaynak' }, * action_ts: '1591362848.574075', * message_ts: '1591362841.009500', * attachment_id: '1', * token: 'G4JhZ8If4pLcedS5QPsyiyNV', * is_app_unfurl: false, * original_message: { * bot_id: 'BTMMWHN8K', * type: 'message', * text: 'Title: *deneme*\n\nVotes:\n<@U564FEHQ9>: awaiting', * user: 'UTP27NNQH', * ts: '1591362841.009500', * team: 'T561GTB96', * bot_profile: { * id: 'BTMMWHN8K', * deleted: false, * name: '[DEV] Poker Planner', * updated: 1581613927, * app_id: 'AC6C6M3N1', * icons: [Object], * team_id: 'T561GTB96' * }, * attachments: [ [Object], [Object], [Object], [Object] ] * }, * response_url: 'https://hooks.slack.com/actions/T561GTB96/1165712662965/5OdlLGqF2prmoARG1IGSZafT', * trigger_id: '1167091649234.176050929312.48865d594224e94e82b1042b37fd24b4' * } * ``` */ export interface IInteractiveMessageActionPayload { type: 'interactive_message'; actions: { name: string; type: string; value: string }[]; callback_id: string; team: { id: string; domain: string }; channel: { id: string; name: string }; user: { id: string; name: string }; action_ts: string; message_ts: string; token: string; response_url: string; trigger_id: string; } /** * Sample: * ```json * { * "type": "view_submission", * "team": { * "id": "T561GTB96", * "domain": "pokerplanner" * }, * "user": { * "id": "U564FEHQ9", * "username": "dgurkaynak", * "name": "dgurkaynak", * "team_id": "T561GTB96" * }, * "api_app_id": "AC6C6M3N1", * "token": "G4JhZ8If4pLcedS5QPsyiyNV", * "trigger_id": "1152407284295.176050929312.b8658e4c92df7fec56da770d32bf3229", * "view": { * "id": "V014XAJ3Q83", * "team_id": "T561GTB96", * "type": "modal", * "blocks": [ * { * "type": "input", * "block_id": "title", * "label": { * "type": "plain_text", * "text": "Session Title", * "emoji": true * }, * "optional": false, * "element": { * "type": "plain_text_input", * "placeholder": { * "type": "plain_text", * "text": "Write a topic for this voting session", * "emoji": true * }, * "initial_value": "deneme", * "action_id": "SzXL" * } * }, * { * "type": "input", * "block_id": "participants", * "label": { * "type": "plain_text", * "text": "Participants", * "emoji": true * }, * "optional": false, * "element": { * "type": "multi_users_select", * "initial_users": [ * "U564FEHQ9", * "U567DPL73", * "U567E53FT" * ], * "placeholder": { * "type": "plain_text", * "text": "Add users", * "emoji": true * }, * "action_id": "7dc" * } * } * ], * "private_metadata": "{\"channelId\":\"C561GTCJC\"}", * "callback_id": "newSessionModal:submit", * "state": { * "values": { * "title": { * "SzXL": { * "type": "plain_text_input", * "value": "deneme" * } * }, * "participants": { * "7dc": { * "type": "multi_users_select", * "selected_users": [ * "U564FEHQ9", * "U567DPL73", * "U567E53FT" * ] * } * } * } * }, * "hash": "1591364280.7b14b51d", * "title": { * "type": "plain_text", * "text": "Poker Planner", * "emoji": true * }, * "clear_on_close": false, * "notify_on_close": false, * "close": { * "type": "plain_text", * "text": "Cancel", * "emoji": true * }, * "submit": { * "type": "plain_text", * "text": "Start New Session", * "emoji": true * }, * "previous_view_id": null, * "root_view_id": "V014XAJ3Q83", * "app_id": "AC6C6M3N1", * "external_id": "", * "app_installed_team_id": "T561GTB96", * "bot_id": "BTMMWHN8K" * }, * "response_urls": [] * } * ``` */ export interface IViewSubmissionActionPayload { type: 'view_submission'; team: { id: string; domain: string }; user: { id: string; name: string }; token: string; trigger_id: string; view: { type: 'modal'; blocks: { type: string; block_id: string; }[]; private_metadata: string; callback_id: string; state: { values: { [key: string]: { [key: string]: | { type: 'plain_text_input'; value: string; } | { type: 'multi_users_select'; selected_users: string[]; } | { type: 'checkboxes'; selected_options?: { value: string }[]; }; }; }; }; }; } ================================================ FILE: src/views/index.html ================================================ Poker Planner for Slack
Poker Planner Slack

Poker Planner for Slack

Add to Slack
Star Issue

What is this?

Poker Planner is a free and open-source project lets you make estimations with planning poker technique (or scrum poker) directly in Slack, without any need of external software. It can be a useful tool for agile remote teams. You can read more about planning poker on wiki. This service is free in goodwill, and hopefully will remain free. You can install the app with "Add to Slack" button above. If you want to host your own app, please check the setup guide on Github.

Features

  • Customizable poker points
  • Persistent settings for each channel/conversation: the app will remember the last settings you use on that channel, including participants and poker points.
  • Auto-reveal after voting ends (min 1 minute, max 7 days)
  • Protected sessions: prevent others to reveal/cancel the sessions you've created.
  • Average point: calculate average point to aid decision making process.

Usage

After successful installation, /pp slash command will be available in your team workspace. It works in:

  • Public channels
  • Private channels
  • Group direct messages
Poker Planner app must be added to channel/conversation before usage. You can add it from channel/conversation details menu, or just simply mention the app, like @poker_planner.

/pp some session title
This command starts a poker planning session with specified title, or simply anything you typed after /pp.

WARNING: Topic text cannot start with "config" and "help" (case-sensitive). They have another functionalities which is described below.

/pp config
This command is deprecated and will be removed in future releases.

/pp help
This commands prints a cheatsheet of usage.

Required Slack Permissions

  • commands - Add actions and/or slash commands that people can use
    • We will add /pp slash command
  • chat:write - Send messages as Poker Planner

FAQ

  • I enter /pp command, but nothing happens?
    In order to open a modal, Slack waits an API call for 3 seconds. After that, Slack does not allow us to open a modal. However, due to operational latencies which can be caused by either Slack's or our infrastructure, it's normal to get this error. Please try again.

Privacy

Apart from that, I get lots of question about privacy, so I want to clarify what the app can and cannot do:
  • The app does not know anything about your channels/conversations until you explicitly add the bot user to it.
  • Even if the bot is added to a channel, the only data that Slack sends to the app is channel id and channel name. Getting additional information (topic, member list, etc.) requires another permission that the app doesn't have.
  • The app cannot read any of your messages, even if the bot is added to that channel/conversation! The only thing the app can read is the poker planning session title you enter while using the app.
  • The app cannot list the users in your team workspace. When a user interacted with the app (create a new session, use a vote, etc.), Slack sends just user id and user name, nothing more.

Support

Since this project is open source, Github Issues is the preferred support channel. However you can directly e-mail me too.

Privacy Policy | Copyright © 2017-2020 Deniz Gurkaynak
================================================ FILE: src/views/oauth-success.html ================================================ Poker Planner for Slack

Poker Planner is successfully
installed to your Slack team
{{@TEAM_NAME}}

Learn How to Use Star on Github
================================================ FILE: src/views/privacy.html ================================================ Privacy Policy | Poker Planner for Slack

Privacy Policy

Effective date: October 1, 2019

Poker Planner ("us", "we", or "our") operates the Slack Poker Planner plugin (the "Service").

This page informs you of our policies regarding the collection, use, and disclosure of personal data when you use our Service and the choices you have associated with that data.

We use your data to provide and improve the Service. By using the Service, you agree to the collection and use of information in accordance with this policy.

Information Collection And Use

We collect several different types of information for various purposes to provide and improve our Service to you.

Types of Data Collected

Slack identifiers
We collect identifiers provided to us by Slack. This includes user and channel identifiers.

Personal Data
In addition to Slack identifiers, we collect user names and channel/conversation names provided to us by Slack.

Usage Data
We collect information how the Service is accessed and used. This data may include information such as the functionality of the Service you use, the time and date of your usage and other diagnostic data.

Log data
The Service records log data. The log data is retained for 90 days and then automatically removed. It may include data provided to the Service by you.

Use of Data

Poker Planner uses the collected data for various purposes:

  • To provide and maintain the Service
  • To notify you about changes to our Service
  • To allow you to participate in interactive features of our Service when you choose to do so
  • To provide customer care and support
  • To provide analysis of information so that we can improve the Service
  • To monitor the usage of the Service
  • To detect, prevent and address technical issues

Transfer Of Data

Your information, including Personal Data, may be transferred to — and maintained on — computers located outside of your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from your jurisdiction.

Your consent to this Privacy Policy followed by your submission of such information represents your agreement to that transfer.

Poker Planner will take all steps reasonably necessary to ensure that your data is treated securely and in accordance with this Privacy Policy and no transfer of your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of your data and other personal information.

Disclosure Of Data

Legal Requirements

Poker Planner may disclose your Personal Data in the good faith belief that such action is necessary to:

  • To comply with a legal obligation
  • To protect and defend the rights or property of Poker Planner
  • To prevent or investigate possible wrongdoing in connection with the Service
  • To protect the personal safety of users of the Service or the public
  • To protect against legal liability

Security Of Data

The security of your data is important to us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While we strive to use commercially acceptable means to protect your Personal Data, we cannot guarantee its absolute security.

Service Providers

We may employ third party companies and individuals to facilitate our Service ("Service Providers"), to provide the Service on our behalf, to perform Service-related services or to assist us in analyzing how our Service is used.

These third parties have access to your Personal Data only to perform these tasks on our behalf and are obligated not to disclose or use it for any other purpose.

Links To Other Sites

Our Service may contain links to other sites that are not operated by us. If you click on a third party link, you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit.

We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.

Changes To This Privacy Policy

We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.

You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.

Contact Us

If you have any questions about this Privacy Policy, please contact us: dgurkaynak@gmail.com

Home | Copyright © 2017-2019 Deniz Gurkaynak
================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "outDir": "./dist/", "sourceMap": true, "noImplicitAny": true, "module": "es6", "target": "ES2021", "jsx": "react", "moduleResolution": "node", "allowSyntheticDefaultImports": true, "experimentalDecorators": true } } ================================================ FILE: webpack.config.js ================================================ const path = require('path'); const nodeExternals = require('webpack-node-externals'); module.exports = { target: 'node', entry: './src/app.ts', devtool: 'source-map', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, ], }, resolve: { extensions: ['.tsx', '.ts', '.js'], }, output: { filename: 'slack-poker-planner.js', path: path.resolve(__dirname, 'dist'), }, externals: [nodeExternals()], };