const { App: SlackApp } = require('@slack/bolt') const config = require('../config') const fs = require('fs') const { addReactions, saveGame, setSlackAppClientChatUpdate, parseOr } = require('../games/hvacoins/utils') const temperatureChannelId = 'C034156CE03' const dailyStandupChannelId = 'C03L533AU3Z' const pollingMinutes = 5 const pollingPeriod = 1000 * 60 * pollingMinutes const MAX_POLLS = 3 const HOURS_PER_WINDOW = 2 const colderEmoji = 'snowflake' const hotterEmoji = 'fire' const goodEmoji = '+1' const app = new SlackApp({ token: config.slackBotToken, signingSecret: config.slackSigningSecret, appToken: config.slackAppToken, socketMode: true }) // app.client.conversations.list({types: 'private_channel'}).then(fetched => { // temperatureChannelId = fetched.channels.filter(channel => channel.name === 'thermo-posting')[0].id // console.log('techThermostatChannelId', temperatureChannelId) // }) const pollTriggers = ['!temp', '!temperature', '!imhot', '!imcold', '!imfreezing', '!idonthavemysweater'] const halfTriggers = ['change temperature', "i'm cold", "i'm hot", 'quack', 'hvacker', '<@U0344TFA7HQ>'] const sendHelp = async (say, prefix) => { if (prefix) { prefix = prefix + '\n' } else { prefix = '' } await say({ text: prefix + `Sending a message matching any of \`${pollTriggers.join('`, `')}\` will start a temperature poll.\n` + '\'Hotter\' and \'Colder\' votes offset. E.g. with votes Hotter - 4, Colder - 3, and Content - 2, the temp won\'t change.\n' + `At this time I am not capable of actually changing the temperature. Go bug ${users.ThermoController}.` }) } const getMessage = async ({ channel, ts }) => app.client.conversations.history({ channel: channel, latest: ts, inclusive: true, limit: 1 }) app.event('reaction_added', async ({ event, context, client, say }) => { console.log('reaction_added', event) for (const listener of reactionListeners) { listener({ event, say }) } }) const users = parseOr(fs.readFileSync('./users.json', 'utf-8'), () => ({})) const buildSayPrepend = ({ say, prepend }) => async msg => { if (typeof(msg) === 'string') { return say(prepend + msg) } return say({ ...msg, text: prepend + msg.text }) } process.once('SIGINT', code => { saveGame('SIGINT', true) process.exit() }) let pollHistory = [] const activePolls = {} const testId = 'U028BMEBWBV_TEST' let testMode = false app.event('message', async ({ event, context, client, say }) => { if (event.subtype !== 'message_changed' && event?.text !== '!') { console.log('message.event', { ...event, userName: users[event.user] }) } if (event?.user === users.Admin) { if (event?.text.startsWith('!')) { if (testMode) { await messageAdmin('Currently in test mode!') } } if (event?.text === '!test') { testMode = !testMode await messageAdmin(`TestMode: ${testMode} with ID ${testId}`) } else if (event?.text === '!notest') { testMode = false await messageAdmin(`TestMode: ${testMode}`) } if (testMode) { event.user = testId } } for (const listener of messageListeners) { listener({ event, say }) } if (event.user) { console.log('MSG', users[event.user], "'" + event.text + "'", new Date().toLocaleTimeString()) } if (event.user === users.Admin && event.channel === 'D0347Q4H9FE') { if (event.text === '!!kill') { saveGame('!!kill', true) process.exit(1) } else if (event.text === '!!restart') { if (Object.entries(activePolls).length === 0) { saveGame('!!restart', true) process.exit(0) } else { await messageAdmin('Restart pending poll completion...') pendingRestart = true } } if (event.text?.startsWith('!say ') || event.text?.startsWith('!say\n')) { await postToTechThermostatChannel(event.text.substring(4).trim().replace('@here', '')) return } } const eventText = event.text?.toLowerCase() || '' if (eventText === '!help') { await sendHelp(say) return } if (!pollTriggers.includes(eventText) || event.user === users.John) { if (halfTriggers.includes(eventText)) { await sendHelp(say, 'It looks like you might want to change the temperature.') } return } if (event.channel !== temperatureChannelId) { return say(`Please request polls in the appropriate channel.`) } if (activePolls[event.channel]) { await postToTechThermostatChannel({ text: "There's already an active poll in this channel!" }) return } const now = new Date() const windowStart = new Date() windowStart.setHours(now.getHours() - HOURS_PER_WINDOW) const pollsInWindow = pollHistory.filter(pollTime => pollTime > windowStart) const pollText = MAX_POLLS === 1 ? 'poll' : 'polls' const hourText = HOURS_PER_WINDOW === 1 ? 'hour' : 'hours' if (pollsInWindow.length >= MAX_POLLS) { await postToTechThermostatChannel({ text: `You have exceeded the limit of ${MAX_POLLS} ${pollText} per ${HOURS_PER_WINDOW} ${hourText}!` }) return } if (pollHistory.push(now) > MAX_POLLS) { [, ...pollHistory] = pollHistory } activePolls[event.channel] = true const pollTs = await startPoll() setTimeout(async () => { const reactions = await app.client.reactions.get({ channel: temperatureChannelId, timestamp: pollTs, full: true }) const reactPosters = {} reactions.message.reactions.forEach(r => r.users.forEach(user => { reactPosters[user] ??= [] reactPosters[user].push(r.name) })) const reactCounts = {} Object.entries(reactPosters).forEach(([id, votes]) => { console.log(`VOTES FROM ${id}:`, votes) votes = votes.filter(v => [goodEmoji, hotterEmoji, colderEmoji].find(emoji => v.startsWith(emoji))) if (votes.length === 1) { const name = votes[0].replace(/:.*/g, '') reactCounts[name] ??= 0 reactCounts[name] += 1 } }) console.log('REACT COUNTS', JSON.stringify(reactCounts)) const contentVotes = reactCounts[goodEmoji] || 0 let hotterVotes = reactCounts[hotterEmoji] || 0 let colderVotes = reactCounts[colderEmoji] || 0 console.log('before contentVotes', contentVotes) console.log('before colderVotes', colderVotes) console.log('before hotterVotes', hotterVotes) if (hotterVotes > colderVotes) { hotterVotes -= colderVotes colderVotes = 0 } else if (colderVotes > hotterVotes) { colderVotes -= hotterVotes hotterVotes = 0 } console.log('after contentVotes', contentVotes) console.log('after colderVotes', colderVotes) console.log('after hotterVotes', hotterVotes) let text if (hotterVotes > colderVotes && hotterVotes > contentVotes) { text = `<@${users[users.ThermoController]}> The people have spoken, and would like to ` text += 'raise the temperature, quack.' requestTempChange('Hotter') } else if (colderVotes > hotterVotes && colderVotes > contentVotes) { text = `<@${users[users.ThermoController]}> The people have spoken, and would like to ` text += 'lower the temperature, quack quack.' requestTempChange('Colder') } else { text = `The people have spoken, and would like to ` text += 'keep the temperature as-is, quaaack.' requestTempChange('Good') } await postToTechThermostatChannel({ text }) delete activePolls[event.channel] if (pendingRestart && Object.entries(activePolls).length === 0) { await messageAdmin('Performing pending restart!') saveGame(null, true) process.exit(0) } }, pollingPeriod) }) let pendingRestart = false ;(async () => { await app.start() console.log('Slack Bolt has started') })() const postToTechThermostatChannel = async optionsOrText => { if (optionsOrText === null || typeof optionsOrText !== 'object') { optionsOrText = { text: optionsOrText } } return app.client.chat.postMessage({ ...optionsOrText, channel: temperatureChannelId }) } const messageAdmin = async optionsOrText => messageIn(users.Admin, optionsOrText) const messageIn = async (channel, optionsOrText) => { if (optionsOrText === null || typeof optionsOrText !== 'object') { optionsOrText = { text: optionsOrText } } return app.client.chat.postMessage({ ...optionsOrText, channel }) } const startPoll = async () => { const sent = await postToTechThermostatChannel({ text: ` Temperature poll requested! In ${pollingMinutes} minutes the temperature will be adjusted. Pick :${colderEmoji}: if you want it colder, :${hotterEmoji}: if you want it hotter, or :${goodEmoji}: if you like it how it is. (Note that I can't actually change the temperature yet. Make ${users.ThermoController} do it!)` }) await addReactions({ app, channelId: temperatureChannelId, timestamp: sent.ts, reactions: [colderEmoji, hotterEmoji, goodEmoji] }) return sent.ts } const tempChangeListeners = [] const messageListeners = [] const reactionListeners = [] const requestTempChange = change => { tempChangeListeners.forEach(listener => listener(change)) } // noinspection HttpUrlsUsage const encodeData = (key, data) => `` const decodeData = (key, message) => { try { const regex = new RegExp(`http://${key}ZZZ[^|]*`) let match = message.match(regex) if (!match) { return match } match = match[0].substring(10 + key.length) // 10 === 'http://'.length + 'ZZZ'.length return JSON.parse(Buffer.from(match, 'base64').toString('utf-8')) } catch (e) { console.error(e) return null } } const onReaction = listener => reactionListeners.push(listener) const channelIsIm = async channel => (await app.client.conversations.info({ channel }))?.channel?.is_im const wasMyMessage = async event => { const text = (await app.client.conversations.history({ channel: event.item.channel, latest: event.item.ts, limit: 1, inclusive: true })).messages[0].text const decoded = decodeData('commandPayload', text) return decoded.event.user === event.user } onReaction(async ({ event }) => { console.log({ event }) if (event.reaction === 'x' && (event.user === users.Admin || (await wasMyMessage(event)) || await channelIsIm(event.item.channel))) { try { await app.client.chat.delete({ channel: event.item.channel, ts: event.item.ts }) } catch (e) { } } }) setSlackAppClientChatUpdate(app.client.chat.update) module.exports = { app, temperatureChannelId, dailyStandupChannelId, onAction: app.action, getMessage, updateMessage: app.client.chat.update, postToTechThermostatChannel, onTempChangeRequested: listener => tempChangeListeners.push(listener), onMessage: listener => messageListeners.push(listener), onReaction, encodeData, decodeData, messageAdmin, messageIn, testMode, testId, users, buildSayPrepend, pollTriggers, pendingRestart }