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 <dgurkaynak@gmail.com>"
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
[](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 <dgurkaynak@gmail.com>",
"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<void> {
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<sqlite3.Database, sqlite3.Statement>;
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<T>(promise: Promise<T>): 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 ' +
"<https://slackhq.com/introducing-a-dramatically-upgraded-slack-app-toolkit|Slack's new app toolkit> " +
'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<void> {
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<ITeam> {
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<ITeam, 'id' | 'name' | 'access_token' | 'scope' | 'user_id'>) {
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<ITeam, 'id' | 'name' | 'access_token' | 'scope' | 'user_id'>) {
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<ITeam, 'id' | 'name' | 'access_token' | 'scope' | 'user_id'>) {
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<TData = { [key: string]: unknown }> = {
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<ReconnectingWebSocketOptions>;
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<void>((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
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Poker Planner for Slack</title>
<meta name="description" content="Free and open-source tool for remote agile teams to estimate with planning poker directly in Slack.">
<meta name="author" content="Deniz Gurkaynak">
<meta name="slack-app-id" content="{{@SLACK_APP_ID}}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="canonical" href="https://deniz.co/slack-poker-planner/">
<link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
<link rel='shortcut icon' type='image/x-icon' href='./favicon.ico' />
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<a href="https://github.com/dgurkaynak/slack-poker-planner" class="github-corner" aria-label="View source on Github"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a><style>.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}</style>
<div class="container">
<div class="row" style="text-align: center; margin-top: 70px; border-bottom: 1px solid #ddd;">
<img src="./logo.png" alt="Poker Planner Slack" style="width: 120px; border-radius: 20px;">
<h3 class="title">Poker Planner for Slack</h3>
<p style="margin: 50px 0;">
<a href="https://slack.com/oauth/v2/authorize?client_id={{@SLACK_CLIENT_ID}}&scope={{@SLACK_SCOPE}}"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
<br/>
<a class="github-button" href="https://github.com/dgurkaynak/slack-poker-planner" data-icon="octicon-star" data-show-count="true" aria-label="Star dgurkaynak/slack-poker-planner on GitHub">Star</a>
<a class="github-button" href="https://github.com/dgurkaynak/slack-poker-planner/issues" data-icon="octicon-issue-opened" data-show-count="true" aria-label="Issue dgurkaynak/slack-poker-planner on GitHub">Issue</a>
</p>
</div>
<div class="row" style="margin-top: 50px; text-align: center;">
<video src="./demo.mp4" muted autoplay loop style="width: 75%;"></video>
</div>
<div class="row" style="margin-top: 50px; border-bottom: 1px solid #ddd; margin-bottom: 20px;">
<h4>What is this?</h4>
<p>
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
<a href="https://en.wikipedia.org/wiki/Planning_poker">planning poker on wiki</a>.
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 <a href="https://github.com/dgurkaynak/slack-poker-planner">the setup guide on Github</a>.
</p>
<h4 id="features">Features</h4>
<p>
<ul>
<li>Customizable poker points</li>
<li>
Persistent settings for each channel/conversation: the app will remember the last settings you
use on that channel, including participants and poker points.
</li>
<li>Auto-reveal after voting ends (min 1 minute, max 7 days)</li>
<li>Protected sessions: prevent others to reveal/cancel the sessions you've created.</li>
<li>Average point: calculate average point to aid decision making process.</li>
</ul>
</p>
<h4 id="usage">Usage</h4>
<p>
After successful installation, <code>/pp</code>
slash command will be available in your team workspace. It works in: <br />
<ul>
<li>Public channels</li>
<li>Private channels</li>
<li>Group direct messages</li>
</ul>
<strong>Poker Planner app must be added to channel/conversation before usage.</strong>
You can add it from channel/conversation details menu, or just simply mention the app,
like <code>@poker_planner</code>.
</p>
<p>
<h5><code>/pp some session title</code></h5>
This command starts a poker planning session with specified title,
or simply anything you typed after <code>/pp</code>. <br /><br />
<strong>WARNING:</strong> Topic text cannot start with "<strong>config</strong>" and
"<strong>help</strong>" (case-sensitive). They have another functionalities
which is described below.
</p>
<p>
<h5><code>/pp config</code></h5>
This command is deprecated and will be removed in future releases.
</p>
<p>
<h5><code>/pp help</code></h5>
This commands prints a cheatsheet of usage.
</p>
<h4 id="slack-permissions">Required Slack Permissions</h4>
<ul>
<li>
<code>commands</code> - Add actions and/or slash commands that people can use
<ul>
<li>
We will add <code>/pp</code> slash command
</li>
</ul>
</li>
<li>
<code>chat:write</code> - Send messages as Poker Planner
</li>
</ul>
<h4 id="faq">FAQ</h4>
<p>
<ul>
<li>
<strong>
I enter <code>/pp</code> command, but nothing happens?
</strong>
<br />
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.
</li>
</ul>
</p>
<h4 id="privacy">Privacy</h4>
<div style="margin-bottom: 10px;">
Apart from that, I get lots of question about privacy,
so I want to clarify what the app can and cannot do:
</div>
<ul>
<li>
The app <strong>does not</strong> know anything about your channels/conversations until
you explicitly add the bot user to it.
</li>
<li>
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.
</li>
<li>
The app <strong>cannot</strong> 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.
</li>
<li>
The app <strong>cannot</strong> 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.
</li>
</ul>
<h4 id="support">Support</h4>
<p>
Since this project is open source,
<a href="https://github.com/dgurkaynak/slack-poker-planner/issues" target="_blank">Github Issues</a>
is the preferred support channel. However you can directly
<a href="mailto:dgurkaynak@gmail.com?Subject=Poker%20Planner%20-%20your%20issue%20here">e-mail me</a> too.
</p>
</div>
<div class="row" style="text-align: center;">
<h6 style="font-size: 1.25rem;">
<a href="./privacy">Privacy Policy</a> |
Copyright © 2017-2020 Deniz Gurkaynak
</h6>
</div>
</div>
<script async defer src="https://buttons.github.io/buttons.js"></script>
<script type='text/javascript'>
var olay = (window.olay = {
epoch: Date.now(),
bufferedEvents: [],
addEvent: function (type, metadata) {
metadata = metadata || {};
var localTime = Date.now() - olay.epoch;
olay.bufferedEvents.push({
localTime: localTime,
type: type,
metadata: metadata,
});
},
});
(function () {
var scriptEl = document.createElement("script");
scriptEl.type = "text/javascript";
scriptEl.async = true;
scriptEl.src = "https://deniz.co/olay/client-web.js?project=slack-poker-planner-web";
var s = document.getElementsByTagName("script")[0];
// @ts-ignore
s.parentNode.insertBefore(scriptEl, s);
})();
</script>
</body>
</html>
================================================
FILE: src/views/oauth-success.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Poker Planner for Slack</title>
<meta name="author" content="Deniz Gurkaynak">
<meta name="slack-app-id" content="{{@SLACK_APP_ID}}">
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
<link rel='shortcut icon' type='image/x-icon' href='./favicon.ico' />
<link rel="stylesheet" href="./styles.css">
<style type="text/css">
svg { width: 120px; height: 120px; }
/* SVG Icon itself */
.st0{fill:#2BB673;}
.st1{fill:none;stroke:#FFFFFF;stroke-width:30;stroke-miterlimit:10;}
</style>
</head>
<body>
<div class="container">
<div class="row" style="text-align: center; margin-top: 70px;">
<svg id="Layer_1" style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path class="st0" d="M489,255.9c0-0.2,0-0.5,0-0.7c0-1.6,0-3.2-0.1-4.7c0-0.9-0.1-1.8-0.1-2.8c0-0.9-0.1-1.8-0.1-2.7 c-0.1-1.1-0.1-2.2-0.2-3.3c0-0.7-0.1-1.4-0.1-2.1c-0.1-1.2-0.2-2.4-0.3-3.6c0-0.5-0.1-1.1-0.1-1.6c-0.1-1.3-0.3-2.6-0.4-4 c0-0.3-0.1-0.7-0.1-1C474.3,113.2,375.7,22.9,256,22.9S37.7,113.2,24.5,229.5c0,0.3-0.1,0.7-0.1,1c-0.1,1.3-0.3,2.6-0.4,4 c-0.1,0.5-0.1,1.1-0.1,1.6c-0.1,1.2-0.2,2.4-0.3,3.6c0,0.7-0.1,1.4-0.1,2.1c-0.1,1.1-0.1,2.2-0.2,3.3c0,0.9-0.1,1.8-0.1,2.7 c0,0.9-0.1,1.8-0.1,2.8c0,1.6-0.1,3.2-0.1,4.7c0,0.2,0,0.5,0,0.7c0,0,0,0,0,0.1s0,0,0,0.1c0,0.2,0,0.5,0,0.7c0,1.6,0,3.2,0.1,4.7 c0,0.9,0.1,1.8,0.1,2.8c0,0.9,0.1,1.8,0.1,2.7c0.1,1.1,0.1,2.2,0.2,3.3c0,0.7,0.1,1.4,0.1,2.1c0.1,1.2,0.2,2.4,0.3,3.6 c0,0.5,0.1,1.1,0.1,1.6c0.1,1.3,0.3,2.6,0.4,4c0,0.3,0.1,0.7,0.1,1C37.7,398.8,136.3,489.1,256,489.1s218.3-90.3,231.5-206.5 c0-0.3,0.1-0.7,0.1-1c0.1-1.3,0.3-2.6,0.4-4c0.1-0.5,0.1-1.1,0.1-1.6c0.1-1.2,0.2-2.4,0.3-3.6c0-0.7,0.1-1.4,0.1-2.1 c0.1-1.1,0.1-2.2,0.2-3.3c0-0.9,0.1-1.8,0.1-2.7c0-0.9,0.1-1.8,0.1-2.8c0-1.6,0.1-3.2,0.1-4.7c0-0.2,0-0.5,0-0.7 C489,256,489,256,489,255.9C489,256,489,256,489,255.9z" id="XMLID_3_"/>
<g id="XMLID_1_">
<line class="st1" id="XMLID_2_" x1="213.6" x2="369.7" y1="344.2" y2="188.2"/>
<line class="st1" id="XMLID_4_" x1="233.8" x2="154.7" y1="345.2" y2="266.1"/>
</g>
</svg>
<h4 class="title">
Poker Planner is successfully<br />
installed to your Slack team<br />
<strong>{{@TEAM_NAME}}</strong>
</h4>
<a class="button" href="./#usage">Learn How to Use</a>
<a class="button" href="https://github.com/dgurkaynak/slack-poker-planner">Star on Github</a>
</div>
</div>
</body>
</html>
================================================
FILE: src/views/privacy.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Privacy Policy | Poker Planner for Slack</title>
<meta name="author" content="Deniz Gurkaynak">
<meta name="slack-app-id" content="{{@SLACK_APP_ID}}">
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
<link rel='shortcut icon' type='image/x-icon' href='./favicon.ico' />
<link rel="stylesheet" href="./styles.css">
<style>
.policy-text h6 { font-weight: bold; }
</style>
</head>
<body>
<a href="https://github.com/dgurkaynak/slack-poker-planner" class="github-corner" aria-label="View source on Github"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a><style>.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}</style>
<div class="container">
<div class="row policy-text" style="margin-top: 40px; border-bottom: 1px solid #ddd; margin-bottom: 20px;">
<h3>Privacy Policy</h3>
<p>Effective date: October 1, 2019</p>
<p>Poker Planner ("us", "we", or "our") operates the Slack Poker Planner plugin (the "Service").</p>
<p>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.</p>
<p>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.</p>
<h4>Information Collection And Use</h4>
<p>We collect several different types of information for various purposes to provide and improve our Service to you.</p>
<h5>Types of Data Collected</h5>
<p>
<h6>Slack identifiers</h6>
We collect identifiers provided to us by Slack.
This includes user and channel identifiers.
</p>
<p>
<h6>Personal Data</h6>
In addition to Slack identifiers, we collect user names
and channel/conversation names provided to us by Slack.
</p>
<p>
<h6>Usage Data</h6>
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.
</p>
<p>
<h6>Log data</h6>
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.
</p>
<h4>Use of Data</h4>
<p>Poker Planner uses the collected data for various purposes:</p>
<ul>
<li>To provide and maintain the Service</li>
<li>To notify you about changes to our Service</li>
<li>To allow you to participate in interactive features of our Service when you choose to do so</li>
<li>To provide customer care and support</li>
<li>To provide analysis of information so that we can improve the Service</li>
<li>To monitor the usage of the Service</li>
<li>To detect, prevent and address technical issues</li>
</ul>
<h4>Transfer Of Data</h4>
<p>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.</p>
<p>Your consent to this Privacy Policy followed by your submission of such information represents your agreement to that transfer.</p>
<p>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.</p>
<h4>Disclosure Of Data</h4>
<h5>Legal Requirements</h5>
<p>Poker Planner may disclose your Personal Data in the good faith belief that such action is necessary to:</p>
<ul>
<li>To comply with a legal obligation</li>
<li>To protect and defend the rights or property of Poker Planner</li>
<li>To prevent or investigate possible wrongdoing in connection with the Service</li>
<li>To protect the personal safety of users of the Service or the public</li>
<li>To protect against legal liability</li>
</ul>
<h4>Security Of Data</h4>
<p>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.</p>
<h4>Service Providers</h4>
<p>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.</p>
<p>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.</p>
<h4>Links To Other Sites</h4>
<p>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.</p>
<p>We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.</p>
<h4>Changes To This Privacy Policy</h4>
<p>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.</p>
<p>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.</p>
<p>
<h4>Contact Us</h4>
If you have any questions about this Privacy Policy, please contact us:
<a href="mailto:dgurkaynak@gmail.com">dgurkaynak@gmail.com</a>
</p>
</div>
<div class="row" style="text-align: center;">
<h6 style="font-size: 1.25rem;">
<a href="./">Home</a> |
Copyright © 2017-2019 Deniz Gurkaynak
</h6>
</div>
</div>
<script async defer src="https://buttons.github.io/buttons.js"></script>
<script type='text/javascript'>
var olay = (window.olay = {
epoch: Date.now(),
bufferedEvents: [],
addEvent: function (type, metadata) {
metadata = metadata || {};
var localTime = Date.now() - olay.epoch;
olay.bufferedEvents.push({
localTime: localTime,
type: type,
metadata: metadata,
});
},
});
(function () {
var scriptEl = document.createElement("script");
scriptEl.type = "text/javascript";
scriptEl.async = true;
scriptEl.src = "https://deniz.co/olay/client-web.js?project=slack-poker-planner-web";
var s = document.getElementsByTagName("script")[0];
// @ts-ignore
s.parentNode.insertBefore(scriptEl, s);
})();
</script>
</body>
</html>
================================================
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()],
};
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
SYMBOL INDEX (113 symbols across 19 files)
FILE: migrations/001-initial-schema.sql
type team (line 4) | CREATE TABLE team (
FILE: migrations/002-custom-points.sql
type team_backup (line 12) | CREATE TEMPORARY TABLE team_backup(id, name, access_token, scope, user_id)
type team (line 15) | CREATE TABLE team (
FILE: migrations/003-channel-settings.sql
type channel_settings (line 4) | CREATE TABLE channel_settings (
type idx_channel_settings (line 12) | CREATE INDEX idx_channel_settings ON channel_settings (team_id, channel_id)
FILE: src/app.ts
constant PORT (line 15) | const PORT = process.env.PORT || 3000;
constant BASE_PATH (line 16) | const BASE_PATH = process.env.BASE_PATH || '/';
constant SLACK_SCOPE (line 17) | const SLACK_SCOPE = 'commands,chat:write';
constant USE_REDIS (line 18) | const USE_REDIS = process.env.USE_REDIS?.toLowerCase() === 'true' || false;
function main (line 20) | async function main() {
function initServer (line 45) | async function initServer(): Promise<void> {
function initRoutes (line 71) | function initRoutes(server: express.Express) {
FILE: src/lib/logger.ts
type Severity (line 5) | type Severity = 'info' | 'warning' | 'error';
type Log (line 7) | type Log = {
class Logger (line 13) | class Logger {
method init (line 16) | public init() {
method info (line 21) | public info(log: Log) {
method warning (line 25) | public warning(log: Log) {
method error (line 29) | public error(log: Log) {
method log (line 33) | private log(log: Log & { level: Severity }) {
function replacer (line 78) | function replacer(_key: string | Symbol, value: unknown) {
FILE: src/lib/olay.ts
function getOlay (line 5) | function getOlay() {
FILE: src/lib/redis.ts
function init (line 6) | async function init() {
function getSingleton (line 24) | function getSingleton() {
FILE: src/lib/sqlite.ts
function init (line 8) | async function init() {
function getSingleton (line 32) | function getSingleton() {
FILE: src/lib/string-match-all.ts
function matchAll (line 1) | function matchAll(str: string, regex: RegExp) {
FILE: src/lib/to.ts
function to (line 6) | async function to<T>(promise: Promise<T>): Promise<[Error, T]> {
FILE: src/routes/interactivity.ts
constant APP_INSTALL_LINK (line 28) | const APP_INSTALL_LINK =
constant ISSUES_LINK (line 30) | const ISSUES_LINK =
constant MAX_VOTING_DURATION (line 33) | const MAX_VOTING_DURATION =
class InteractivityRoute (line 36) | class InteractivityRoute {
method handle (line 42) | static async handle(req: express.Request, res: express.Response) {
method interactiveMessage (line 109) | static async interactiveMessage({
method viewSubmission (line 512) | static async viewSubmission({
method submitNewSessionModal (line 581) | static async submitNewSessionModal({
method vote (line 996) | static async vote({
method revealSession (line 1078) | static async revealSession({
method cancelSession (line 1157) | static async cancelSession({
FILE: src/routes/oauth.ts
constant ISSUES_LINK (line 8) | const ISSUES_LINK =
class OAuthRoute (line 12) | class OAuthRoute {
method handle (line 16) | static async handle(req: express.Request, res: express.Response) {
FILE: src/routes/pp-command.ts
constant APP_INSTALL_LINK (line 14) | const APP_INSTALL_LINK =
constant ISSUES_LINK (line 16) | const ISSUES_LINK =
class PPCommandRoute (line 20) | class PPCommandRoute {
method handle (line 25) | static async handle(req: express.Request, res: express.Response) {
method openNewSessionModal (line 75) | static async openNewSessionModal(
method configure (line 251) | static async configure(cmd: ISlackCommandRequestBody, res: express.Res...
method help (line 264) | static help(res: express.Response) {
FILE: src/session/isession.ts
type ISession (line 3) | interface ISession {
FILE: src/session/session-controller.ts
constant DEFAULT_POINTS (line 12) | const DEFAULT_POINTS = [
type SessionControllerErrorCode (line 28) | enum SessionControllerErrorCode {
class SessionController (line 39) | class SessionController {
method postMessage (line 44) | static async postMessage(session: ISession, team: ITeam) {
method deleteMessage (line 55) | static async deleteMessage(
method openModal (line 71) | static async openModal({
method revealAndUpdateMessage (line 270) | static async revealAndUpdateMessage(
method cancelAndUpdateMessage (line 284) | static async cancelAndUpdateMessage(
method vote (line 297) | static async vote(
method updateMessage (line 343) | static async updateMessage(session: ISession, team: ITeam) {
method getAverage (line 376) | static getAverage(votes: { [key: string]: string }): string | boolean {
method isNumeric (line 386) | static isNumeric(n: any) {
method extractTitle (line 403) | static extractTitle(formattedText: string) {
function autoRevealEndedSessions (line 459) | async function autoRevealEndedSessions() {
function buildMessageBlocks (line 507) | function buildMessageBlocks(session: ISession) {
function buildMessageText (line 662) | function buildMessageText(session: ISession) {
function buildMessageAttachments (line 717) | function buildMessageAttachments(session: ISession) {
function stringReplaceRange (line 811) | function stringReplaceRange(
FILE: src/session/session-model.ts
constant USE_REDIS (line 5) | const USE_REDIS = process.env.USE_REDIS?.toLowerCase() === 'true' || false;
constant REDIS_NAMESPACE (line 6) | const REDIS_NAMESPACE = process.env.REDIS_NAMESPACE || 'pp';
function getRedisKeyMatcher (line 11) | function getRedisKeyMatcher() {
function buildRedisKey (line 15) | function buildRedisKey(sessionId: string) {
function findById (line 27) | function findById(id: string): ISession {
function restore (line 34) | async function restore(): Promise<void> {
function upsert (line 80) | function upsert(session: ISession) {
function persist (line 96) | async function persist(sessionId: string) {
function remove (line 131) | async function remove(id: string) {
function getAllSessions (line 140) | function getAllSessions() {
FILE: src/team/team-model.ts
type ITeam (line 3) | interface ITeam {
type ChannelSettingKey (line 12) | enum ChannelSettingKey {
type IChannelSetting (line 20) | interface IChannelSetting {
class TeamStore (line 27) | class TeamStore {
method findById (line 28) | static async findById(id: string): Promise<ITeam> {
method create (line 33) | private static async create({
method update (line 58) | private static async update({
method upsert (line 88) | static async upsert({
method fetchSettings (line 104) | static async fetchSettings(teamId: string, channelId: string) {
method upsertSettings (line 129) | static async upsertSettings(
method upsertSetting (line 145) | static async upsertSetting(
FILE: src/vendor/olay-node-client.ts
type CustomClientAddEventMessageData (line 6) | type CustomClientAddEventMessageData = {
type CustomClientUpdateMetadataMessageData (line 12) | type CustomClientUpdateMetadataMessageData = {
type WebSocketMessageType (line 17) | enum WebSocketMessageType {
type WebSocketMessage (line 24) | type WebSocketMessage<TData = { [key: string]: unknown }> = {
type ReconnectingWebSocketError (line 36) | enum ReconnectingWebSocketError {
type ReconnectingWebSocketOptions (line 40) | type ReconnectingWebSocketOptions = {
class ReconnectingWebSocket (line 47) | class ReconnectingWebSocket {
method constructor (line 58) | constructor(options: ReconnectingWebSocketOptions) {
method createNewWebSocket (line 71) | private createNewWebSocket() {
method onWebSocketOpen (line 79) | private onWebSocketOpen(ev: ws.Event) {
method onWebSocketMessage (line 84) | private onWebSocketMessage(ev: ws.MessageEvent) {
method onWebSocketClose (line 88) | private onWebSocketClose(ev: ws.CloseEvent) {
method onWebSocketError (line 106) | private onWebSocketError(ev: ws.ErrorEvent) {
method send (line 116) | async send(data: string) {
type NodeClientOptions (line 138) | type NodeClientOptions = {
class NodeClient (line 143) | class NodeClient {
method constructor (line 147) | constructor(options: NodeClientOptions) {
method onConnected (line 156) | private onConnected() {
method send (line 165) | private send(message: WebSocketMessage) {
method updateMetadata (line 176) | updateMetadata(sessionId: string, metadata: { [key: string]: unknown }...
method addEvent (line 187) | addEvent(
FILE: src/vendor/slack-api-interfaces.ts
type ISlackCommandRequestBody (line 5) | interface ISlackCommandRequestBody {
type IInteractiveMessageActionPayload (line 110) | interface IInteractiveMessageActionPayload {
type IViewSubmissionActionPayload (line 243) | interface IViewSubmissionActionPayload {
Condensed preview — 37 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (151K chars).
[
{
"path": ".dockerignore",
"chars": 39,
"preview": "node_modules\nnpm-debug.log\nassets\ndist\n"
},
{
"path": ".editorconfig",
"chars": 131,
"preview": "root = true\n\n[*]\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\nindent_style = space\ninden"
},
{
"path": ".gitignore",
"chars": 34,
"preview": "/node_modules\ndb.sqlite\ndist\n.env\n"
},
{
"path": ".nvmrc",
"chars": 9,
"preview": "v22.11.0\n"
},
{
"path": ".prettierrc",
"chars": 26,
"preview": "{\n \"singleQuote\": true\n}\n"
},
{
"path": "Dockerfile",
"chars": 205,
"preview": "FROM node:18-alpine3.20\n\nLABEL maintainer=\"Deniz Gurkaynak <dgurkaynak@gmail.com>\"\n\nWORKDIR /app\nADD . .\n\nRUN npm ci\nRUN"
},
{
"path": "LICENSE",
"chars": 1072,
"preview": "MIT License\n\nCopyright (c) 2020 Deniz Gurkaynak\n\nPermission is hereby granted, free of charge, to any person obtaining a"
},
{
"path": "README.md",
"chars": 3063,
"preview": "# Poker Planner for Slack\n\nThis project lets you make estimations with planning poker technique (or scrum poker) directl"
},
{
"path": "migrations/001-initial-schema.sql",
"chars": 541,
"preview": "--------------------------------------------------------------------------------\n-- Up\n---------------------------------"
},
{
"path": "migrations/002-custom-points.sql",
"chars": 1081,
"preview": "--------------------------------------------------------------------------------\n-- Up\n---------------------------------"
},
{
"path": "migrations/003-channel-settings.sql",
"chars": 710,
"preview": "--------------------------------------------------------------------------------\n-- Up\n---------------------------------"
},
{
"path": "migrations/004-change-default-point-0.5.sql",
"chars": 683,
"preview": "--------------------------------------------------------------------------------\n-- Up\n---------------------------------"
},
{
"path": "migrations/005-add-dates.sql",
"chars": 744,
"preview": "--------------------------------------------------------------------------------\n-- Up\n---------------------------------"
},
{
"path": "package.json",
"chars": 1650,
"preview": "{\n \"name\": \"slack-poker-planner\",\n \"version\": \"2.1.1\",\n \"description\": \"Poker planning app for slack\",\n \"scripts\": {"
},
{
"path": "src/app.ts",
"chars": 3113,
"preview": "require('dotenv').config();\n\nimport { logger } from './lib/logger';\nimport * as sqlite from './lib/sqlite';\nimport * as "
},
{
"path": "src/lib/logger.ts",
"chars": 2460,
"preview": "import chalk from 'chalk';\nimport isEmpty from 'lodash/isEmpty';\nimport omit from 'lodash/omit';\n\nexport type Severity ="
},
{
"path": "src/lib/olay.ts",
"chars": 379,
"preview": "import { NodeClient } from '../vendor/olay-node-client';\n\nlet olay: NodeClient | undefined;\n\nexport function getOlay() {"
},
{
"path": "src/lib/redis.ts",
"chars": 499,
"preview": "import * as redis from 'redis';\nimport { logger } from './logger';\n\nlet client: redis.RedisClientType;\n\nexport async fun"
},
{
"path": "src/lib/sqlite.ts",
"chars": 795,
"preview": "import * as path from 'path';\nimport * as sqlite3 from 'sqlite3';\nimport { open, Database } from 'sqlite';\nimport { logg"
},
{
"path": "src/lib/string-match-all.ts",
"chars": 287,
"preview": "export function matchAll(str: string, regex: RegExp) {\n const res: string[] = [];\n let m: RegExpExecArray;\n if (regex"
},
{
"path": "src/lib/to.ts",
"chars": 324,
"preview": "/**\n * Inspired by\n * https://medium.com/javascript-in-plain-english/how-to-avoid-try-catch-statements-nesting-chaining-"
},
{
"path": "src/public/styles.css",
"chars": 8000,
"preview": "/*! normalize.css v3.0.2 | MIT License | git.io/normalize */\nhtml{font-family:sans-serif;-ms-text-size-adjust:100%;-webk"
},
{
"path": "src/routes/interactivity.ts",
"chars": 38693,
"preview": "import * as express from 'express';\nimport { logger } from '../lib/logger';\nimport { generate as generateId } from 'shor"
},
{
"path": "src/routes/oauth.ts",
"chars": 2963,
"preview": "import * as express from 'express';\nimport { WebClient } from '@slack/web-api';\nimport { logger } from '../lib/logger';\n"
},
{
"path": "src/routes/pp-command.ts",
"chars": 9345,
"preview": "import * as express from 'express';\nimport { logger } from '../lib/logger';\nimport { TeamStore, ChannelSettingKey } from"
},
{
"path": "src/session/isession.ts",
"chars": 1288,
"preview": "import type { ChatPostMessageResponse } from '@slack/web-api';\n\nexport interface ISession {\n /**\n * Random generated "
},
{
"path": "src/session/session-controller.ts",
"chars": 21989,
"preview": "import * as SessionStore from './session-model';\nimport { ISession } from './isession';\nimport chunk from 'lodash/chunk'"
},
{
"path": "src/session/session-model.ts",
"chars": 3315,
"preview": "import * as redis from '../lib/redis';\nimport { ISession } from './isession';\nimport { logger } from '../lib/logger';\n\nc"
},
{
"path": "src/team/team-model.ts",
"chars": 4066,
"preview": "import * as sqlite from '../lib/sqlite';\n\nexport interface ITeam {\n id: string;\n name: string;\n access_token: string;"
},
{
"path": "src/vendor/olay-node-client.ts",
"chars": 5590,
"preview": "import * as ws from 'ws';\n\n/////////////////////////////////////////////\n////// INTERNAL TYPES FOR OLAY TRACKER //////\n/"
},
{
"path": "src/vendor/slack-api-interfaces.ts",
"chars": 8834,
"preview": "/**\n * Interface of Slack API command request body\n * https://api.slack.com/interactivity/slash-commands#app_command_han"
},
{
"path": "src/views/index.html",
"chars": 9928,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <title>Poker Planner for Slack</title>\n <meta name=\""
},
{
"path": "src/views/oauth-success.html",
"chars": 2803,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <title>Poker Planner for Slack</title>\n <meta name=\""
},
{
"path": "src/views/privacy.html",
"chars": 8813,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <title>Privacy Policy | Poker Planner for Slack</titl"
},
{
"path": "tsconfig.json",
"chars": 280,
"preview": "{\n \"compilerOptions\": {\n \"outDir\": \"./dist/\",\n \"sourceMap\": true,\n \"noImplicitAny\": true,\n \"module\": \"es6\","
},
{
"path": "webpack.config.js",
"chars": 506,
"preview": "const path = require('path');\nconst nodeExternals = require('webpack-node-externals');\n\nmodule.exports = {\n target: 'no"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the dgurkaynak/slack-poker-planner GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 37 files (140.9 KB), approximately 37.1k tokens, and a symbol index with 113 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.