Init commit

This commit is contained in:
Sage Vaillancourt 2022-03-03 11:23:22 -05:00
commit a3c838438e
18 changed files with 5679 additions and 0 deletions

131
.gitignore vendored Normal file
View File

@ -0,0 +1,131 @@
.idea/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

1
hvacoins.json Normal file
View File

@ -0,0 +1 @@
{"users":{"U028BMEBWBV":{"coins":145479654964,"lastCheck":1646324260.972,"items":{"mouse":98,"accountant":65,"whale":44,"train":34,"fire":31,"boomerang":25,"moon":17,"butterfly":10,"mirror":4},"highestEver":214386486937,"upgrades":{"mouse":["doubleClick","biggerTeeth"],"whale":["thinnerWater","biggerBlowhole"],"accountant":["fasterComputers","widerBrains"],"train":["greasyTracks","loudConductors"],"fire":["gasolineFire","cavemanFire"],"boomerang":["spoonerang","doubleRang"],"butterfly":["glassButterfly"],"moon":["lunarPower"],"general":["homage"]},"achievements":{"leaderBoardViewer":true,"seeTheQuade":true,"greenCoin":true}},"UTDLFGZA5":{"coins":246913571,"lastCheck":1646259791.412,"highestEver":246913584,"items":{},"upgrades":{},"achievements":{}},"U0X0ZQCN6":{"coins":100000000,"items":{},"upgrades":{},"achievements":{}},"U02AAB54V34":{"coins":37269811139,"lastCheck":1646320737.911,"highestEver":101412524777,"items":{"mouse":69,"accountant":50,"whale":40,"train":30,"fire":20,"moon":15,"butterfly":5,"boomerang":20},"upgrades":{"mouse":["biggerTeeth","doubleClick"],"accountant":["fasterComputers","widerBrains"],"whale":["biggerBlowhole","thinnerWater"],"train":["greasyTracks","loudConductors"],"fire":["gasolineFire"],"moon":["lunarPower"],"general":["homage"],"boomerang":["spoonerang"]},"achievements":{}},"U02KYLVK1GV":{"coins":946596248,"lastCheck":1646320737.912,"items":{"mouse":69,"accountant":69,"whale":69,"train":69,"fire":52,"boomerang":8,"moon":3,"butterfly":2},"highestEver":37641599401,"upgrades":{"mouse":["doubleClick","biggerTeeth"],"accountant":["fasterComputers","widerBrains"],"whale":["biggerBlowhole","thinnerWater"],"train":["greasyTracks","loudConductors"],"fire":["gasolineFire","cavemanFire"],"boomerang":["spoonerang"],"moon":["lunarPower"],"general":["homage"]},"achievements":{"leaderBoardViewer":true,"seeTheQuade":true}},"U02U15RFK4Y":{"coins":29281767996,"items":{"mouse":40,"accountant":32,"whale":19,"train":13,"fire":13,"boomerang":14,"moon":22,"butterfly":9,"mirror":1},"highestEver":156855843643,"lastCheck":1646320737.911,"upgrades":{"mouse":["doubleClick","biggerTeeth"],"accountant":["fasterComputers","widerBrains"],"whale":["biggerBlowhole"],"train":["greasyTracks"],"fire":["gasolineFire"],"boomerang":["spoonerang"],"moon":["lunarPower"],"butterfly":["glassButterfly"]},"achievements":{"leaderBoardViewer":true,"seeTheQuade":true}},"U2X0SG7BP":{"coins":0,"items":{},"upgrades":{},"achievements":{}},"U017PG4EL1Y":{"coins":4218340103,"items":{"mouse":21,"accountant":23,"whale":22,"fire":8,"train":2,"boomerang":2},"lastCheck":1646320737.912,"highestEver":4218340103,"upgrades":{},"achievements":{}},"U0344TFA7HQ":{"coins":100000101,"items":{},"upgrades":{},"achievements":{}},"U017CB5L1K3":{"coins":100000000,"items":{},"upgrades":{},"achievements":{}},"UQB1E7884":{"coins":100000000,"items":{},"upgrades":{},"achievements":{}}},"nfts":[{"name":"quackers","price":100000000000,"description":"A gorgeously photorealistic picture of a duck.","picture":"``` ______\n/ o \\__ ( Quack! )\n\\ _/\n \\ /\n | \\```","owner":"U02AAB54V34"},{"name":"quackers_imprisoned","price":1000000000000,"description":"The infamous quackers, now in in-jail form","picture":"```| |__|__| | |\n| /|  |o |__| (|Quack! )\n| \\|  |  |_/| |\n| |  |  | | |\n| || |  |\\ | |```","owner":null}]}

3970
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "hvacker-api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node src/index.js",
"test": "jest --passWithNoTests",
"build": "npm ci",
"lint": "standard"
},
"repository": {
"type": "git",
"url": "ssh://git@git.add123.com:7999/add/hvacker-api.git"
},
"author": "Auto Data Direct, Inc.",
"license": "UNLICENSED",
"dependencies": {
"@slack/bolt": "^3.9.0",
"base-64": "^1.0.0",
"express": "^4.17.3",
"fs": "0.0.1-security",
"jest": "27.0.6"
},
"standard": {
"env": "jest"
}
}

13
src/auth/index.js Normal file
View File

@ -0,0 +1,13 @@
let base64 = require('base-64')
let config = require('../config')
const url = ''
const headers = new Headers()
headers.append('Authorization', 'Basic ' + base64.encode(config.honeywellKey + ':' + config.honeywellSecret))
fetch(url, {method:'GET',
headers: headers,
//credentials: 'user:passwd'
}).then(response => response.json())
.then(json => console.log(json));

21
src/config.js Normal file
View File

@ -0,0 +1,21 @@
const path = require('path')
const homedir = require('os').homedir()
const fs = require('fs')
const fileName = 'hvackerconfig.json'
const configPaths = [
path.join('/secrets', fileName),
path.join(homedir, '.add123', fileName)
]
const getConfigData = () => {
const configPath = configPaths.find(fs.existsSync)
if (!configPath) {
throw 'Could not find a config file!'
}
const config = fs.readFileSync(configPath, 'utf8')
return JSON.parse(config)
}
module.exports = getConfigData()

View File

@ -0,0 +1,28 @@
module.exports = {
leaderBoardViewer: {
name: 'Leaderboard-Viewer',
description: 'Thank you for viewing the leaderboard!',
emoji: 'trophy',
},
seeTheQuade: {
name: 'See the Quade',
description: 'Quade has appeared in your buyables',
emoji: 'quade',
},
greenCoin: {
name: 'Lucky Green Coin',
description: 'Its wings smell like onions',
emoji: 'money_with_wings'
},
goldBrick: {
name: 'The Golden Brick',
description: 'Find a lucky gold brick',
emoji: 'goldbrick'
},
luckyGem: {
name: 'Lucky Gem Acquired',
description: 'It sparkles',
emoji: 'gem'
}
}

View File

@ -0,0 +1,68 @@
module.exports = {
mouse: {
baseCost: 100,
earning: 1,
emoji: 'mouse2',
description: 'A mouse to steal coins for you.'
},
accountant: {
baseCost: 1_100,
earning: 8,
emoji: 'male-office-worker',
description: 'Legally make money from nothing!'
},
whale: {
baseCost: 12_000,
earning: 47,
emoji: 'whale',
description: 'Someone to spend money on your HVAC Coin mining app.'
},
train: {
baseCost: 130_000,
earning: 260,
emoji: 'train2',
description: 'Efficiently ship your most valuable coins.'
},
fire: {
baseCost: 1_400_000,
earning: 1_400,
emoji: 'fire',
description: 'Return to the roots of HVAC.'
},
boomerang: {
baseCost: 20_000_000,
earning: 7_800,
emoji: 'boomerang',
description: 'Your coin always seems to come back.'
},
moon: {
baseCost: 330_000_000,
earning: 44_000,
emoji: 'new_moon_with_face',
description: 'Convert dark new-moon energy into HVAC Coins.'
},
butterfly: {
baseCost: 5_100_000_000,
earning: 260_000,
emoji: 'butterfly',
description: 'Create the exact worldly chaos to bit-flip HVAC Coins into existence on your computer.'
},
mirror: {
baseCost: 75_000_000_000,
earning: 1_600_000,
emoji: 'mirror',
description: 'Only by gazing inward can you collect enough Coin to influence the thermostat.'
},
quade: {
baseCost: 1_000_000_000_000,
earning: 10_000_000,
emoji: 'quade',
description: 'Has thumbs capable of physically manipulating the thermostat.'
},
hvacker: {
baseCost: 14_000_000_000_000,
earning: 65_000_000,
emoji: 'hvacker_angery',
description: 'Harness the power of the mad god himself.'
}
}

514
src/games/coins/index.js Normal file
View File

@ -0,0 +1,514 @@
const slack = require('../../slack')
const fs = require('fs')
const jokes = require('./../jokes')
const buyableItems = require('./buyableItems')
const upgrades = require('./upgrades')
const achievements = require('./achievements')
const saveFile = './hvacoins.json'
const logError = msg => msg ? console.error(msg) : undefined
const maybeNews = say => {
const random = Math.random()
if (random > 0.98) {
const prefixedSay = msg => console.log(`Sent news update: '${msg}'`) || say('_Breaking news:_\n' + msg)
setTimeout(() => jokes.newsAlert(prefixedSay).catch(logError), 3000)
} else if (random > 0.96) {
setTimeout(async () => await say('_Say have you heard this one?_'), 3000)
setTimeout(() => jokes.tellJoke(say).catch(logError), 4000)
}
}
const commas = num => num.toLocaleString()
const getItemHeader = user => ([itemName, {baseCost, description, earning, emoji}]) => {
const itemCost = commas(user ? calculateCost({itemName, user}) : baseCost)
return `*${itemName}* :${emoji}: - ${itemCost} HVAC Coins - ${commas(earning)} CPS\n_${description}_`
}
const helpText = (highestCoins, user) => Object.entries(buyableItems)
.filter(([, value]) => value.baseCost < (highestCoins || 1) * 101)
.map(getItemHeader(user))
.join('\n\n') +
'\n\n:grey_question::grey_question::grey_question:' +
'\n\nJust type \'!buy item_name\' to purchase' +
'\n\nNote: Listed prices are _base costs_ and will increase as you buy more.'
const getUpgradeEmoji = upgrade => upgrade.emoji || buyableItems[upgrade.type].emoji
const upgradeText = user =>
(!user ? '' : '\n\n' + Object.entries(upgrades).filter(([upgradeName, upgrade]) => !hasUpgrade(user, upgrade, upgradeName)).filter(([, upgrade]) => upgrade.condition(user)).map(([key, value]) => `:${getUpgradeEmoji(value)}: *${key}* - ${commas(value.cost)}\n_${value.description}_`).join('\n\n')) +
'\n\n:grey_question::grey_question::grey_question:' +
'\n\nJust type \'!upgrade upgrade_name\' to purchase'
const game = JSON.parse(fs.readFileSync(saveFile, 'utf-8'))
const { users, nfts } = game
const saveGame = () => fs.writeFile(saveFile, JSON.stringify(game), logError)
const getSeconds = () => new Date().getTime() / 1000
const setHighestCoins = userId => {
const prevMax = users[userId].highestEver || 0
if (prevMax < users[userId].coins) {
users[userId].highestEver = users[userId].coins
}
}
const hasUpgrade = (user, upgrade, upgradeName) => user.upgrades[upgrade.type]?.includes(upgradeName)
const addAchievement = async (user, achievementName, say) => {
if (user.achievements[achievementName]) {
return
}
user.achievements[achievementName] = true
saveGame()
await say(`You earned the achievement ${achievements[achievementName].name}!`)
}
// I'm super not confident this will stay
// What's nice is that it centralizes
// * calling with event,say,words
// * checking for words[1]==='help'
const commands = new Map()
const command = (commandNames, helpText, action, condition) => {
commandNames.forEach(name => commands.set(name, {
helpText,
action,
condition: condition || (() => true)
}))
}
command( // I don't like that command() is at the highest indentation level instead of the route name
['!t', '!test'],
'HelpText: Testing out new routing functionality',
async ({ event, say, words }) => {// This extra indendation sucks
await say('This is the route acton') // Also the function can't go after command() - not initialized
}
)
slack.onMessage(async ({ event, say }) => {
const words = event?.text?.split(/\s+/) || []
const c = commands.get(words[0])
if (event.user !== slack.sageUserId) {
return // Don't do anything while this is incubating
}
if (!c?.condition(event.user)) {
await say(`Command '${words[0]} not found'`)
return
}
if (words[1] === 'help') {
await say(c.helpText)
return
}
await c.action({ event, say, words })
})
// End of new command action
const listAchievements = async ({ event, say }) => {
const user = getUser(event.user)
const achievementCount = Object.keys(user.achievements).length
const prefix = `You have ${achievementCount} achievements!\n\n`
const mult = (Math.pow(1.01, achievementCount) - 1) * 100
const list = Object.keys(user.achievements)
.map(name => achievements[name])
.map(({description, emoji, name}) => `:${emoji}: *${name}* - ${description}`)
.join('\n')
const postfix = achievementCount ? `\n\n_Achievements are boosting your CPS by ${mult.toPrecision(3)}%_` : ''
await say(prefix + list + postfix)
}
const getItemCps = (user, itemName) => {
const achievements = Object.keys(user.achievements || {}).length
const achievementMultiplier = Math.pow(1.01, achievements)
const baseCps = (user.items[itemName] || 0) * buyableItems[itemName].earning
const itemUpgrades = (user.upgrades[itemName] || []).map(name => upgrades[name])
return achievementMultiplier * itemUpgrades.reduce((totalCps, upgrade) => upgrade.effect(totalCps, user), baseCps)
}
const emojiLine = (itemName, countOwned) => countOwned < 5
? `:${buyableItems[itemName].emoji}:`.repeat(countOwned)
: `:${buyableItems[itemName].emoji}: x${countOwned}`
const collection = userId =>
Object.entries(users[userId]?.items || {})
.map(([itemName, countOwned]) => emojiLine(itemName, countOwned) + ' - ' + commas(getItemCps(getUser(userId), itemName)) + ' cps')
.join('\n')
const getCPS = userId => {
const user = getUser(userId)
const userItems = user?.items || {}
const userGeneralUpgrades = user?.upgrades?.general || []
const cpsFromItems = Object.keys(userItems).reduce((total, itemName) => total + getItemCps(getUser(userId), itemName), 0)
return Object.entries(userGeneralUpgrades).reduce((total, [, upgradeName]) => upgrades[upgradeName].effect(total, user), cpsFromItems)
}
const getCoins = userId => {
const user = users[userId]
const currentTime = getSeconds()
const lastCheck = user.lastCheck || currentTime
const secondsPassed = currentTime - lastCheck
user.coins += getCPS(userId) * secondsPassed
user.coins = Math.floor(user.coins)
user.lastCheck = currentTime
setHighestCoins(userId)
saveGame()
return user.coins
}
const mineCoinHelp = 'Mine HVAC coins: `!coin` or `!c`'
const mineCoin = async ({ event, say, words }) => {
const user = getUser(event.user)
if (words[1] === 'help') {
await say(mineCoinHelp)
return
}
maybeNews(say)
const random = Math.random()
const c = getCoins(event.user)
const secondsOfCps = seconds => Math.floor(getCPS(event.user) * seconds)
let diff
let prefix
if (random > 0.9967) {
diff = 500 + Math.floor(c * 0.10) + secondsOfCps(60 * 30)
prefix = `:gem: You found a lucky gem worth ${commas(diff)} HVAC!\n`
await addAchievement(user, 'luckyGem', say)
await slack.messageSage(`${slack.ourUsers[event.user]} FOUND A LUCKY GEM COIN WORTH ${commas(diff)} HVAC!`)
} else if (random > 0.986) {
diff = 50 + Math.floor(c * 0.025) + secondsOfCps(60)
prefix = `:goldbrick: You found a lucky gold coin worth ${commas(diff)} HVAC!\n`
await slack.messageSage(`${slack.ourUsers[event.user]} found a lucky gold coin worth ${commas(diff)} HVAC!`)
await addAchievement(user, 'goldBrick', say)
} else if (random > 0.96) {
diff = 10 + Math.floor(c * 0.01) + secondsOfCps(10)
prefix = `:money_with_wings: You found a lucky green coin worth ${commas(diff)} HVAC!\n`
await addAchievement(user, 'greenCoin', say)
} else {
prefix = `You mined one HVAC.\n`
diff = 1
}
user.coins += diff
await say(`${prefix}You now have ${commas(user.coins)} HVAC coin` + (c !== 0 ? 's' : '') + '. Spend wisely.')
saveGame()
}
const gambleCoinHelp = 'Gamble away your HVAC: `!gamble` or `!g`\n'+
'To use, say `!gamble coin_amount` or `!gamble all`'
const gambleCoin = async ({ event, say, words }) => {
if (words[1] === 'help') {
await say(gambleCoinHelp)
return
}
if (!users[event.user]) {
users[event.user] = {
coins: 0
}
}
let n
let currentCoins = getCoins(event.user)
if (words[1].toLowerCase() === 'all') {
if (currentCoins === 0) {
await say('You don\'t have any coins!')
return
}
n = currentCoins
} else {
n = parseInt(event.text.substring(7))
}
if (!n || n < 0) {
await say(`Invalid number '${n}'`)
return
}
if (currentCoins < n) {
await say(`You don\'t have that many coins! You have ${commas(currentCoins)}.`)
return
}
users[event.user].coins -= n
let outcome
if (Math.random() > 0.5) {
users[event.user].coins += (2 * n)
outcome = 'won'
} else {
outcome = 'lost'
}
console.log(`They ${outcome}`)
await say(`You bet ${commas(n)} coins and ${outcome}! You now have ${commas(users[event.user].coins)}.`)
saveGame()
}
const calculateCost = ({ itemName, user, quantity = 1 }) => {
let currentlyOwned = user.items[itemName] || 0
let realCost = 0
for (let i = 0; i < quantity; i++) {
realCost += Math.ceil(buyableItems[itemName].baseCost * Math.pow(1.15, currentlyOwned || 0))
currentlyOwned += 1
}
return realCost
}
const buyNftHelp = 'Acquire high-quality art: `!buynft`\n'+
'To use, say `!buynft nft_name`'
const buyNft = async ({ event, say, words }) => {
if (words[1] === 'help') {
await say(buyNftHelp)
return
}
const nft = nfts.find(n => n.name.toLowerCase() === words[1])
if (!nft) {
const suffix = words[1].match(/[^a-z0-9]/i) ? '. And I\'m unhackable, so cut it out.' : ''
await say('No NFT with that name found' + suffix)
return
}
if (nft.owner) {
await say('Someone already owns that NFT!')
return
}
const c = getCoins(event.user)
if (c < nft.price) {
await say('You don\'t have enough coin for this nft')
return
}
users[event.user].coins -= nft.price
nft.owner = event.user
saveGame()
await say('You bought ' + nft.name + '!')
}
const getUser = userId => {
if (!users[userId]) {
users[userId] = {
coins: 0,
items: {},
upgrades: {},
achievements: {}
}
} else {
users[userId].items ??= {}
users[userId].upgrades ??= {}
users[userId].achievements ??= {}
}
return users[userId]
}
const buyUpgradeHelp = 'Improve the performance of your HVAC-generators. `!upgrade` or `!u`\n'+
'Say `!upgrade` to list available upgrades, or `!upgrade upgrade_name` to purchase.'
const buyUpgrade = async ({ userId, say, words }) => {
if (words[1] === 'help') {
await say(buyUpgradeHelp)
return
}
const user = getUser(userId)
const upgradeName = words[1]
if (!upgradeName) {
await say(upgradeText(user))
return
}
const upgrade = upgrades[upgradeName]
if (!upgrade) {
await say('An upgrade with that name does not exist!')
return
}
if (!user.upgrades[upgrade.type]) {
user.upgrades[upgrade.type] = []
}
if (hasUpgrade(user, upgrade, upgradeName)) {
await say('You already have that upgrade!')
return
}
if (!upgrade.condition(user)) {
await say('That item does not exist!')
return
}
const c = getCoins(userId)
if (c < upgrade.cost) {
await say(`You don't have enough coins! You have ${commas(c)}, but you need ${commas(upgrade.cost)}`)
return
}
user.coins -= upgrade.cost
user.upgrades[upgrade.type].push(upgradeName)
await saveGame()
await say(`You bought ${upgradeName}!`)
}
const buyItemHelp = 'Buy new items to earn HVAC with: `!buy` or `!b`\n'+
'Use `!buy` to list available items, and `!buy item_name optional_quantity` to get \'em.'
const buyItem = async ({ event, say, words }) => {
if (words[1] === 'help') {
await say(buyItemHelp)
return
}
const user = getUser(event.user)
const buying = words[1]
const quantity = parseInt(words[2] || '1')
if (!quantity || quantity < 1) {
await say('Quantity must be a positive integer')
return
}
setHighestCoins(event.user)
if (!buying) {
const highestCoins = user.highestEver || user.coins || 1
if (buyableItems.quade.baseCost < highestCoins * 100) {
await addAchievement(user, 'seeTheQuade', say)
}
await say(helpText(highestCoins, user))
return
}
const buyable = buyableItems[buying]
if (!buyable) {
await say('That item does not exist!')
return
}
const realCost = calculateCost({itemName: buying, user, quantity})
const currentCoins = getCoins(event.user)
if (currentCoins < realCost) {
await say(`You don't have enough coins! You have ${commas(currentCoins)}, but you need ${commas(realCost)}`)
return
}
user.coins -= realCost
user.items[buying] = user.items[buying] || 0
user.items[buying] += quantity
if (quantity === 1) {
await say(`You bought one ${buying}`)
} else {
await say(`You bought ${quantity} ${buying}`)
}
saveGame()
}
const getCoinCommand = async ({ target, say }) => {
if (!target?.startsWith('<@') || !target.endsWith('>')) {
await say('Target must be a valid @')
return
}
const targetId = target.substring(2, target.length - 1)
if (targetId !== 'U0344TFA7HQ') {
const user = getUser(targetId)
await say(`<@${targetId}> has ${commas(user.coins)} HVAC.`)
} else {
const members = (await slack.app.client.conversations.members({channel: slack.temperatureChannelId})).members
const humanMembers = members.filter(name => name.length === 11)
await say(`Hvacker owns ${humanMembers.length} souls.`)
}
}
const gift = async ({ userId, words, say }) => {
const user = getUser(userId)
const [, target, amountText] = words
const amount = amountText === 'all' ? (getCoins(userId)) : parseInt(amountText)
if (!amount || amount < 0) {
await say('Amount must be a positive integer!')
return
}
if (!target?.startsWith('<@') || !target.endsWith('>')) {
await say('Target must be a valid @')
return
}
if (user.coins < amount) {
await say(`You don't have that many coins! You have ${commas(user.coins)} HVAC.`)
return
}
const targetId = target.substring(2, target.length - 1)
const targetUser = getUser(targetId)
user.coins -= amount
targetUser.coins += amount
await say(`Gifted ${commas(amount)} HVAC to <@${targetId}>`)
}
slack.onMessage(async ({ event, say }) => {
// if (event?.text?.startsWith('!') && event.user !== slack.sageUserId) {
// await say('Hvacker is taking a quick nap.')
// return
// }
const words = event?.text?.split(/\s+/) || []
console.log(event?.text, 'by', slack?.ourUsers[event?.user], 'at', new Date().toLocaleTimeString())
switch (words[0]) {
case '!c':
case '!coin':
return mineCoin({ event, say, words })
case '!g':
case '!gamble':
return gambleCoin({ event, say, words })
case '!b':
case '!buy': {
return buyItem({event, say, words})
}
case '!u':
case '!upgrade':
return buyUpgrade({userId: event.user, say, words})
case '!cps':
return say(`You are currently earning \`${commas(getCPS(event.user))}\` HVAC Coin per second.`)
case '!gift':
case '!give':
return gift({userId: event.user, words, say})
case '!check':
return getCoinCommand({target: words[1], say})
case '!s':
case '!status':
return say(
`You are currently earning \`${commas(getCPS(event.user))}\` HVAC Coin per second.\n\n` +
`You currently have ${commas(getCoins(event.user))} HVAC Coins\n\n` +
`${collection(event.user)}\n\n`
)
case '!a':
return listAchievements({ event, say })
case '!nfts':
const owner = nft => `Owner: *${slack.ourUsers[nft.owner] || 'NONE'}*`
const nftDisplay = nft => `_"${nft.name}"_\n\n${nft.description}\n\n${commas(nft.price)} HVAC.\n\n${nft.picture}\n\n${owner(nft)}`
const filter = words[1] ? nft => words[1]?.toLowerCase() === nft.name : null
return say(nfts
.filter(filter || (() => true))
.map(nftDisplay)
.join('\n-------------------------\n') || (filter ? 'No NFTs with that name exist' : 'No NFTs currently exist.')
)
case '!lb':
const user = getUser(event.user)
return say('```' +
Object.entries(users)
.filter(([id]) => getCPS(id) !== 0)
.sort(([id], [id2]) => getCPS(id) > getCPS(id2) ? -1 : 1)
.map(([id]) => `${slack.ourUsers[id] || '???'} - ${commas(getCPS(id))} CPS - ${commas(getCoins(id))} HVAC`)
.join('\n') + '```').then(() => addAchievement(user, 'leaderBoardViewer', say))
case '!buynft':
return buyNft({event, say, words})
case '!ligma':
return say(':hvacker_angery:')
case '!pog':
return say('<https://i.imgur.com/XCg7WDz.png|poggers>')
}
if (event.user === slack.sageUserId) {
const firstWord = words[0]
if (firstWord === '!addnft') {
const [, name, price] = event.text.substring(0, event.text.indexOf('\n')).split(' ')
const rest = event.text.substring(event.text.indexOf('\n') + 1)
const desc = rest.substring(0, rest.indexOf('\n'))
const picture = rest.substring(rest.indexOf('\n') + 1)
const newNft = {
name,
price: parseInt(price.replace(/,/g, '')),
description: desc,
picture,
owner: null
}
nfts.push(newNft)
console.log('addedNft', newNft)
return saveGame()
} else if (firstWord === '!s') {
const target = words[1]
const targetId = target.substring(2, target.length - 1)
maybeNews(say)
return say(
`${target} are currently earning \`${commas(getCPS(targetId))}\` HVAC Coin per second.\n\n` +
`They currently have ${commas(getCoins(targetId))} HVAC Coins\n\n` +
`${collection(targetId)}\n\n`
)
} else if (firstWord === '!updatenft') {
const nft = nfts.find(n => n.name.toLowerCase() === 'quackers')
nft.price *= 100
return saveGame()
}
}
})

140
src/games/coins/upgrades.js Normal file
View File

@ -0,0 +1,140 @@
const basic = ({ type, description, count, cost }) => ({
type,
description,
condition: user => user.items[type] >= count,
cost,
effect: itemCps => itemCps * 2
})
module.exports = {
doubleClick: basic({
type: 'mouse',
description: 'Doubles the power of mice',
count: 1,
cost: 1_000,
}),
biggerTeeth: basic({
type: 'mouse',
description: 'Mice can intimidate twice as much HVAC out of their victims.',
count: 25,
cost: 50_000,
}),
fasterComputers: basic({
type: 'accountant',
description: 'Accountants can ~steal~ optimize twice as much HVAC!',
count: 1,
cost: 11_000,
}),
widerBrains: basic({
type: 'accountant',
description: 'For accountant do double of thinking.',
count: 25,
cost: 550_000,
}),
biggerBlowhole: basic({
type: 'whale',
description: 'With all that extra air, whales have double power!',
count: 1,
cost: 120_000,
}),
thinnerWater: basic({
type: 'whale',
description: 'Whales can move twice as quickly through this physics-defying liquid',
count: 25,
cost: 6_000_000,
}),
greasyTracks: basic({
type: 'train',
description: 'Lets trains deliver HVAC twice as efficiently',
count: 1,
cost: 1_300_000,
}),
loudConductors: basic({
type: 'train',
description: 'Conductors can onboard twice as much HVAC',
count: 25,
cost: 65_000_000,
}),
gasolineFire: basic({
type: 'fire',
description: 'Extremely good for breathing in.',
count: 1,
cost: 14_000_000,
}),
cavemanFire: basic({
type: 'fire',
description: 'They just don\'t make \'em like they used to.',
count: 25,
cost: 700_000_000,
}),
spoonerang: basic({
type: 'boomerang',
description: 'Scoops up HVAC mid-flight',
count: 1,
cost: 200_000_000,
}),
doubleRang: basic({
type: 'boomerang',
description: 'You throw one, but somehow catch two',
count: 25,
cost: 10_000_000_000,
}),
lunarPower: basic({
type: 'moon',
description: 'Out with the sol, in with the lun!',
count: 1,
cost: 3_300_000_000,
}),
doubleCraters: basic({
type: 'moon',
description: 'Making every side look like the dark side.',
count: 25,
cost: 165_000_000_000,
}),
glassButterfly: basic({
type: 'butterfly',
description: 'Not your grandma\'s universe manipulation.',
count: 1,
cost: 51_000_000_000,
}),
quadWing: basic({
type: 'butterfly',
description: 'Sounds a lot like a trillion bees buzzing inside your head.',
count: 25,
cost: 2_550_000_000_000,
}),
silverMirror: basic({
type: 'mirror',
description: 'Excellent for stabbing vampires.',
count: 1,
cost: 750_000_000_000,
}),
window: basic({
type: 'mirror',
description: 'Only through looking around you can you acquire the self reflection necessary to control the thermostat.',
count: 25,
cost: 37_500_000_000_000,
}),
fzero: basic({
type: 'quade',
description: 'Brings out his competitive spirit.',
count: 1,
cost: 10_000_000_000_000,
}),
adam: basic({
type: 'quade',
description: 'He could probably reach the thermostat if he wanted.',
count: 25,
cost: 500_000_000_000_000,
}),
homage: {
type: 'general',
description: 'The power of original ideas increases your overall CPS by 10%',
condition: user => Object.entries(user.items).reduce((total, [, countOwned]) => countOwned + total, 0) >= 200,
emoji: 'cookie',
cost: 10_000_000_000,
effect: (itemCps, user) => Math.ceil(itemCps * 1.1)
},
}

123
src/games/connect4.js Normal file
View File

@ -0,0 +1,123 @@
const routine = require('./routine')
const emptyBoard = [
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',]
]
const textFromBoard = board => {
const makeRow = r => ` |${r[0]}|${r[1]}|${r[2]}|${r[3]}|${r[4]}|${r[5]}|${r[6]}|`
return board.map(makeRow).join('\n') + '\n' +
' -----------------\n' +
' 1-2-3-4-5-6-7'
}
const numEmojis = ['one', 'two', 'three', 'four', 'five', 'six', 'seven']
const checkRows = board => {
for (const row of board) {
const match = row.join('').match(/(XXXX)|(OOOO)/)
if (match) {
return match[0][0]
}
}
return null
}
const checkColumns = board => {
const colToText = i =>
board[0][i] +
board[1][i] +
board[2][i] +
board[3][i] +
board[4][i] +
board[5][i]
for (let i = 0; i < 6; i++) {
const match = colToText(i).match(/(XXXX)|(OOOO)/)
if (match) {
return match[0][0]
}
}
return null
}
const checkDiagonals = board => {
for (let row = 0; row < 3; row++) {
// Starting top-left
for (let col = 0; col < 4; col++) {
if (board[row][col] !== ' ' &&
board[row][col] === board[row + 1][col + 1] &&
board[row][col] === board[row + 2][col + 2] &&
board[row][col] === board[row + 3][col + 3]
){
return board[row][col]
}
}
// Starting top-right
for (let col = 3; col < 7; col++) {
if (board[row][col] !== ' ' &&
board[row][col] === board[row + 1][col - 1] &&
board[row][col] === board[row + 2][col - 2] &&
board[row][col] === board[row + 3][col - 3]
){
return board[row][col]
}
}
}
return null
}
const checkFull = board => {
for (const row of board) {
for (const col of row) {
if (col !== ' ') {
return null
}
}
}
return routine.tie
}
const getTurn = board => {
let x = 0
let o = 0
for (const row of board) {
for (const col of row) {
if (col === 'X') {
x += 1
} else if (col === 'O') {
o += 1
}
}
}
return x > o ? 'O' : 'X'
}
const placeAt = (i, board, char) => {
for (let y = 5; y >= 0; y--) {
if (board[y][i] === ' ') {
board[y][i] = char
return true
}
}
return false
}
const makeMove = (emoji, board) =>
placeAt(numEmojis.indexOf(emoji), board, getTurn(board))
routine.build({
startTriggers: ['connect 4', 'c4'],
initialBoard: () => emptyBoard,
turnChoiceEmojis: numEmojis,
gameName: 'Connect 4',
textFromBoard,
checkWinner: board => checkRows(board) || checkColumns(board) || checkDiagonals(board) || checkFull(board),
makeMove
})

4
src/games/index.js Normal file
View File

@ -0,0 +1,4 @@
require('./connect4')
require('./tictactoe')
require('./jokes')
require('./coins')

108
src/games/jokes.js Normal file
View File

@ -0,0 +1,108 @@
const slack = require('../slack')
// TODO: Move jokes/news into their own files, and let hvacker edit them when !addjoke or !addnews are used
const jokes = [
['What do you call a duck that steals things from the bathroom?', 'A robber ducky.'],
['On what side does a duck have the most feathers?', 'The outside.'],
['Why did the duck cross the playground?', 'To get to the other slide.'],
['Why do ducks fly south for the winter?', 'It\'s too far to waddle.'],
['Why do ducks lay eggs?', 'They would break if they dropped them.'],
['Why do ducks quack?', 'Well, because they can\'t oink, or moo, or bark.'],
['Why do ducks fly south for the winter?', 'It\'s too far to waddle.'],
['Why did the duck cross the road?', 'To show the chicken how to do it.'],
// Puns:
['Why do ducks make good detectives?', 'Because they always quack the case!'],
['When does a duck get up in the morning?', 'At the quack of dawn!'],
['What do you call a duck that loves fireworks?', 'A fire-quacker.'],
['What did the duck say to the waiter?', '"Put it on my bill."'],
['Where do sick ducks go?', 'To the Ductor!'],
['What kind of TV shows do ducks watch?', 'Duckumenteries!'],
['What type of food do you get when you cross a duck with a mole?', 'Duckamole!'],
['What did the duck say when he dropped the dishes?', '"I hope I didn\'t quack any!"'],
['What is a duck\'s favourite game?', '"Beak-a-boo!"'],
['Why did the duck cross the road?', 'Because there was a quack in the pavement!'],
['What has webbed feet and fangs?', 'Count Duckula!'],
['What do ducks get after they eat?', 'A bill.'],
['What do ducks eat with their soup?', 'Quackers.'],
['What happens when you say something funny to a duck?', 'It quacks up.'],
['What\'s a duck\'s favourite ballet?', 'The Nutquacker.'],
['What do ducks say when people throw things at them?', '"Time to duck!"'],
['Why are ducks so good at fixing things?', 'Because they\'re great at using duck-tape!'],
['What do you get when you put a bunch of rubber ducks in a box?', 'A box of quackers.'],
['Why was the teacher annoyed with the duck?', 'Because it wouldn\'t stop quacking jokes!'],
['What did the duck eat for a snack?', 'Salted quackers!'],
['What do you call a rude duck?', 'A duck with a quackitude.'],
['What did the lawyer say to the duck in court?', '"I demand an egg-splanation!"'],
['How can you tell rubber ducks apart?', 'You can\'t, they look egg-xactly the same!'],
['Why are ducks good at budgeting?', 'They know how to handle the bills!'],
['Where do tough ducks come from?', 'Hard-boiled eggs.'],
['Why do ducks have webbed feet?', 'To stomp out fires.', 'Why do elephants have big feet?', 'To stomp out burning ducks.'],
['Why do ducks check the news?', 'For the feather forecast.'],
['What did the ducks carry their schoolbooks in?', 'Their quack-packs.'],
['What do you call it when it\'s raining ducks and chickens?', 'Fowl weather.'],
['Why did the duck get a red card in the football game?', 'For Fowl-play.'],
['What did the duck say to the spider?', '"Why don\'t you have webbed feet?"'],
['What do you get if you cross a duck with an accountant?', 'A bill with a bill.'],
['What do you call a duck\'s burp?', 'A fowl smell!'],
['What do you get if you cross a vampire, duck and a sheep?', 'Count Duck-ewe-la.'],
['What do you call a duck that\'s very clever?', 'A wise-quacker.'],
['Why do ducks never ask for directions?', 'They prefer to wing it.'],
// I wrote dis
['What did the man say to his wife when a duck flew at her head?', '"Look out! There\'s a duck flying at your head!"'],
['What kind of duck plays goalie?', 'A hockey duck.'],
['What kind of bird gets a job here?', 'A software duckveloper!'],
['How many ducks does it take to screw in a light bulb?', 'Ducks do not live indoors.'],
['What kind of drug does a duck like?', 'Quack.']
]
const news = [
'Duck criminal escapes from duck prison. Whereabouts unknown.',
'Criminal mastermind duck believed to have taken refuge on Slack.',
'Infamous _"quackers"_ NFT may be related to recent Chicago crime spree. More at 11.',
'Six geese arrested under suspicion of honking.',
'Swan under investigation for illegal trumpet-smuggling ring.',
'Local rooster the subject of serious egg-stealing allegations.',
'Inducknesian court rules that the news is certainly _not_ controlled by an all-powerful duck lord, and that everyone should please stop asking about it.',
'"Spending 6,000,000,000 HVAC on a space yacht was the best decision of my life", reveals local billionaire/jerk.',
'16 Unethical Egg Hacks You Won\'t Learn In School. You can\'t believe number 5!',
'The word "Tuesday" declared illegal. Refer to it as "Quackday" from now on.',
'Has anyone been killed/maimed/publicly humiliated for not following the duck lord\'s orders? The answer might surprise you.',
'_They called him crazy -_ Local duck bores to the center of the earth and lives with the mole people.',
'Jerry Seinfeld - Love affair with a hen! Is this the new Hollywood power couple?',
'Is your uncle secretly a goose? There\'s simply no way of knowing.',
'Danny Duckvito considers opening his own chain of supermarkets. He is quoted as saying "Heyyyy, I\'m Danny Duckvito"',
'Slack-related-gambling epidemic! People around the globe are betting their digital lives away.'
]
const tellJoke = async say => {
const joke = jokes[Math.floor(Math.random() * jokes.length)]
let timeout = 0
for (const part of joke) {
setTimeout(async () => await say(part), timeout);
timeout += 5000
}
}
const newsAlert = async say => {
let article = lastNews
while(article === lastNews) {
article = Math.floor(Math.random() * news.length)
}
lastNews = article
await say(news[article])
}
let lastNews = -1
slack.onMessage(async ({ event, say }) => {
if (event.text?.toLowerCase() === '!joke') {
await tellJoke(say)
} else if (event.text?.toLowerCase() === '!news') {
await newsAlert(say)
}
})
module.exports = {
tellJoke,
newsAlert,
}

145
src/games/routine.js Normal file
View File

@ -0,0 +1,145 @@
const slack = require('../slack')
const tie = 'TIE'
const messageFromBoard = ({ dataName, gameName, textFromBoard, board, player1, player2 }) =>
gameName + ' between ' + player1.toUpperCase() + ' and ' + player2.toUpperCase() + ' ' + encodeGame(dataName, board, [player1, player2]) + '\n' +
'```' + textFromBoard(board) + '\n```'
const addChoiceEmojis = async ({ choices, channel, ts }) => {
const addEmoji = async emojiName =>
await slack.app.client.reactions.add({
channel,
timestamp: ts,
name: emojiName
})
for (const choice of choices) {
await addEmoji(choice)
}
}
const buildGameStarter = ({ startTriggers, dataName, gameName, textFromBoard, initialBoard, turnChoiceEmojis }) => async ({ event, say }) => {
if (event.channel_type === 'im') {
const eventText = event.text?.toLowerCase()
if (eventText && startTriggers.find(keyword => eventText.startsWith('!' + keyword))) {
const opponent = event.text.toUpperCase().match(/<@[^>]*>/)[0]
const msg = messageFromBoard({
dataName,
gameName,
textFromBoard,
board: initialBoard(),
player1: '<@' + event.user + '>',
player2: opponent
})
const sent = await say(msg)
await addChoiceEmojis({...sent, choices: turnChoiceEmojis})
}
}
}
const encodeGame = (dataKey, board, players) => slack.encodeData(dataKey, {board, players})
const decodeGame = (dataKey, message) => slack.decodeData(dataKey, message)
const getMessages = winner => {
const buildMessage = state => !winner ? '' : winner === tie ? '\nIt\'s a tie!' : `\nYou ${state}!`
return {
you: buildMessage('win'),
opponent: buildMessage('lost')
}
}
const buildTurnHandler = ({ gameName, dataName, checkWinner, textFromBoard, turnChoiceEmojis, makeMove }) => async ({ event, say }) => {
if (event.item_user !== slack.hvackerBotUserId || !turnChoiceEmojis.includes(event.reaction)) {
return
}
const message = await slack.getMessage(event.item)
if (!message.messages[0]?.text?.includes(gameName)) {
return
}
const game = decodeGame(dataName, message.messages[0].text)
if (!game) {
return
}
const { board, players } = game
let winner = checkWinner(board)
if (winner) {
return
}
const [player1, player2] = players
let opponent = (player1.includes(event.user) ? player2 : player1)
opponent = opponent.replace(/[^A-Z0-9]/gi, '').toUpperCase()
if (!makeMove(event.reaction, board)) {
await say('You can\'t go there!')
return
}
winner = checkWinner(board)
const boardMessage = messageFromBoard({
dataName,
gameName,
textFromBoard,
board,
player1,
player2
})
const winnerMessages = getMessages(winner)
await say(boardMessage + winnerMessages.you)
if (!winner) {
await say('Waiting for opponent\'s response...')
}
const removeEmoji = async emojiName =>
slack.app.client.reactions.remove({
channel: event.item.channel,
timestamp: message.messages[0]?.ts,
name: emojiName
})
turnChoiceEmojis.forEach(removeEmoji)
const sentBoard = await slack.app.client.chat.postMessage({
channel: opponent,
text: boardMessage + winnerMessages.opponent
})
if (!winner) {
await addChoiceEmojis({...sentBoard, choices: turnChoiceEmojis})
}
}
module.exports = {
tie,
build: ({
startTriggers,
initialBoard,
turnChoiceEmojis,
gameName,
textFromBoard,
checkWinner,
makeMove,
}) => {
const dataName = gameName.replace(/[^0-9a-zA-Z]/gi, '')
const gameStarter = buildGameStarter({
startTriggers,
gameName,
dataName,
initialBoard,
textFromBoard,
turnChoiceEmojis
});
slack.onMessage(gameStarter)
const turnHandler = buildTurnHandler({
gameName,
dataName,
checkWinner,
textFromBoard,
turnChoiceEmojis,
makeMove
})
slack.onReaction(turnHandler)
}
}

74
src/games/tictactoe.js Normal file
View File

@ -0,0 +1,74 @@
const routine = require("./routine");
const emptyBoard = [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']
const textFromBoard = board =>
` ${board[0]} | ${board[1]} | ${board[2]} \n` +
`-----------\n` +
` ${board[3]} | ${board[4]} | ${board[5]} \n` +
`-----------\n` +
` ${board[6]} | ${board[7]} | ${board[8]}`
const numEmojis = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']
const winningThrees = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
]
const checkWinner = board => {
for (const [a, b, c] of winningThrees) {
if (board[a] !== ' ' && board[a] === board[b] && board[a] === board[c]) {
return board[a]
}
}
for (let i = 0; i < 9; i++) {
if (board[i] === ' ') {
return null
}
}
return routine.tie
}
const getTurn = board => {
let x = 0
let o = 0
board.forEach(spot => {
if (spot === 'X') {
x += 1
} else if (spot === 'O') {
o += 1
}
})
return x > o ? 'O' : 'X'
}
const placeAt = (i, board, char) => {
if (board[i] === ' ') {
board[i] = char
return true
}
return false
}
const applyTurn = (emoji, board) =>
placeAt(numEmojis.indexOf(emoji), board, getTurn(board))
routine.build({
startTriggers: ['ttt', 'tictactoe', 'tic-tac-toe'],
initialBoard: () => emptyBoard,
turnChoiceEmojis: ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'],
gameName: 'Tic Tac Toe',
textFromBoard,
makeMove: applyTurn,
checkWinner
})

18
src/honeywell/index.js Normal file
View File

@ -0,0 +1,18 @@
const postToHoneywell = options => {}
const postTemp = ({ heatSetpoint, coolSetpoint, mode}) => {
postToHoneywell({
heatSetpoint,
coolSetpoint,
mode
/* thermostatSetpointStatus or autoChangeoverActive depending on TCC or LCC device.
* or maybe nothing, the docs aren't crazy clear */
})
}
const getCurrentTemp = () => 76
module.exports = {
postTemp,
getCurrentTemp
}

58
src/index.js Normal file
View File

@ -0,0 +1,58 @@
const { postTemp, getCurrentTemp } = require('./honeywell')
const { onTempChangeRequested } = require('./slack')
require('./games')
const coolMode = 'Cool'
const heatMode = 'Heat'
const minTemp = 68
const maxTemp = 80
let lowTemp = 72
let highTemp = 74
const cleanTemp = temp => {
if (temp > maxTemp) {
temp = maxTemp
} else if (temp < minTemp) {
temp = minTemp
}
return temp
}
onTempChangeRequested(change => {
const { indoorTemperature } = getCurrentTemp()
switch (change) {
case 'Hotter': {
lowTemp += 2
highTemp += 2
break;
}
case 'Colder': {
lowTemp -= 2
highTemp -= 2
break;
}
case 'Good': {
return
}
}
highTemp = cleanTemp(highTemp)
lowTemp = cleanTemp(lowTemp)
const mode =
indoorTemperature < lowTemp ? heatMode : // Heat if lower than low
indoorTemperature > highTemp ? coolMode : // Cool if hotter than high
change === 'Hotter' ? heatMode : coolMode // Otherwise (lower priority) follow the requested change
if (!mode) {
return
}
postTemp({
coolSetpoint: lowTemp,
heatSetpoint: highTemp,
mode
})
})

235
src/slack/index.js Normal file
View File

@ -0,0 +1,235 @@
const { App: SlackApp } = require('@slack/bolt')
const config = require('../config')
const temperatureChannelId = 'C034156CE03'
const hvackerBotUserId = 'U0344TFA7HQ'
const sageUserId = 'U028BMEBWBV'
const pollingMinutes = 10
const pollingPeriod = 1000 * 60 * pollingMinutes
const colderEmoji = 'snowflake'
const hotterEmoji = 'fire'
const goodEmoji = '+1'
let app
try {
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)
// })
} catch (e) {
console.log('Failed to initialize SlackApp', e)
}
const pollTriggers = ['!temp', '!temperature', '!imhot', '!imcold']
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` +
'At this time I am not capable of actually changing the temperature. Go bug Quade.'
})
}
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 }) => {
for (const listener of reactionListeners) {
listener({ event, say })
}
})
const ourUsers = {
'U028BMEBWBV': 'Sage',
'U02U15RFK4Y': 'Adam',
'U02AAB54V34': 'Houston',
'U02KYLVK1GV': 'Quade',
'U017PG4EL1Y': 'Max',
'UTDLFGZA5': 'Tyler'
}
let activePolls = {}
app.event('message', async ({ event, context, client, say }) => {
console.log(event)
for (const listener of messageListeners) {
listener({ event, say })
}
const words = event.text?.split('\s')
if (event.user === 'U028BMEBWBV' && event.channel === 'D0347Q4H9FE') {
if (event.text?.startsWith('!say ') || event.text?.startsWith('!say\n')) {
await postToTechThermostatChannel(event.text.substring(4).trim())
return
}
if (event.text?.startsWith('!saytoq ')) {
await messageQuade(event.text.substring(7).trim())
return
}
if (event.text?.startsWith('!saytos')) {
await messageSage(event.text.substring(7).trim())
return
}
}
let eventText = event.text?.toLowerCase() || ''
if (eventText.startsWith('!help')) {
await sendHelp(say)
return
}
if (!pollTriggers.includes(eventText)) {
if (halfTriggers.includes(eventText)) {
await sendHelp(say, 'It looks like you might want to change the temperature.')
}
return
}
if (activePolls[event.channel]) {
await postToTechThermostatChannel({ text: "There's already an active poll in this channel!" })
return
}
activePolls[event.channel] = true
const pollTs = await startPoll()
setTimeout(async () => {
const reactions = await app.client.reactions.get({
channel: temperatureChannelId,
timestamp: pollTs,
full: true
})
const reactCounts = {}
reactions.message.reactions.forEach(reaction => reactCounts[reaction.name] = reaction.count)
const contentVotes = reactCounts[goodEmoji]
const hotterVotes = reactCounts[hotterEmoji]
const colderVotes = reactCounts[colderEmoji]
let text = 'The people have spoken, and would like to '
if (hotterVotes > colderVotes && hotterVotes > contentVotes) {
text += 'raise the temperature, quack.'
requestTempChange('Hotter')
} else if (colderVotes > hotterVotes && colderVotes > contentVotes) {
text += 'lower the temperature, quack quack.'
requestTempChange('Colder')
} else {
text += 'keep the temperature as-is, quaaack.'
requestTempChange('Good')
}
await postToTechThermostatChannel({ text })
delete activePolls[event.channel]
}, pollingPeriod)
})
;(async () => {
await app.start().catch(console.error)
console.log('Slack Bolt has started')
//setTimeout(async () => {
// await messageSage('<https://i.imgur.com/VCvfvdz.png|...>')
//}, 2000)
})();
const postToTechThermostatChannel = async optionsOrText => {
if (optionsOrText === null || typeof optionsOrText !== 'object') {
optionsOrText = {
text: optionsOrText
}
}
return await app.client.chat.postMessage({...optionsOrText, channel: temperatureChannelId})
}
const messageSage = async optionsOrText => messageIn(sageUserId, optionsOrText)
const messageQuade = async optionsOrText => messageIn('U02KYLVK1GV', optionsOrText)
const messageIn = async (channel, optionsOrText) => {
if (optionsOrText === null || typeof optionsOrText !== 'object') {
optionsOrText = {
text: optionsOrText
}
}
return await app.client.chat.postMessage({...optionsOrText, channel})
}
const startPoll = async () => {
const sent = await postToTechThermostatChannel({
text: `<!here|here> Temperature poll requested! In ${pollingMinutes} minute(s) the temperature will be adjusted.\n` +
`Pick :${colderEmoji}: if you want it colder, :${hotterEmoji}: if you want it hotter, or :${goodEmoji}: if you like it how it is.` +
`\n(Note that I can't actually change the temperature yet. Make Quade do it!)`
})
const addReaction = async emojiName =>
await app.client.reactions.add({
channel: temperatureChannelId,
timestamp: sent.ts,
name: emojiName
})
await addReaction(colderEmoji)
await addReaction(hotterEmoji)
await addReaction(goodEmoji)
return sent.ts
}
const tempChangeListeners = []
const messageListeners = []
const reactionListeners = []
const requestTempChange = change => {
tempChangeListeners.forEach(listener => listener(change))
}
const encodeData = (key, data) =>
`<http://${key}ZZZ${Buffer.from(JSON.stringify(data), 'utf-8').toString('base64')}| >`
const decodeData = (key, message) => {
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'))
}
const onReaction = listener => reactionListeners.push(listener)
onReaction(async ({ event, say }) => {
if (event.user === sageUserId && event.reaction === 'x') {
console.log(event)
await app.client.chat.delete({ channel: event.item.channel, ts: event.item.ts })
}
})
module.exports = {
app,
hvackerBotUserId,
temperatureChannelId,
onAction: app.action,
getMessage,
updateMessage: app.client.chat.update,
postToTechThermostatChannel,
onTempChangeRequested: listener => tempChangeListeners.push(listener),
onMessage: listener => messageListeners.push(listener),
onReaction,
encodeData,
decodeData,
sageUserId,
messageSage,
ourUsers
}