|
| 1 | +/** |
| 2 | + * Working PlusPlus++ |
| 3 | + * Like plusplus.chat, but one that actually works, because you can host it yourself! 😉 |
| 4 | + * |
| 5 | + * @see https://github.com/tdmalone/working-plusplus |
| 6 | + * @see https://api.slack.com/events-api |
| 7 | + * @author Tim Malone <tdmalone@gmail.com> |
| 8 | + */ |
| 9 | + |
| 10 | +const express = require( 'express' ), |
| 11 | + bodyParser = require( 'body-parser' ), |
| 12 | + slackClient = require('@slack/client'), |
| 13 | + pg = require( 'pg' ), |
| 14 | + messages = require( './messages' ); |
| 15 | + |
| 16 | +const SLACK_BOT_USER_OAUTH_ACCESS_TOKEN = process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN, |
| 17 | + SLACK_VERIFICATION_TOKEN = process.env.SLACK_VERIFICATION_TOKEN, |
| 18 | + DATABASE_URL = process.env.DATABASE_URL; |
| 19 | + |
| 20 | +// Let Heroku set the port. |
| 21 | +const PORT = process.env.PORT || 80; |
| 22 | + |
| 23 | +const scoresTableName = 'scores'; |
| 24 | + |
| 25 | +const app = express(), |
| 26 | + postgres = new pg.Pool({ connectionString: DATABASE_URL, ssl: true }), |
| 27 | + slack = new slackClient.WebClient( SLACK_BOT_USER_OAUTH_ACCESS_TOKEN ); |
| 28 | + |
| 29 | +const getRandomMessage = ( operation ) => { |
| 30 | + operation = operation.replace( '+', 'plus' ).replace( '-', 'minus' ); |
| 31 | + max = messages[ operation ].length - 1; |
| 32 | + random = Math.floor( Math.random() * max ); |
| 33 | + return messages[ operation ][ random ]; |
| 34 | +}; |
| 35 | + |
| 36 | +app.use( bodyParser.json() ); |
| 37 | +app.enable( 'trust proxy' ); |
| 38 | + |
| 39 | +app.get( '/', ( request, response ) => { |
| 40 | + response.send( 'It works! However, this app only accepts POST requests for now.' ); |
| 41 | +}); |
| 42 | + |
| 43 | +app.post( '/', async ( request, response ) => { |
| 44 | + |
| 45 | + console.log( |
| 46 | + request.ip + ' ' + request.method + ' ' + request.path + ' ' + request.headers['user-agent'] |
| 47 | + ); |
| 48 | + |
| 49 | + // Respond to challenge sent by Slack during event subscription set up. |
| 50 | + if ( request.body.challenge ) { |
| 51 | + response.send( request.body.challenge ); |
| 52 | + console.info( '200 Challenge response sent' ); |
| 53 | + return; |
| 54 | + } |
| 55 | + |
| 56 | + // Sanity check for bad verification values. |
| 57 | + if ( ! SLACK_VERIFICATION_TOKEN || 'xxxxxxxxxxxxxxxxxxxxxxxx' === SLACK_VERIFICATION_TOKEN ) { |
| 58 | + response.status( 403 ).send( 'Access denied.' ); |
| 59 | + console.error( '403 Access denied - bad verification value' ); |
| 60 | + return; |
| 61 | + } |
| 62 | + |
| 63 | + // Check that this is Slack making the request. |
| 64 | + // TODO: Move to calculating the signature instead (newer, more secure method). |
| 65 | + if ( SLACK_VERIFICATION_TOKEN !== request.body.token ) { |
| 66 | + response.status( 403 ).send( 'Access denied.' ); |
| 67 | + console.error( '403 Access denied - incorrect verification token' ); |
| 68 | + return; |
| 69 | + } |
| 70 | + |
| 71 | + // Send back a 200 OK now so Slack doesn't get upset. |
| 72 | + response.send( '' ); |
| 73 | + |
| 74 | + const event = request.body.event; |
| 75 | + |
| 76 | + // Drop events that aren't messages, or that don't have message text. |
| 77 | + if ( 'message' !== event.type || ! event.text ) { |
| 78 | + console.warn( 'Invalid event received (' + request.event.type + ') or event data missing' ); |
| 79 | + return; |
| 80 | + } |
| 81 | + |
| 82 | + // Drop retries. This is controversial. But, because we're mainly gonna be running on free Heroku |
| 83 | + // dynos, we'll be sleeping after inactivity. It takes longer than Slack's 3 second limit to start |
| 84 | + // back up again, so Slack will retry immediately and then again in a minute - which will result |
| 85 | + // in the action being carried out 3 times if we listen to it! |
| 86 | + // @see https://api.slack.com/events-api#graceful_retries |
| 87 | + if ( request.headers['x-slack-retry-num'] ) { |
| 88 | + console.log( 'Skipping Slack retry.' ); |
| 89 | + return; |
| 90 | + } |
| 91 | + |
| 92 | + const text = event.text; |
| 93 | + |
| 94 | + // Drop text that doesn't mention anybody/anything. |
| 95 | + if ( -1 === text.indexOf( '@' ) ) { |
| 96 | + return; |
| 97 | + } |
| 98 | + |
| 99 | + // Drop text that doesn't include ++ or -- (or —, to support iOS replacing --). |
| 100 | + if ( -1 === text.indexOf( '++' ) && -1 === text.indexOf( '--' ) && -1 === text.indexOf( '—' ) ) { |
| 101 | + return; |
| 102 | + } |
| 103 | + |
| 104 | + // If we're still here, it's a message to deal with! |
| 105 | + |
| 106 | + // Get the user or 'thing' that is being spoken about, and the 'operation' being done on it. |
| 107 | + // We take the operation down to one character, and also support — due to iOS' replacement of --. |
| 108 | + const data = text.match( /@([A-Za-z0-9\.\-_]*?)>?\s*([\-+]{2}|—{1})/ ); |
| 109 | + const item = data[1]; |
| 110 | + const operation = data[2].substring( 0, 1 ).replace( '—', '-' ); |
| 111 | + |
| 112 | + // If we somehow didn't get anything, drop it. This can happen when eg. @++ is typed. |
| 113 | + if ( ! item.trim() ) { |
| 114 | + return; |
| 115 | + } |
| 116 | + |
| 117 | + // If the user is trying to ++ themselves... |
| 118 | + if ( item === event.user && '+' === operation ) { |
| 119 | + |
| 120 | + const message = getRandomMessage( 'selfPlus' ); |
| 121 | + |
| 122 | + slack.chat.postMessage({ |
| 123 | + channel: event.channel, |
| 124 | + text: '<@' + event.user + '> ' + message, |
| 125 | + }).then( ( data ) => { |
| 126 | + console.log( |
| 127 | + data.ok ? |
| 128 | + item + ' tried to alter their own score.' : |
| 129 | + 'Error occurred posting response to user altering their own score.' |
| 130 | + ); |
| 131 | + }); |
| 132 | + |
| 133 | + return; |
| 134 | + |
| 135 | + } |
| 136 | + |
| 137 | + // Connect to the DB, and create a table if it's not yet there. |
| 138 | + const dbClient = await postgres.connect(); |
| 139 | + const dbCreateResult = await dbClient.query( 'CREATE EXTENSION IF NOT EXISTS citext; CREATE TABLE IF NOT EXISTS ' + scoresTableName + ' (item CITEXT PRIMARY KEY, score INTEGER);' ); |
| 140 | + |
| 141 | + // Atomically record the action. |
| 142 | + // TODO: Fix potential SQL injection issues here, even though we know the input should be safe. |
| 143 | + const dbInsert = await dbClient.query( 'INSERT INTO ' + scoresTableName + ' VALUES (\'' + item + '\', ' + operation + '1) ON CONFLICT (item) DO UPDATE SET score = ' + scoresTableName + '.score ' + operation + ' 1;' ); |
| 144 | + |
| 145 | + // Get the new value. |
| 146 | + // TODO: Fix potential SQL injection issues here, even though we know the input should be safe. |
| 147 | + const dbSelect = await dbClient.query( 'SELECT score FROM ' + scoresTableName + ' WHERE item = \'' + item + '\';' ); |
| 148 | + const score = dbSelect.rows[0].score; |
| 149 | + |
| 150 | + dbClient.release(); |
| 151 | + |
| 152 | + // Respond. |
| 153 | + const itemMaybeLinked = item.match( /U[A-Z0-9]{8}/ ) ? '<@' + item + '>' : item; |
| 154 | + const pluralise = score === 1 ? '' : 's'; |
| 155 | + const message = getRandomMessage( operation ); |
| 156 | + slack.chat.postMessage({ |
| 157 | + channel: event.channel, |
| 158 | + text: ( |
| 159 | + message + ' ' + |
| 160 | + '*' + itemMaybeLinked + '* is now on ' + score + ' point' + pluralise + '.' |
| 161 | + ) |
| 162 | + }).then( ( data ) => { |
| 163 | + console.log( data.ok ? item + ' now on ' + score : 'Error occurred posting response.' ); |
| 164 | + }); |
| 165 | + |
| 166 | +}); |
| 167 | + |
| 168 | +app.listen( PORT, () => { |
| 169 | + console.log( 'Listening on port ' + PORT + '.' ) |
| 170 | +}); |
0 commit comments