├── .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 |
--------------------------------------------------------------------------------