├── .beepboop ├── inorout-bg.png ├── inorout-icon-trans.png ├── inorout-icon.png ├── inorout-long.gif ├── inorout-simple.gif ├── main.md ├── privacy.md ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png └── support.md ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── bot.yml ├── package.json ├── server.js └── src ├── chronos.js ├── config.js ├── flows ├── chatter.js ├── help.js ├── index.js └── whoisin.js └── poll.js /.beepboop/inorout-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepBoopHQ/in-or-out/0835195f3cd732aec72ea9cb2f4d2d1adb04f0b8/.beepboop/inorout-bg.png -------------------------------------------------------------------------------- /.beepboop/inorout-icon-trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepBoopHQ/in-or-out/0835195f3cd732aec72ea9cb2f4d2d1adb04f0b8/.beepboop/inorout-icon-trans.png -------------------------------------------------------------------------------- /.beepboop/inorout-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepBoopHQ/in-or-out/0835195f3cd732aec72ea9cb2f4d2d1adb04f0b8/.beepboop/inorout-icon.png -------------------------------------------------------------------------------- /.beepboop/inorout-long.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepBoopHQ/in-or-out/0835195f3cd732aec72ea9cb2f4d2d1adb04f0b8/.beepboop/inorout-long.gif -------------------------------------------------------------------------------- /.beepboop/inorout-simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepBoopHQ/in-or-out/0835195f3cd732aec72ea9cb2f4d2d1adb04f0b8/.beepboop/inorout-simple.gif -------------------------------------------------------------------------------- /.beepboop/main.md: -------------------------------------------------------------------------------- 1 | ## Easy Polling of your Teammates 2 | 3 | *In or Out* allows you to ask simple questions of your teammates right in Slack. Ask questions like: 4 | 5 | + Who is coming into the office? 6 | + Who wants lunch? 7 | + Should we meet today or tomorrow? 8 | + How are you feeling today? 9 | 10 | ### Key Features 11 | 12 | + Vote with buttons 13 | + Track each persons vote 14 | + See who hasn't voted yet 15 | + Schedule recurring or one-time questions 16 | 17 | ![gif](https://storage.googleapis.com/beepboophq/InOrOutPan.gif) 18 | -------------------------------------------------------------------------------- /.beepboop/privacy.md: -------------------------------------------------------------------------------- 1 | # Terms & Conditions + Privacy Policy 2 | 3 | 4 | ## Privacy Policy 5 | 6 | 7 | The application does not collect or send any kind of personally identifiable information, except 8 | for information required for connecting to Slack API services, the information retrieved from RSS 9 | feed, contact information for teams who have installed the application, and information about 10 | slash commands and requests sent explicitly by users to the application. 11 | 12 | 13 | The application may collect data related to actions performed within the application (such as 14 | chat interactions with the application, navigating between different screens, or clicking any 15 | of the buttons). This information will be aggregated and used for analyzing the common usage 16 | patterns of the product, in order to improve the application. 17 | 18 | 19 | 20 | ## Warranty & Limitation of Liability disclaimers 21 | 22 | THE APPLICATION DEVELOPER PROVIDES THE SOFTWARE AND THE SERVICES "AS IS" WITHOUT WARRANTY OF ANY KIND 23 | EITHER EXPRESS, IMPLIED OR STATUTORY, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY 24 | AND FITNESS FOR A PARTICULAR PURPOSE. ALL RISK OF QUALITY AND PERFORMANCE OF THE SOFTWARE OR SERVICES 25 | REMAINS WITH YOU. BY INSTALLING OR USING THIS APPLICATION, YOU AGREE TO THE TERMS PROVIDED IN THIS 26 | DISCLAIMER AND THE FOLLOWING LIMITATION OF LIABILITY DISCLAIMER. 27 | 28 | IN NO EVENT WILL THE APPLICATION DEVELOPER, ITS EMPLOYEES, DISTRIBUTORS, DIRECTORS OR AGENTS BE LIABLE FOR 29 | ANY INDIRECT DAMAGES OR OTHER RELIEF ARISING OUT OF YOUR USE OR INABILITY TO USE THE SOFTWARE OR SERVICES 30 | INCLUDING, BY WAY OF ILLUSTRATION AND NOT LIMITATION, LOST PROFITS, LOST BUSINESS OR LOST OPPORTUNITY, 31 | OR ANY INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL OR EXEMPLARY DAMAGES, INCLUDING LEGAL FEES, ARISING 32 | OUT OF SUCH USE OR INABILITY TO USE THE PROGRAM, EVEN IF THE APPLICATION DEVELOPER HAS BEEN ADVISED OF THE 33 | POSSIBILITY OF SUCH DAMAGES, OR FOR ANY CLAIM BY ANY OTHER PARTY. BECAUSE SOME STATES OR JURISDICTIONS DO 34 | NOT ALLOW THE EXCLUSION OR THE LIMITATION OF LIABILITY FOR CONSEQUENTIAL OR INCIDENTAL DAMAGES, IN SUCH 35 | STATES OR JURISDICTIONS, THE APPLICATION DEVELOPER'S LIABILITY SHALL BE LIMITED TO THE EXTENT PERMITTED BY 36 | LAW. 37 | 38 | ## Changes to our Terms & Conditions / Privacy Policy 39 | 40 | Any future changes to this document will be posted on this page. 41 | -------------------------------------------------------------------------------- /.beepboop/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepBoopHQ/in-or-out/0835195f3cd732aec72ea9cb2f4d2d1adb04f0b8/.beepboop/screenshot1.png -------------------------------------------------------------------------------- /.beepboop/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepBoopHQ/in-or-out/0835195f3cd732aec72ea9cb2f4d2d1adb04f0b8/.beepboop/screenshot2.png -------------------------------------------------------------------------------- /.beepboop/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepBoopHQ/in-or-out/0835195f3cd732aec72ea9cb2f4d2d1adb04f0b8/.beepboop/screenshot3.png -------------------------------------------------------------------------------- /.beepboop/support.md: -------------------------------------------------------------------------------- 1 | 2 | ## Installing In or Out 3 | 4 | 1) Go to this page -https://beepboophq.com/bots/90644ec769634755806c2f06c67b5b86 5 | 6 | 2) Click on "Add to Slack" 7 | 8 | 3) You're done! 9 | 10 | ## Working with In or Out 11 | 12 | 1) Go to a channel where you want to ask a question. 13 | 14 | 2) Ask question with the `/inorout` Slash command: 15 | 16 | ``` 17 | /inorout [type your question here] 18 | [answer 1] 19 | [answer 2] 20 | [...] 21 | ``` 22 | 23 | Up to 15 answers may go on following lines (shift-enter or ctrl-enter or return on mobile). 24 | 25 | For example: 26 | 27 | ``` 28 | /inorout What time should we meet? 29 | 10:30AM PST 30 | 2:00PM PST 31 | :no_entry: never 32 | ``` 33 | 34 | 3) Anyone can vote on each options and the results will be tallied on the message! Click the "move to the bottom" button to send the message to the bottom of the stream. 35 | 36 | ## Support 37 | 38 | Email hello@beepboophq.com with any support or feedback. 39 | 40 | Thanks! 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | out.jsonl 4 | setup.txt 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:6.2-onbuild 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeepBoopHQ/in-or-out/0835195f3cd732aec72ea9cb2f4d2d1adb04f0b8/LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | In or Out? 2 | ---------- 3 | 4 | [![Sponsored by Beep Boop](https://img.shields.io/badge/%E2%9D%A4%EF%B8%8F_sponsored_by-%E2%9C%A8_Beep_Boop_%E2%9C%A8-FB6CBE.svg)](https://beepboophq.com) 5 | 6 | Are your team members in or are they out? Now you can ask them simply in Slack and track the responses. 7 | This is a [Slapp](https://github.com/BeepBoopHQ/slapp) example showing how to create a Slack app using the Events API, Slash Commands and Interactive Messages. 8 | 9 | ![In or Out](https://beepboophq.storage.googleapis.com/ecf859cec6830b414c7cab411b80168702373f5a1d44bed41c49c14c76b704c9) 10 | 11 | Try it yourself! 12 | 13 | Add to Slack 14 | 15 | 16 | # License 17 | MIT Copyright (c) 2016 Beep Boop, Robots & Pencils 18 | -------------------------------------------------------------------------------- /bot.yml: -------------------------------------------------------------------------------- 1 | name: In or Out 2 | description: 3 | email: hello@beepboophq.com 4 | avatar: .beepboop/inorout-icon.png 5 | background: .beepboop/inorout-bg.png 6 | screenshots: 7 | - screenshot: .beepboop/screenshot1.png 8 | - screenshot: .beepboop/screenshot2.png 9 | - screenshot: .beepboop/screenshot3.png 10 | slackscopes: 11 | - bot 12 | - commands 13 | - reactions:write 14 | health-check: 15 | protocol: http 16 | path: /healthz -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inout", 3 | "version": "0.0.1", 4 | "description": "Slackbot for tracking who is in and who is out", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Mike Brevoort", 10 | "license": "MIT", 11 | "dependencies": { 12 | "beepboop-persist": "^0.2.1", 13 | "express": "^4.14.0", 14 | "moment": "^2.17.1", 15 | "moment-timezone": "^0.5.11", 16 | "needle": "^1.4.3", 17 | "slack-message-builder": "1.1.0", 18 | "slapp": "2.4.1", 19 | "slapp-context-beepboop": "1.4.0", 20 | "slapp-convo-beepboop": "^1.0.1", 21 | "uuid": "^3.0.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const express = require('express') 3 | const Slapp = require('slapp') 4 | const BeepBoopConvoStore = require('slapp-convo-beepboop') 5 | const BeepBoopContext = require('slapp-context-beepboop') 6 | const BeepBoopPersist = require('beepboop-persist') 7 | const Chronos = require('./src/chronos') 8 | const config = require('./src/config').validate() 9 | 10 | var slapp = Slapp({ 11 | verify_token: config.slack_verify_token, 12 | log: config.slapp_log, 13 | colors: config.slapp_colors, 14 | record: 'out.jsonl', 15 | convo_store: BeepBoopConvoStore(), 16 | context: BeepBoopContext() 17 | }) 18 | 19 | var server = slapp.attachToExpress(express()) 20 | 21 | var app = { 22 | slapp, 23 | server, 24 | kv: BeepBoopPersist({ provider: config.persist_provider }), 25 | chronos: Chronos({ 26 | beepboop_token: config.beepboop_token, 27 | beepboop_project_id: config.beepboop_project_id 28 | }) 29 | } 30 | 31 | require('./src/flows')(app) 32 | server.get('/', function (req, res) { 33 | res.send('Hello') 34 | }) 35 | 36 | server.get('/healthz', function (req, res) { 37 | res.send({ version: process.env.VERSION, id: process.env.BEEPBOOP_ID }) 38 | }) 39 | 40 | console.log('Listening on :' + config.port) 41 | server.listen(config.port) 42 | -------------------------------------------------------------------------------- /src/chronos.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const needle = require('needle') 3 | 4 | module.exports = (config) => { 5 | return new Chronos(config) 6 | } 7 | 8 | class Chronos { 9 | constructor (config) { 10 | if (!config.beepboop_token) throw new Error('beepboop_token required') 11 | if (!config.beepboop_project_id) throw new Error('beepboop_project_id required') 12 | this.token = config.beepboop_token 13 | this.project_id = config.beepboop_project_id 14 | this.base = config.base || 'https://beepboophq.com/api/v1/chronos' 15 | } 16 | 17 | list (callback) { 18 | this._get(`${this.base}/tasks`, callback) 19 | } 20 | 21 | active (callback) { 22 | this._get(`${this.base}/tasks?inactive=false`, callback) 23 | } 24 | 25 | inactive (callback) { 26 | this._get(`${this.base}/tasks?inactive=true`, callback) 27 | } 28 | 29 | create (data, callback) { 30 | needle.post(`${this.base}/tasks`, data, this._baseOptions(), (err, resp) => { 31 | if (err) return callback(err) 32 | if (resp.statusCode !== 201) { 33 | return callback(new Error(`unsuccesful status code ${resp.statusCode}`)) 34 | } 35 | callback(null, resp.body) 36 | }) 37 | } 38 | 39 | scheduleSyntheticEvent (msg, cron, type, payload, callback) { 40 | let ts = Date.now() + '' 41 | 42 | this.create({ 43 | schedule: cron, 44 | url: `https://beepboophq.com/proxy/${this.project_id}/slack/event`, 45 | method: 'POST', 46 | headers: { 47 | 'BB-Enrich': `slack_team_id=${msg.meta.team_id}` 48 | }, 49 | payload: { 50 | token: msg.body.token, 51 | team_id: msg.meta.team_id, 52 | type: 'event_callback', 53 | event: { 54 | ts: ts, 55 | event_ts: ts, 56 | type: type, 57 | payload: payload, 58 | user: msg.meta.user_id, 59 | channel: msg.meta.channel_id 60 | } 61 | } 62 | }, callback) 63 | } 64 | 65 | delete (id, callback) { 66 | console.log(`${this.base}/tasks/${id} - ${this.token}`) 67 | needle.delete(`${this.base}/tasks/${id}`, null, this._baseOptions(), (err, resp) => { 68 | if (err) return callback(err) 69 | if (resp.statusCode !== 200) { 70 | return callback(new Error(`unsuccesful status code ${resp.statusCode}`)) 71 | } 72 | callback(null, resp.body) 73 | }) 74 | } 75 | 76 | _get (url, callback) { 77 | needle.get(url, this._baseOptions(), (err, resp) => { 78 | if (err) return callback(err) 79 | if (resp.statusCode !== 200) { 80 | return callback(new Error(`unsuccesful status code ${resp.statusCode}`)) 81 | } 82 | callback(null, resp.body) 83 | }) 84 | } 85 | 86 | _baseOptions () { 87 | return { 88 | headers: { 89 | Authorization: `Bearer ${this.token}` 90 | }, 91 | json: true 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let baseUrl = process.env.BASE_URL || `https://beepboophq.com/proxy/${process.env.BEEPBOOP_PROJECT_ID}` 4 | let config = module.exports = { 5 | // HTTP port 6 | port: process.env.PORT || 4000, 7 | 8 | // External base URL 9 | base_url: baseUrl, 10 | 11 | // Slapp config 12 | debug: !!process.env.DEBUG, 13 | slapp_colors: true, 14 | slapp_log: true, 15 | slack_verify_token: process.env.SLACK_VERIFY_TOKEN, 16 | 17 | // Beep Boop Persist API provider (beepboop, fs, memory) 18 | persist_provider: process.env.PERSIST_PROVIDER || 'beepboop', 19 | 20 | // Beep Boop Project Id and Token for Chronos API 21 | beepboop_project_id: process.env.BEEPBOOP_PROJECT_ID, 22 | beepboop_token: process.env.BEEPBOOP_TOKEN, 23 | 24 | validate: () => { 25 | let required = ['beepboop_token'] 26 | 27 | required.forEach((prop) => { 28 | if (!config[prop]) { 29 | throw new Error(`${prop.toUpperCase()} required but missing`) 30 | } 31 | }) 32 | return config 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/flows/chatter.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const handleHowAreYou = 'chatter:handleHowAreYou' 4 | 5 | module.exports = (app) => { 6 | let slapp = app.slapp 7 | 8 | slapp.message('^(hi|hello|hey)$', ['direct_mention', 'direct_message'], (msg, text) => { 9 | msg 10 | .say(text + ', how are you?') 11 | .route(handleHowAreYou, {}, 60) 12 | }) 13 | 14 | slapp.route(handleHowAreYou, (msg) => { 15 | var resp = msg.body.event && msg.body.event.text 16 | 17 | if (new RegExp('good', 'i').test(resp)) { 18 | msg 19 | .say(['Great! Ready?', ':smile: Are you sure?']) 20 | .route(handleHowAreYou, 60) 21 | } else { 22 | msg.say('Me too') 23 | } 24 | }) 25 | 26 | slapp.message('^(thanks|thank you)', ['mention', 'direct_message'], (msg) => { 27 | msg.say(['You are welcome', 'Of course']) 28 | }) 29 | 30 | slapp.message('good night|bye', ['mention', 'direct_message'], (msg) => { 31 | msg.say(['Cheers :beers:', 'Bye', 'Goodbye', 'Adios']) 32 | }) 33 | 34 | slapp.message('.*', ['direct_mention', 'direct_message'], (msg) => { 35 | // respond only 40% of the time 36 | if (Math.random() < 0.4) { 37 | msg.say([':wave:', ':pray:', ':raised_hands:']) 38 | } 39 | }) 40 | 41 | return {} 42 | } 43 | -------------------------------------------------------------------------------- /src/flows/help.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = (app) => { 4 | let slapp = app.slapp 5 | 6 | let help = `In or Out is pretty simple. Ask question with the \`/inorout\` command: 7 | \`\`\` 8 | /inorout [type your question here] 9 | [answer 1] 10 | [answer 2] 11 | [...] 12 | \`\`\` 13 | 14 | Up to 15 answers may go on following lines (shift-enter or ctrl-enter or return on mobile). 15 | 16 | For example: 17 | 18 | \`\`\` 19 | /inorout What time should we meet? 20 | 10:30AM PST 21 | 2:00PM PST 22 | :no_entry: never 23 | \`\`\` 24 | 25 | Choose a button option and results are aggregated under the question. 26 | 27 | ":arrow_heading_down: move to bottom" moves the question down to the bottom of the stream. 28 | 29 | Like this! https://goo.gl/ucnthN 30 | ` 31 | 32 | slapp.command('/inorout', /^\s*help\s*$/, (msg) => { 33 | msg.respond(help) 34 | }) 35 | 36 | slapp.message('help', ['direct_mention', 'direct_message'], (msg, text) => { 37 | msg.say(help) 38 | }) 39 | 40 | slapp.event('bb.team_added', function (msg) { 41 | slapp.client.im.open({ token: msg.meta.bot_token, user: msg.meta.user_id }, (err, data) => { 42 | if (err) { 43 | return console.error(err) 44 | } 45 | let channel = data.channel.id 46 | 47 | msg.say({ channel: channel, text: 'Thanks for adding me to your team!' }) 48 | msg.say({ channel: channel, text: help }) 49 | }) 50 | }) 51 | 52 | return {} 53 | } 54 | -------------------------------------------------------------------------------- /src/flows/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // list out explicitly to control order 4 | module.exports = (app) => { 5 | app.flows = { 6 | help: require('./help')(app), 7 | whoisin: require('./whoisin')(app), 8 | chatter: require('./chatter')(app) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/flows/whoisin.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const os = require('os') 3 | const Poll = require('../poll') 4 | const smb = require('slack-message-builder') 5 | const moment = require('moment') 6 | 7 | module.exports = (app) => { 8 | let slapp = app.slapp 9 | let kv = app.kv 10 | let chronos = app.chronos 11 | 12 | slapp.command('/inorout', /.*/, (msg) => { 13 | var lines = msg.body.text.split(os.EOL).map((it) => { return it.trim() }) 14 | 15 | let poll = null 16 | if (!lines[0]) { 17 | poll = Poll.createDefault() 18 | } else if (lines.length == 1) { 19 | poll = Poll.createDefault() 20 | poll.question = lines[0] 21 | } else { 22 | poll = Poll.create() 23 | poll.question = lines[0] 24 | } 25 | poll.generateId(msg.meta.team_id) 26 | poll.draft = true 27 | poll.team_id = msg.meta.team_id 28 | poll.channel_id = msg.meta.channel_id 29 | poll.author_id = msg.meta.user_id 30 | 31 | 32 | // max 15 answers (3 for buttons, 1 for move to bottom, 15 for each answer) 33 | if (lines.length > 16) { 34 | msg.respond(`:sob: Sorry, you may only enter 15 options. Here is what you entered:\n\n/inorout ${msg.body.text}`) 35 | return 36 | } 37 | 38 | if (lines.length > 1) { 39 | for (var i = 1; i < lines.length; i++) { 40 | var answer = lines[i] 41 | poll.addAnswer(answer) 42 | } 43 | } 44 | 45 | // only show hasn't answered for channels (kinda hackish :/ ) 46 | if (msg.meta.channel_id[0] === 'C') { 47 | poll.enableUnaccounted = true 48 | } 49 | 50 | slapp.client.users.info({ token: msg.meta.bot_token, user: msg.meta.user_id }, (err, data) => { 51 | if (err) return msg.respond(`Sorry, something went wrong. Try again? (${err.message || err})`) 52 | 53 | // add author information to first attachment 54 | poll.author_name = data.user.profile.real_name || data.user.name 55 | poll.author_icon = data.user.profile.image_24 56 | poll.tz = data.user.tz 57 | poll.tz_offset = data.user.tz_offset 58 | poll.tz_label = data.user.tz_label 59 | 60 | kv.set(poll.id, poll, (err) => { 61 | msg.respond(poll.renderDraft().json()) 62 | }) 63 | }) 64 | }) 65 | 66 | slapp.event('scheduled_publish', (msg) => { 67 | let poll = Poll.create(msg.body.event.payload) 68 | poll.parent_id = poll.id 69 | poll.generateId() 70 | 71 | // get the parent poll to find the chronos task ID 72 | kv.get(poll.parent_id, (err, parentPoll) => { 73 | if (err) return handleError(err, msg) 74 | poll.chronos_id = parentPoll.chronos_id 75 | 76 | kv.set(poll.id, poll, (err) => { 77 | if (err) return handleError(err, msg) 78 | msg.say(poll.render().json()) 79 | }) 80 | }) 81 | }) 82 | 83 | slapp.action('in_or_out_callback', 'menu', (msg, data) => { 84 | if (Array.isArray(data)) { 85 | data = data[0] 86 | } 87 | let action = parseData(data).action 88 | switch (action) { 89 | case 'recycle': 90 | menuRecycle(msg, data) 91 | break 92 | case 'dismiss': 93 | menuDismiss(msg, data) 94 | break 95 | case 'unaccounted': 96 | menuUnaccounted(msg, data) 97 | break 98 | case 'cancel_published_schedule': 99 | cancelPublishedSchedule(msg, data) 100 | break 101 | } 102 | }) 103 | 104 | // backward compatible pre-menu 105 | slapp.action('in_or_out_callback', 'recycle', menuRecycle) 106 | slapp.action('in_or_out_callback', 'dismiss', menuDismiss) 107 | slapp.action('in_or_out_callback', 'unaccounted', menuUnaccounted) 108 | slapp.action('in_or_out_callback', 'cancel_published_schedule', cancelPublishedSchedule) 109 | 110 | 111 | slapp.action('in_or_out_callback', 'confirm_publish', (msg, data) => { 112 | getPollFromAction(msg, data, (err, poll, data) => { 113 | if (err) return handleError(err, msg) 114 | poll.draft = false 115 | 116 | // publish now 117 | if (!poll.draft_schedule.time) { 118 | msg.respond({ delete_original: true }) 119 | msg.say(poll.render().json(), (err, result) => { 120 | if (err) return handleError(err, msg) 121 | poll.ts = msg.ts 122 | poll.channel_id = msg.channel 123 | 124 | kv.set(poll.id, poll, (err) => { 125 | if (err) return handleError(err, msg) 126 | }) 127 | }) 128 | return 129 | } 130 | 131 | // scheduled 132 | chronos.scheduleSyntheticEvent(msg, poll.formatChronosSchedule(), 'scheduled_publish', poll, (err, task) => { 133 | if (err) return handleError(err, msg) 134 | 135 | // store task mapping 136 | poll.chronos_id = task.id 137 | kv.set(poll.id, poll, (err) => { 138 | if (err) return handleError(err, msg) 139 | msg.respond({ delete_original: true }) 140 | msg.say(poll.renderScheduledConfirmation().json(), (err, result) => { 141 | if (err) return handleError(err, msg) 142 | }) 143 | }) 144 | }) 145 | }) 146 | }) 147 | 148 | slapp.action('in_or_out_callback', 'cancel_publish', (msg, data) => { 149 | data = parseData(data) 150 | kv.del(data.id, (err) => { 151 | if (err) return handleError(err, msg) 152 | msg.respond({ delete_original: true }) 153 | }) 154 | }) 155 | 156 | function cancelPublishedSchedule (msg, data) { 157 | getPollFromAction(msg, data, (err, poll, data) => { 158 | if (err) return handleError(err, msg) 159 | chronos.delete(poll.chronos_id, (err) => { 160 | if (err) return handleError(err, msg) 161 | poll.chronos_id = null 162 | poll.chronos_cancelled = true 163 | kv.set(poll.id, poll, (err) => { 164 | if (err) return handleError(err, msg) 165 | if (data.discard) { 166 | msg.respond({ delete_original: true }) 167 | } else { 168 | msg.respond(poll.render().json()) 169 | } 170 | }) 171 | }) 172 | }) 173 | } 174 | 175 | slapp.action('in_or_out_callback', 'draft_schedule', (msg, data) => { 176 | getPollFromAction(msg, data, (err, poll, data) => { 177 | if (err) return handleError(err, msg) 178 | if (!poll.draft_schedule.time) { 179 | poll.draft_schedule.time = Date.now() + (60000) 180 | kv.set(poll.id, poll, (err) => { 181 | if (err) return handleError(err, msg) 182 | msg.respond(poll.renderScheduling().json()) 183 | }) 184 | } else { 185 | msg.respond(poll.renderScheduling().json()) 186 | } 187 | }) 188 | }) 189 | 190 | slapp.action('in_or_out_callback', 'schedule_day_sub', (msg, data) => { 191 | getPollFromAction(msg, data, (err, poll, data) => { 192 | if (err) return handleError(err, msg) 193 | poll.subtractDay(1) 194 | kv.set(poll.id, poll, (err) => { 195 | if (err) return handleError(err, msg) 196 | msg.respond(poll.renderScheduling().json()) 197 | }) 198 | }) 199 | }) 200 | 201 | slapp.action('in_or_out_callback', 'schedule_day_add', (msg, data) => { 202 | getPollFromAction(msg, data, (err, poll, data) => { 203 | if (err) return handleError(err, msg) 204 | poll.addDay(1) 205 | kv.set(poll.id, poll, (err) => { 206 | if (err) return handleError(err, msg) 207 | msg.respond(poll.renderScheduling().json()) 208 | }) 209 | }) 210 | }) 211 | 212 | slapp.action('in_or_out_callback', 'schedule_hour_add', (msg, data) => { 213 | getPollFromAction(msg, data, (err, poll, data) => { 214 | if (err) return handleError(err, msg) 215 | poll.addHour(1) 216 | kv.set(poll.id, poll, (err) => { 217 | if (err) return handleError(err, msg) 218 | msg.respond(poll.renderScheduling().json()) 219 | }) 220 | }) 221 | }) 222 | 223 | slapp.action('in_or_out_callback', 'schedule_hour_sub', (msg, data) => { 224 | getPollFromAction(msg, data, (err, poll, data) => { 225 | if (err) return handleError(err, msg) 226 | poll.subtractHour(1) 227 | kv.set(poll.id, poll, (err) => { 228 | if (err) return handleError(err, msg) 229 | msg.respond(poll.renderScheduling().json()) 230 | }) 231 | }) 232 | }) 233 | 234 | slapp.action('in_or_out_callback', 'draft_repeat', (msg, data) => { 235 | getPollFromAction(msg, data, (err, poll, data) => { 236 | if (err) return handleError(err, msg) 237 | msg.respond(poll.renderRepeats().json()) 238 | }) 239 | }) 240 | 241 | Poll.periods.forEach((period) => { 242 | slapp.action('in_or_out_callback', `repeat_${period}`, (msg, data) => { 243 | getPollFromAction(msg, data, (err, poll, data) => { 244 | if (err) return handleError(err, msg) 245 | poll.draft_schedule.repeat = period 246 | kv.set(poll.id, poll, (err) => { 247 | if (err) return handleError(err, msg) 248 | msg.respond(poll.renderRepeats().json()) 249 | }) 250 | }) 251 | }) 252 | }) 253 | 254 | slapp.action('in_or_out_callback', 'schedule_save', (msg, data) => { 255 | getPollFromAction(msg, data, (err, poll, data) => { 256 | if (err) return handleError(err, msg) 257 | msg.respond(poll.renderDraft().json()) 258 | }) 259 | }) 260 | 261 | slapp.action('in_or_out_callback', 'schedule_cancel', (msg, data) => { 262 | getPollFromAction(msg, data, (err, poll, data) => { 263 | if (err) return handleError(err, msg) 264 | poll.clearSchedule() 265 | kv.set(poll.id, poll, (err) => { 266 | if (err) return handleError(err, msg) 267 | msg.respond(poll.renderDraft().json()) 268 | }) 269 | }) 270 | }) 271 | 272 | // Recycle the message to the bottom (most recent) of the stream 273 | function menuRecycle (msg, value) { 274 | getPollFromAction(msg, value, (err, poll, data) => { 275 | if (err) return handleError(err, msg) 276 | msg.respond({ delete_original: true }) 277 | msg.say(poll.render().json()) 278 | }) 279 | } 280 | 281 | function menuDismiss (msg, value) { 282 | msg.respond({ 283 | delete_original: true 284 | }) 285 | } 286 | 287 | function menuUnaccounted (msg, value) { 288 | getPollFromAction(msg, value, (err, poll, data) => { 289 | if (err) return handleError(err, msg) 290 | 291 | let token = msg.meta.bot_token 292 | let channel = msg.meta.channel_id 293 | let answered = poll.answered() 294 | 295 | slapp.client.channels.info({ token, channel }, (err, result) => { 296 | if (err) return handleError(err, msg) 297 | let membersById = result.channel.members 298 | 299 | slapp.client.users.list({ token }, (err, teamMembers) => { 300 | if (err) return handleError(err, msg) 301 | let channelMembers = teamMembers.members.filter((it) => { 302 | return membersById.indexOf(it.id) >= 0 303 | }) 304 | 305 | let noAnswer = channelMembers.filter((it) => { 306 | return answered.indexOf(it.name) < 0 && !it.is_bot && !it.deleted 307 | }) 308 | 309 | let noAnswerText = noAnswer.map((it) => { return `<@${it.id}>` }) 310 | let message = smb() 311 | .text('') 312 | .responseType('ephemeral') 313 | .replaceOriginal(false) 314 | .attachment() 315 | .fallback('dismiss') 316 | .text(`_${poll.question}_\n${noAnswer.length} people in this channel have not answered:\n${noAnswerText.join(', ')}`) 317 | .callbackId('in_or_out_callback') 318 | .mrkdwnIn(['text']) 319 | .button() 320 | .name('dismiss') 321 | .text('Dismiss') 322 | .value('dismiss') 323 | .end() 324 | .end() 325 | msg.respond(message.json()) 326 | }) 327 | }) 328 | }) 329 | } 330 | 331 | // Handle an answer 332 | slapp.action('in_or_out_callback', 'answer', (msg, value) => { 333 | var username = msg.body.user.name 334 | 335 | getPollFromAction(msg, value, (err, poll, data) => { 336 | if (err) return handleError(err, msg) 337 | let answer = data.answerId !== undefined ? data.answerId : value 338 | poll.unvote(username) 339 | poll.vote(answer, username) 340 | kv.set(poll.id, poll, (err) => { 341 | if (err) return handleError(err, msg) 342 | msg.respond(poll.render().json()) 343 | }) 344 | }) 345 | }) 346 | 347 | function getPollFromAction (msg, actionVal, callback) { 348 | let wrapperCB = (err, poll, data) => { 349 | if (poll) { 350 | if (msg.meta.channel_id[0] === 'C') { 351 | poll.enableUnaccounted = true 352 | } 353 | } 354 | callback(err, poll, data) 355 | } 356 | 357 | var orig = msg.body.original_message 358 | try { 359 | let data = JSON.parse(actionVal) 360 | kv.get(data.id, (err, val) => { 361 | if (err) return wrapperCB(err) 362 | wrapperCB(null, Poll.create(val), data) 363 | }) 364 | } catch (ex) { 365 | let poll = Poll.legacyParse(orig) 366 | poll.generateId(msg.meta.team_id) 367 | let data = { id: poll.id } 368 | kv.set(poll.id, poll, (err) => { 369 | if (err) return wrapperCB(err) 370 | wrapperCB(null, poll, data) 371 | }) 372 | } 373 | } 374 | 375 | function parseData (data) { 376 | try { 377 | return JSON.parse(data) 378 | } catch (ex) { 379 | console.log('Error parsing JSON', ex) 380 | return {} 381 | } 382 | } 383 | 384 | return {} 385 | } 386 | 387 | 388 | function handleError (err, msg) { 389 | console.error(err) 390 | 391 | // Only show errors when we can respond with an ephemeral message 392 | // So this includes any button actions or slash commands 393 | if (!msg.body.response_url) return 394 | 395 | msg.respond({ 396 | text: `:scream: Uh Oh: ${err.message || err}`, 397 | response_type: 'ephemeral', 398 | replace_original: false 399 | }, (err) => { 400 | if (err) console.error('Error handling error:', err) 401 | }) 402 | } 403 | -------------------------------------------------------------------------------- /src/poll.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const smb = require('slack-message-builder') 4 | const uuidV4 = require('uuid/v4') 5 | const moment = require('moment-timezone') 6 | 7 | module.exports = { 8 | create: (obj) => new Poll(obj), 9 | 10 | createDefault: () => { 11 | let poll = new Poll() 12 | poll.question = 'In or Out?' 13 | poll.addAnswer('In') 14 | poll.addAnswer('Out') 15 | return poll 16 | }, 17 | 18 | legacyParse: (message) => { 19 | let poll = new Poll() 20 | poll.question = message.text 21 | if (message.attachments && message.attachments.length > 0) { 22 | poll.author_name = message.attachments[0].author_name 23 | poll.author_icon = message.attachments[0].author_icon 24 | } 25 | for (var i = 0; i < message.attachments.length; i++) { 26 | var attachment = message.attachments[i] 27 | if (attachment.actions) { 28 | attachment.actions.forEach((action) => { 29 | if (action.name === 'answer') { 30 | poll.addAnswer(action.text) 31 | } 32 | }) 33 | } 34 | if (!attachment.actions) { 35 | let attachment = message.attachments[i] 36 | var line = new LegacyAttachmentLine(attachment.text) 37 | line.entries.forEach((person) => { 38 | poll.vote(line.answer, person) 39 | }) 40 | } 41 | } 42 | return poll 43 | }, 44 | 45 | taskMappingKey: (teamId, pollId) => `${teamId}|task_mapping|${pollId}`, 46 | 47 | periods: ['daily', 'm-f', 'mwf', 'tth', 'weekly', 'monthly'] 48 | } 49 | 50 | class Poll { 51 | constructor (obj) { 52 | obj = obj || {} 53 | this.id = obj.id 54 | this.parent_id = obj.parent_id 55 | this.draft = obj.draft || false 56 | this.draft_schedule = obj.draft_schedule || { time: null, repeat: '' } 57 | this.author_name = obj.author_name || '' 58 | this.author_icon = obj.author_icon || '' 59 | this.author_id = obj.author_id || '' 60 | this.tz_offset = obj.tz_offset || '' 61 | this.tz_label = obj.tz_label || '' 62 | this.tz = obj.tz || '' 63 | this.question = obj.question || '' 64 | this.answers = obj.answers || [] 65 | this.channel_id = obj.channel_id || '' 66 | this.team_id = obj.team_id || '' 67 | this.ts = obj.ts || '' 68 | 69 | this.chronos_id = obj.chronos_id 70 | this.chronos_cancelled = obj.chronos_cancelled || false 71 | 72 | this.enableUnaccounted = obj.enableUnaccounted || false 73 | } 74 | 75 | generateId (slackTeamID) { 76 | this.id = `${slackTeamID}|poll|${uuidV4()}` 77 | } 78 | 79 | addAnswer (text) { 80 | let id = this.answers.length 81 | this.answers.push({ 82 | id, 83 | text, 84 | people: [] 85 | }) 86 | } 87 | 88 | vote (id, userId) { 89 | if (typeof id === 'number' && this.answers[id]) { 90 | this.answers[id].people.push(userId) 91 | } else { 92 | for (var i = 0; i < this.answers.length; i++) { 93 | if (this.answers[i].text === id) { 94 | this.answers[i].people.push(userId) 95 | } 96 | } 97 | } 98 | } 99 | 100 | unvote (userId) { 101 | this.answers.forEach((answer) => { 102 | answer.people = answer.people.filter((person) => person !== userId) 103 | }) 104 | } 105 | 106 | answered () { 107 | let all = [] 108 | this.answers.forEach((answer) => { 109 | answer.people.forEach((person) => { 110 | all.push(person) 111 | }) 112 | }) 113 | return all 114 | } 115 | 116 | subtractDay (num) { 117 | this.draft_schedule.time = this.localizedSchedule().add(-1 * num, 'days').toDate() 118 | } 119 | 120 | addDay (num) { 121 | this.draft_schedule.time = this.localizedSchedule().add(num, 'days').toDate() 122 | } 123 | 124 | addHour (num) { 125 | this.draft_schedule.time = this.localizedSchedule().add(num, 'hours').startOf('hour').toDate() 126 | } 127 | 128 | subtractHour (num) { 129 | this.draft_schedule.time = this.localizedSchedule().add(-1 * num, 'hours').startOf('hour').toDate() 130 | } 131 | 132 | clearSchedule () { 133 | this.draft_schedule.time = null 134 | this.draft_schedule.repeat = '' 135 | } 136 | 137 | localizedSchedule () { 138 | let self = this 139 | this.draft_schedule.time = this.draft_schedule.time || Date.now() 140 | let m = moment(self.draft_schedule.time).utcOffset(self.tz_offset / 60) 141 | m.tz(this.tz) 142 | return m 143 | } 144 | 145 | formatPeriod (period) { 146 | switch (period) { 147 | case 'daily': 148 | return 'Daily' 149 | case 'm-f': 150 | return 'Weekdays' 151 | case 'mwf': 152 | return 'M/W/F' 153 | case 'tth': 154 | return 'T/Th' 155 | case 'weekly': 156 | return 'Weekly' 157 | case 'monthly': 158 | return 'Monthly' 159 | } 160 | } 161 | 162 | formatScheduleStatus (stripMarkdown) { 163 | let self = this 164 | let scheduleText = '' 165 | if (self.draft_schedule.time) { 166 | let dayDate = self.localizedSchedule().format('ddd MMM D') 167 | let dayOfWeek = self.localizedSchedule().format('ddd') 168 | let dayOfMonth = self.localizedSchedule().format('Do') 169 | let tod = self.localizedSchedule().format('h:mm a z') 170 | let B = stripMarkdown ? '' : '*' 171 | let period = `${B}${self.formatPeriod(self.draft_schedule.repeat)}${B}` 172 | if (self.draft_schedule.repeat) { 173 | if (self.draft_schedule.repeat === 'monthly') { 174 | scheduleText = `Scheduled for ${B}${tod}${B} (Repeats ${period} on the ${B}${dayOfMonth}${B})` 175 | } else if (self.draft_schedule.repeat === 'weekly') { 176 | scheduleText = `Scheduled for ${B}${tod}${B} (Repeats ${period} each ${B}${dayOfWeek}${B})` 177 | } else { 178 | scheduleText = `Scheduled for ${B}${tod}${B} (Repeats ${B}${self.formatPeriod(self.draft_schedule.repeat)}${B})` 179 | } 180 | } else { 181 | scheduleText = `Scheduled for ${B}${dayDate}${B} at ${B}${tod}${B}` 182 | } 183 | } 184 | return scheduleText 185 | } 186 | 187 | formatChronosSchedule () { 188 | let self = this 189 | if (self.draft_schedule.time) { 190 | let t = moment(self.draft_schedule.time).utc() 191 | if (!self.draft_schedule.repeat) { 192 | return t.toISOString() 193 | } 194 | 195 | switch (self.draft_schedule.repeat) { 196 | case 'daily': 197 | return `${t.minute()} ${t.hour()} * * * *` 198 | case 'm-f': 199 | return `${t.minute()} ${t.hour()} * * 1-5 *` 200 | case 'mwf': 201 | return `${t.minute()} ${t.hour()} * * 1,3,5 *` 202 | case 'tth': 203 | return `${t.minute()} ${t.hour()} * * 2,4 *` 204 | case 'weekly': 205 | return `${t.minute()} ${t.hour()} * * ${t.day()} *` 206 | case 'monthly': 207 | return `${t.minute()} ${t.hour()} * ${t.date()} * *` 208 | } 209 | } 210 | } 211 | 212 | renderBase (isInactive) { 213 | let self = this 214 | let msg = smb().text(`*${this.question}*`) 215 | let actionName = isInactive ? 'inactive_answer' : 'answer' 216 | 217 | let addAttachment = () => { 218 | let a = msg.attachment() 219 | .fallback('In-or-Out choices') 220 | .callbackId('in_or_out_callback') 221 | .color('#47EEBC') 222 | .mrkdwnIn('text') 223 | return a 224 | } 225 | 226 | // answer buttons 227 | let current = addAttachment() 228 | current 229 | .authorName(`asked by ${this.author_name}`) 230 | .authorIcon(this.author_icon) 231 | let currentCount = 0 232 | this.answers.forEach((answer) => { 233 | let action = current 234 | .button() 235 | .name(actionName) 236 | .text(answer.text) 237 | .type('button') 238 | .value(JSON.stringify({ id: self.id, answerId: answer.id })) 239 | 240 | if (isInactive) { 241 | action.confirm() 242 | .title('Question not published!') 243 | .text(':nerd_face: These buttons won\'t work until you publish the question.') 244 | .okText('Ok') 245 | .dismissText('Dismiss') 246 | } 247 | 248 | currentCount++ 249 | 250 | if (currentCount === 5) { 251 | current = addAttachment() 252 | currentCount = 0 253 | } 254 | }) 255 | return msg 256 | } 257 | 258 | render () { 259 | let self = this 260 | let value = JSON.stringify({ id: self.id }) 261 | let msg = self.renderBase() 262 | 263 | // choices, slice(0) creates a clone 264 | let sorted = self.answers.slice(0).sort((a, b) => { return a.people.length > b.people.length ? -1 : 1 }) 265 | sorted.forEach((answer) => { 266 | if (answer.people.length > 0) { 267 | msg 268 | .attachment() 269 | .text(`*${answer.people.length}* ${answer.text} ⇢ _${answer.people.map(it => `@${it}`).join(', ')}_`) 270 | .mrkdwnIn(['text']) 271 | } 272 | }) 273 | 274 | // bottom 275 | let bottom = msg.attachment() 276 | bottom 277 | .text('') 278 | .fallback('move to the bottom') 279 | .callbackId('in_or_out_callback') 280 | 281 | let menu = bottom 282 | .select() 283 | .name('menu') 284 | .text('Options') 285 | 286 | if (self.enableUnaccounted) { 287 | menu.option('Who hasn\'t answered?', { id: self.id, action: 'unaccounted' }) 288 | } 289 | 290 | menu.option('Move to bottom', { id: self.id, action: 'recycle' }) 291 | menu.option('Delete', { id: self.id, action: 'dismiss' }) 292 | 293 | if (self.chronos_id) { 294 | menu.option('Unschedule', { id: self.id, action: 'cancel_published_schedule' }) 295 | bottom.footer(self.formatScheduleStatus(true)) 296 | } 297 | 298 | if (self.chronos_cancelled) { 299 | bottom.footer('Schedule cancelled') 300 | } 301 | 302 | return msg 303 | } 304 | 305 | renderDraft () { 306 | let self = this 307 | let value = JSON.stringify({ id: self.id }) 308 | let scheduleText = self.formatScheduleStatus() 309 | let isInactive = true 310 | let publishText = self.draft_schedule.time ? 'Publish' : 'Publish Now' 311 | 312 | let msg = self.renderBase(isInactive) 313 | .responseType('ephemeral') 314 | .replaceOriginal(true) 315 | 316 | let att = msg.attachment() 317 | .text(scheduleText) 318 | .fallback('Publish or Schedule') 319 | .callbackId('in_or_out_callback') 320 | .mrkdwnIn(['text']) 321 | .button() 322 | .name('confirm_publish') 323 | .value(value) 324 | .text(publishText) 325 | .type('button') 326 | .style('primary') 327 | .end() 328 | .button() 329 | .name('draft_schedule') 330 | .value(value) 331 | .text(':calendar: Schedule') 332 | .type('button') 333 | .end() 334 | 335 | // only show repeat if a schedule has been set 336 | if (self.draft_schedule.time) { 337 | att.button() 338 | .name('draft_repeat') 339 | .value(value) 340 | .text(':repeat: Repeat') 341 | .type('button') 342 | .end() 343 | } 344 | 345 | att.button() 346 | .name('cancel_publish') 347 | .value(value) 348 | .text('Discard') 349 | .type('button') 350 | .style('danger') 351 | .end() 352 | return msg 353 | } 354 | 355 | renderScheduling () { 356 | let self = this 357 | let value = JSON.stringify({ id: self.id }) 358 | let day = self.localizedSchedule().format('ddd MMM D') 359 | let tod = self.localizedSchedule().format('h:mm a z') 360 | let isInactive = true 361 | let msg = self.renderBase(isInactive) 362 | .responseType('ephemeral') 363 | .attachment() 364 | .text('Schedule this post on:') 365 | .fallback('Schedule this post') 366 | .callbackId('in_or_out_callback') 367 | .mrkdwnIn(['text']) 368 | .button() 369 | .name('schedule_day_sub') 370 | .value(value) 371 | .text('-') 372 | .type('button') 373 | .end() 374 | .button() 375 | .name('noop') 376 | .value(value) 377 | .text(day) 378 | .type('button') 379 | .end() 380 | .button() 381 | .name('schedule_day_add') 382 | .value(value) 383 | .text('+') 384 | .type('button') 385 | .end() 386 | .end() 387 | .attachment() 388 | .text('') 389 | .fallback('Schedule this post') 390 | .callbackId('in_or_out_callback') 391 | .button() 392 | .name('schedule_hour_sub') 393 | .value(value) 394 | .text('-') 395 | .type('button') 396 | .end() 397 | .button() 398 | .name('noop') 399 | .value(value) 400 | .text(tod) 401 | .type('button') 402 | .end() 403 | .button() 404 | .name('schedule_hour_add') 405 | .value(value) 406 | .text('+') 407 | .type('button') 408 | .end() 409 | .end() 410 | .attachment() 411 | .text('') 412 | .fallback('Schedule this post') 413 | .callbackId('in_or_out_callback') 414 | .button() 415 | .name('schedule_save') 416 | .value(value) 417 | .text('Save') 418 | .type('button') 419 | .style('primary') 420 | .end() 421 | .button() 422 | .name('schedule_cancel') 423 | .value(value) 424 | .text('Cancel') 425 | .type('button') 426 | .end() 427 | .end() 428 | return msg 429 | } 430 | 431 | renderRepeats () { 432 | let self = this 433 | let unselected = '\u25CB' 434 | let selected = '\u25CF' 435 | let radio = (period) => self.draft_schedule.repeat === period ? selected : unselected 436 | let value = JSON.stringify({ id: this.id }) 437 | let scheduleText = this.formatScheduleStatus() 438 | let isInactive = true 439 | let msg = this.renderBase(isInactive).responseType('ephemeral') 440 | let actionAttachment = null 441 | 442 | module.exports.periods.forEach((period, i) => { 443 | if (i % 4 === 0) { 444 | actionAttachment = msg.attachment() 445 | .fallback('Repeat Frequency') 446 | .callbackId('in_or_out_callback') 447 | .mrkdwnIn(['text']) 448 | } 449 | actionAttachment.button() 450 | .name(`repeat_${period}`) 451 | .value(value) 452 | .text(`${radio(period)} ${self.formatPeriod(period)}`) 453 | .type('button') 454 | }) 455 | 456 | msg.attachment() 457 | .text('') 458 | .fallback('Set Reoccurance') 459 | .callbackId('in_or_out_callback') 460 | .button() 461 | .name('schedule_save') 462 | .value(value) 463 | .text('Save') 464 | .type('button') 465 | .style('primary') 466 | .end() 467 | .button() 468 | .name('schedule_cancel') 469 | .value(value) 470 | .text('Cancel') 471 | .type('button') 472 | .end() 473 | .end() 474 | return msg 475 | } 476 | 477 | renderScheduledConfirmation () { 478 | let value = JSON.stringify({ id: this.id, discard: true }) 479 | let msg = smb() 480 | .text(`:white_check_mark: <@${this.author_id}> scheduled a new poll for this channel.`) 481 | .attachment() 482 | .text(`*${this.question}*`) 483 | .fallback('Cancel scheduled question') 484 | .callbackId('in_or_out_callback') 485 | .footer(this.formatScheduleStatus(true)) 486 | .mrkdwnIn(['text']) 487 | .button() 488 | .name('cancel_published_schedule') 489 | .value(value) 490 | .text('Unschedule') 491 | .type('button') 492 | .confirm() 493 | .title('Are you sure?') 494 | .text(`Cancel all future occurances of "${this.question}" created by ${this.author_name}?\n${this.formatScheduleStatus(true)}.`) 495 | .okText('Yes') 496 | .dismissText('No') 497 | .end() 498 | .end() 499 | .button() 500 | .name('dismiss') 501 | .value(value) 502 | .text('Dismiss') 503 | .type('button') 504 | .end() 505 | .end() 506 | return msg 507 | } 508 | } 509 | 510 | 511 | class LegacyAttachmentLine { 512 | 513 | constructor (text) { 514 | this.entries = [] 515 | this.answer = '' 516 | if (text) { 517 | var parts = text.substring(text.indexOf(' ')).split(/»/) 518 | parts = parts.map((it) => { return it.trim() }) 519 | this.answer = parts[0] 520 | this.entries = parts[1].split(',').map((val) => { return val.trim() }) 521 | } 522 | } 523 | 524 | add (entry) { 525 | this.remove(entry) 526 | this.entries.push(entry) 527 | return this 528 | } 529 | 530 | remove (entry) { 531 | this.entries = this.entries.filter((val) => { return val !== entry }) 532 | return this 533 | } 534 | 535 | contains (entry) { 536 | return this.entries.indexOf(entry) > -1 537 | } 538 | 539 | count () { 540 | return this.entries.length 541 | } 542 | 543 | string () { 544 | return '*' + this.count() + '*' + ' ' + this.answer + ' » ' + this.entries.join(', ') 545 | } 546 | } 547 | --------------------------------------------------------------------------------