├── .prettierrc.json ├── .gitattributes ├── data.json ├── package.json ├── README.md ├── .gitignore └── index.js /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /data.json: -------------------------------------------------------------------------------- 1 | { 2 | "bots": [ 3 | "ULX6HE0DN", 4 | "UL6A87539", 5 | "UMTK90DD0", 6 | "UL40UA54L", 7 | "UL9QGTAUA", 8 | "U01JD2MBVUY" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "node index.js" 4 | }, 5 | "main": "index.js", 6 | "dependencies": { 7 | "airtable": "0.7.2", 8 | "botkit": "0.7.4", 9 | "botkit-storage-redis": "^1.1.0", 10 | "bottleneck": "^2.19.5", 11 | "node-fetch": "2.6.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

The Banker presents himself with a bow. He overdoes it a little.

2 | 3 |

4 | 5 | Good Morrow Hackalacker, 6 | 7 | I shall serve your banking needs in the highly-exclusive Hack Club Slack community. 8 | 9 | In your service, 10 | —Bankbot 11 | 12 | > _Banker is proudly developed by children of Orpheus, the lord of hackers, on the [master](https://github.com/hackclub/bank-bot/tree/master) branch._ 13 | > 14 | > _All hail Orpheus, and the master branch._ 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Botkit = require('botkit'); 2 | var Airtable = require('airtable'); 3 | var Bottleneck = require('bottleneck'); 4 | var _ = require('lodash'); 5 | var fs = require('fs'); 6 | var fetch = require('node-fetch'); 7 | 8 | var rawData = fs.readFileSync('data.json'); 9 | var data = JSON.parse(rawData); 10 | 11 | var base = new Airtable({ 12 | apiKey: process.env.AIRTABLE_KEY, 13 | }).base(process.env.AIRTABLE_BASE); 14 | 15 | var redisConfig = { 16 | url: process.env.REDISCLOUD_URL, 17 | }; 18 | var redisStorage = require('botkit-storage-redis')(redisConfig); 19 | 20 | var startBalance = 0; 21 | 22 | var invoiceReplies = {}; 23 | 24 | console.log('Booting bank bot'); 25 | 26 | function createBalance(user, cb = () => {}) { 27 | console.log(`Creating balance for User ${user}`); 28 | 29 | base('bank').create( 30 | { 31 | User: user, 32 | Balance: startBalance, 33 | }, 34 | function (err, record) { 35 | if (err) { 36 | console.error(err); 37 | return; 38 | } 39 | console.log(`New balance created for User ${user}`); 40 | // console.log(record) 41 | cb(startBalance, record); 42 | } 43 | ); 44 | } 45 | 46 | function setBalance(id, amount, user, cb = () => {}) { 47 | console.log(`Changing balance for Record ${id} by ${amount}`); 48 | getBalance(user, (bal) => { 49 | base('bank').update( 50 | id, 51 | { 52 | Balance: bal + amount, 53 | }, 54 | (err, record) => { 55 | if (err) { 56 | console.error(err); 57 | return; 58 | } 59 | console.log(`Balance for Record ${id} set to ${bal + amount}`); 60 | cb(bal + amount, record); 61 | } 62 | ); 63 | }); 64 | } 65 | 66 | function getBalance(user, cb = () => {}) { 67 | console.log(`Retrieving balance for User ${user}`); 68 | 69 | base('bank') 70 | .select({ 71 | filterByFormula: `User = "${user}"`, 72 | }) 73 | .firstPage(function page(err, records) { 74 | if (err) { 75 | console.error(err); 76 | return; 77 | } 78 | 79 | if (records.length == 0) { 80 | console.log(`No balance found for User ${user}.`); 81 | createBalance(user, cb); 82 | } else { 83 | var record = records[0]; 84 | var fields = record.fields; 85 | var balance = fields['Balance']; 86 | console.log(`Balance for User ${user} is ${balance}`); 87 | console.log(fields); 88 | cb(balance, record); 89 | } 90 | }); 91 | } 92 | 93 | function getInvoice(id) { 94 | return new Promise((resolve, reject) => { 95 | base('invoices').find(id, (err, record) => { 96 | if (err) { 97 | console.error(err); 98 | reject(err); 99 | } 100 | resolve(record); 101 | }); 102 | }); 103 | } 104 | 105 | console.log('Booting banker bot'); 106 | 107 | var controller = Botkit.slackbot({ 108 | clientId: process.env.SLACK_CLIENT_ID, 109 | clientSecret: process.env.SLACK_CLIENT_SECRET, 110 | clientSigningSecret: process.env.SLACK_CLIENT_SIGNING_SECRET, 111 | scopes: ['bot', 'chat:write:bot'], 112 | storage: redisStorage, 113 | }); 114 | 115 | controller.setupWebserver(process.env.PORT, function (err, webserver) { 116 | controller.createWebhookEndpoints(controller.webserver); 117 | controller.createOauthEndpoints(controller.webserver); 118 | }); 119 | 120 | function matchData(str, pattern, keys, obj = {}) { 121 | var match = pattern.exec(str); 122 | 123 | if (match) { 124 | var text = _.head(match); 125 | var vals = _.tail(match); 126 | var zip = _.zipObject(keys, vals); 127 | _.defaults(obj, zip); 128 | return obj; 129 | } 130 | 131 | return null; 132 | } 133 | 134 | // @bot balance --> Returns my balance 135 | // @bot balance @zrl --> Returns zrl's balance 136 | var balancePattern = /^balance(?:\s+<@([A-z|0-9]+)>)?/i; 137 | controller.hears( 138 | balancePattern.source, 139 | 'direct_mention,direct_message,bot_message', 140 | async (bot, message) => { 141 | var { text, user } = message; 142 | var captures = balancePattern.exec(text); 143 | var target = captures[1] || user; 144 | 145 | const verifyResult = await verifyPayload(text); 146 | 147 | if (verifyResult[0] != 204) { 148 | bot.replyInThread(message, JSON.parse(verifyResult[1])['text']); 149 | } else { 150 | console.log( 151 | `Received balance request from User ${user} for User ${target}` 152 | ); 153 | console.log(message); 154 | 155 | getBalance(target, (balance) => { 156 | var reply = 157 | user == target 158 | ? `You have ${balance}gp in your account, hackalacker.` 159 | : `Ah yes, User <@${target}> (${target})—they have ${balance}gp.`; 160 | bot.replyInThread(message, reply); 161 | }); 162 | } 163 | } 164 | ); 165 | 166 | var invoice = async ( 167 | bot, 168 | channelType, 169 | sender, 170 | recipient, 171 | amount, 172 | note, 173 | replyCallback, 174 | ts, 175 | channelid 176 | ) => { 177 | if (sender == recipient) { 178 | console.log(`${sender} attempting to invoice theirself`); 179 | replyCallback(`What are you trying to pull here, <@${sender}>?`); 180 | 181 | return; 182 | } 183 | 184 | if (amount === 0) { 185 | console.log(`${sender} attempting to send 0gp`); 186 | replyCallback(`no`); 187 | 188 | return; 189 | } 190 | 191 | var replyNote = note ? ` for "${note}".` : '.'; 192 | 193 | replyCallback(`I shall invoice <@${recipient}> ${amount}gp` + replyNote); 194 | 195 | var invRecord = await createInvoice(sender, recipient, amount, replyNote); 196 | 197 | var isPrivate = false; 198 | 199 | invoiceReplies[invRecord.id] = replyCallback; 200 | 201 | bot.say({ 202 | user: '@' + recipient, 203 | channel: '@' + recipient, 204 | text: `Good morrow hackalacker. <@${sender}> has just sent you an invoice of ${amount}gp${replyNote} 205 | Reply with "@banker pay ${invRecord.id}".`, 206 | }); 207 | }; 208 | 209 | var txLimiter = new Bottleneck({ 210 | maxConcurrent: 1, 211 | }); 212 | 213 | var transfer = (args, cb) => txLimiter.submit(transferJob, args, cb); 214 | 215 | var transferJob = ( 216 | { bot, channelType, user, target, amount, note, ts, channelid }, 217 | replyCallback 218 | ) => { 219 | if (user == target) { 220 | console.log(`${user} attempting to transfer to theirself`); 221 | replyCallback(`What are you trying to pull here, <@${user}>?`); 222 | 223 | logTransaction(user, target, amount, note, false, 'Self transfer'); 224 | return; 225 | } 226 | 227 | getBalance(user, (userBalance, userRecord) => { 228 | if (userBalance < amount) { 229 | console.log(`User has insufficient funds`); 230 | replyCallback( 231 | `Regrettably, you only have ${userBalance}gp in your account.`, 232 | false 233 | ); 234 | 235 | logTransaction(user, target, amount, note, false, 'Insufficient funds'); 236 | } else { 237 | getBalance(target, (targetBalance, targetRecord) => { 238 | setBalance(userRecord.id, -amount, user); 239 | // Treats targetBalance+amount as a string concatenation. WHY??? 240 | setBalance(targetRecord.id, -(-amount), target); 241 | 242 | var replyNote = note ? ` for "${note}".` : '.'; 243 | 244 | replyCallback( 245 | `I shall transfer ${amount}gp to <@${target}> immediately` + 246 | replyNote, 247 | true 248 | ); 249 | 250 | var isPrivate = false; 251 | 252 | if (data.bots.includes(target)) { 253 | // send clean, splittable data string 254 | bot.say({ 255 | user: '@' + target, 256 | channel: '@' + target, 257 | text: `$$$ | <@${user}> | ${amount} | ${replyNote} | ${channelid} | ${ts}`, 258 | }); 259 | } else if (channelType == 'im') { 260 | bot.say({ 261 | user: '@' + target, 262 | channel: '@' + target, 263 | text: `Good morrow hackalacker. <@${user}> has just transferred ${amount}gp to your account${replyNote}`, 264 | }); 265 | 266 | isPrivate = true; 267 | } 268 | 269 | logTransaction(user, target, amount, note, true, '', isPrivate); 270 | }); 271 | } 272 | }); 273 | }; 274 | 275 | // log transactions in ledger 276 | // parameters: user, target, amount, note, success, log message, private 277 | function logTransaction(u, t, a, n, s, m, p) { 278 | if (p === undefined) p = false; 279 | 280 | console.log(parseInt(a)); 281 | 282 | base('ledger').create( 283 | { 284 | From: u, 285 | To: t, 286 | Amount: parseInt(a), 287 | Note: n, 288 | Success: s, 289 | 'Admin Note': m, 290 | Timestamp: Date.now(), 291 | Private: p, 292 | }, 293 | function (err, record) { 294 | if (err) { 295 | console.error(err); 296 | return; 297 | } 298 | console.log('New ledger transaction logged: ' + record.getId()); 299 | } 300 | ); 301 | } 302 | 303 | // log invoice on airtable 304 | function createInvoice(sender, recipient, amount, note) { 305 | return new Promise((resolve, reject) => { 306 | base('invoices').create( 307 | { 308 | From: sender, 309 | To: recipient, 310 | Amount: parseInt(amount), 311 | Reason: note, 312 | }, 313 | function (err, record) { 314 | if (err) { 315 | console.error(err); 316 | reject(err); 317 | } 318 | console.log('New invoice created:', record.getId()); 319 | resolve(record); 320 | } 321 | ); 322 | }); 323 | } 324 | 325 | // @bot give @zrl 100 --> Gives 100gp from my account to zrl's 326 | controller.hears( 327 | /give\s+<@([A-z|0-9]+)>\s+([0-9]+)(?:gp)?(?:\s+for\s+(.+))?/i, 328 | 'direct_mention,direct_message,bot_message', 329 | async (bot, message) => { 330 | // console.log(message) 331 | var { text, user, event, ts, channel } = message; 332 | 333 | const verifyResult = await verifyPayload(text); 334 | 335 | if (verifyResult[0] != 204) { 336 | bot.replyInThread(message, JSON.parse(verifyResult[1])['text']); 337 | } else { 338 | if (message.thread_ts) { 339 | ts = message.thread_ts; 340 | } 341 | if (message.type == 'bot_message' && !data.bots.includes(user)) return; 342 | 343 | console.log(`Processing give request from ${user}`); 344 | console.log(message); 345 | 346 | var target = message.match[1]; 347 | var amount = message.match[2]; 348 | var note = message.match[3] || ''; 349 | 350 | var replyCallback = (text) => bot.replyInThread(message, text); 351 | 352 | transfer( 353 | { 354 | bot, 355 | channelType: event['channel_type'], 356 | user, 357 | target, 358 | amount, 359 | note, 360 | ts, 361 | channelid: channel, 362 | }, 363 | replyCallback 364 | ); 365 | } 366 | } 367 | ); 368 | 369 | // @bot invoice @zrl 100 for stickers --> Creates invoice for 100gp & notifies @zrl 370 | 371 | controller.hears( 372 | /invoice\s+<@([A-z|0-9]+)>\s+([0-9]+)(?:gp)?(?:\s+for\s+(.+))?/i, 373 | 'direct_mention,direct_message,bot_message', 374 | async (bot, message) => { 375 | var { text, user, event, ts, channel } = message; 376 | 377 | const verifyResult = await verifyPayload(text); 378 | 379 | if (verifyResult[0] != 204) { 380 | bot.replyInThread(message, JSON.parse(verifyResult[1])['text']); 381 | } else { 382 | if (message.thread_ts) { 383 | ts = message.thread_ts; 384 | } 385 | if (message.type == 'bot_message' && !data.bots.includes(user)) return; 386 | 387 | console.log(`Processing invoice request from ${user}`); 388 | 389 | var target = message.match[1]; 390 | var amount = message.match[2]; 391 | var note = message.match[3] || ''; 392 | 393 | var replyCallback = (text) => bot.replyInThread(message, text); 394 | invoice( 395 | bot, 396 | event['channel_type'], 397 | user, 398 | target, 399 | amount, 400 | note, 401 | replyCallback, 402 | ts, 403 | channel 404 | ); 405 | } 406 | } 407 | ); 408 | 409 | // @bot pay rec182yhe902 --> pays an invoice 410 | 411 | controller.hears( 412 | /pay\s+([A-z|0-9]+)/i, 413 | 'direct_mention,direct_message,bot_message', 414 | async (bot, message) => { 415 | var { text, user, event, ts, channel } = message; 416 | 417 | const verifyResult = await verifyPayload(text); 418 | 419 | if (message.thread_ts) { 420 | ts = message.thread_ts; 421 | } 422 | if (message.type == 'bot_message' && !data.bots.includes(user)) return; 423 | 424 | console.log(`Processing invoice payment from ${user}`); 425 | 426 | var id = message.match[1]; 427 | var invRecord = await getInvoice(id); 428 | 429 | if (invRecord.fields['Paid']) { 430 | bot.replyInThread(message, "You've already paid this invoice!"); 431 | } 432 | var amount = invRecord.fields['Amount']; 433 | var target = invRecord.fields['From']; 434 | var note = `for invoice ${invRecord.id}`; 435 | var replyCallback = (text, wentThrough) => { 436 | bot.replyInThread(message, text); 437 | if (typeof invoiceReplies[id] == 'function' && wentThrough) { 438 | invoiceReplies[id]( 439 | `<@${user}> paid their invoice of ${amount} gp from <@${target}>${invRecord.fields['Reason']}` 440 | ); 441 | } 442 | }; 443 | 444 | transfer( 445 | { 446 | bot, 447 | channelType: channel.type, 448 | user, 449 | target, 450 | amount, 451 | note, 452 | ts, 453 | channelid: channel, 454 | }, 455 | replyCallback 456 | ); 457 | } 458 | ); 459 | 460 | controller.on('slash_command', async (bot, message) => { 461 | var { command, text, user_id, ts, channel } = message; 462 | var user = user_id; 463 | console.log(`Slash command received from ${user_id}: ${text}`); 464 | console.log(message); 465 | 466 | bot.replyAcknowledge(); 467 | 468 | const verifyResult = await verifyPayload(text); 469 | 470 | if (verifyResult[0] != 204) { 471 | bot.replyPrivateDelayed(message, JSON.parse(verifyResult[1])['text']); 472 | } else { 473 | if (message.channel_id == process.env.SLACK_SELF_ID) { 474 | bot.replyPublicDelayed( 475 | message, 476 | "Just fyi... You're talking to me already... no need for slash commands to summon me!" 477 | ); 478 | } else { 479 | if (command == '/give') { 480 | var pattern = /<@([A-z|0-9]+)\|.+>\s+([0-9]+)(?:gp)?(?:\s+for\s+(.+))?/; 481 | var match = pattern.exec(text); 482 | if (match) { 483 | var target = match[1]; 484 | var amount = match[2]; 485 | var note = match[3] || ''; 486 | 487 | var replyCallback = (text) => 488 | bot.replyPublicDelayed(message, { 489 | blocks: [ 490 | { 491 | type: 'section', 492 | text: { 493 | type: 'mrkdwn', 494 | text: text, 495 | }, 496 | }, 497 | { 498 | type: 'context', 499 | elements: [ 500 | { 501 | type: 'mrkdwn', 502 | text: `Transferred by <@${user_id}>`, 503 | }, 504 | ], 505 | }, 506 | ], 507 | }); 508 | 509 | transfer( 510 | { 511 | bot, 512 | channelType: 'public', 513 | user: user_id, 514 | target, 515 | amount, 516 | note, 517 | ts, 518 | channel, 519 | }, 520 | replyCallback 521 | ); 522 | } else { 523 | bot.replyPrivateDelayed( 524 | message, 525 | 'I do not understand! Please type your message as `/give @user [positive-amount]gp for [reason]`' 526 | ); 527 | } 528 | } 529 | 530 | if (command == '/balance') { 531 | var pattern = /(?:<@([A-z|0-9]+)\|.+>)?/i; 532 | var match = pattern.exec(text); 533 | if (match) { 534 | var target = match[1] || user; 535 | console.log( 536 | `Received balance request from User ${user} for User ${target}` 537 | ); 538 | getBalance(target, (balance) => { 539 | var reply = 540 | user == target 541 | ? `Ah yes, <@${target}> (${target}). You have ${balance}gp in your account, hackalacker.` 542 | : `Ah yes, <@${target}> (${target})—they have ${balance}gp.`; 543 | bot.replyPrivateDelayed(message, { 544 | blocks: [ 545 | { 546 | type: 'section', 547 | text: { 548 | type: 'mrkdwn', 549 | text: reply, 550 | }, 551 | }, 552 | { 553 | type: 'context', 554 | elements: [ 555 | { 556 | type: 'mrkdwn', 557 | text: `Requested by <@${user}>`, 558 | }, 559 | ], 560 | }, 561 | ], 562 | }); 563 | }); 564 | } 565 | } 566 | } 567 | } 568 | }); 569 | 570 | controller.hears('.*', 'direct_mention,direct_message', (bot, message) => { 571 | var { text, user } = message; 572 | console.log(`Received unhandled message from User ${user}:\n${text}`); 573 | 574 | // Ignore if reply is in a thread. Hack to work around infinite bot loops. 575 | if (_.has(message.event, 'parent_user_id')) return; 576 | 577 | bot.replyInThread(message, 'Pardon me, but I do not understand.'); 578 | }); 579 | 580 | let verifyPayload = async (data) => { 581 | const response = await fetch('https://slack.hosted.hackclub.com', { 582 | method: 'post', 583 | body: data 584 | }); 585 | const responseData = await response.text(); 586 | const status = await response.status; 587 | 588 | console.log("Data: " + responseData); 589 | console.log("Status: " + status) 590 | 591 | return [status, responseData]; 592 | }; 593 | --------------------------------------------------------------------------------