├── .gitignore ├── LICENSE.txt ├── Procfile ├── README.md ├── bot.js ├── package.json └── public ├── favicon.ico ├── index.html ├── style.css ├── tv-bg-squiggle-matte.png └── tv-bg-squiggle.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Configs 2 | nodemon.json 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 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 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 31 | node_modules 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Totally Viable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node bot.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # snitch 2 | Spy on our Slack room 3 | 4 | ![https://s3.amazonaws.com/f.cl.ly/items/1I1x3c3G3i1N2n05402c/Snitch_2.jpg?v=27606b6a](https://s3.amazonaws.com/f.cl.ly/items/1I1x3c3G3i1N2n05402c/Snitch_2.jpg?v=27606b6a) 5 | -------------------------------------------------------------------------------- /bot.js: -------------------------------------------------------------------------------- 1 | var os = require('os'); 2 | var util = require('util'); 3 | var _ = require('underscore'); 4 | var s = require("underscore.string"); 5 | 6 | var Botkit = require('botkit'); 7 | 8 | var express = require('express'); 9 | var app = express(); 10 | var http = require('http').Server(app); 11 | var request = require('request'); 12 | var io = require('socket.io')(http); 13 | 14 | var redis = require('redis'), 15 | redis_client = redis.createClient(process.env.REDIS_URL); 16 | 17 | // logs 18 | 19 | function _log(){ 20 | console.log.apply(console, arguments); 21 | } 22 | 23 | function _error(){ 24 | console.error.apply(console, arguments); 25 | } 26 | 27 | function _dump(){ 28 | _.each(arguments, function(arg){ 29 | _log(util.inspect(arg)); 30 | }); 31 | } 32 | 33 | // web server 34 | 35 | if (! process.env.PORT) { 36 | process.env.PORT = 3000; 37 | } 38 | 39 | app.use(express.static('public')); 40 | 41 | http.listen(process.env.PORT, function(){ 42 | _log('listening on port ' + process.env.PORT); 43 | }); 44 | 45 | app.get("/file/:file_id/:variant.:ext?", function(req, res){ 46 | get_file(req.params.file_id, function(err, file){ 47 | file = JSON.parse(file); 48 | 49 | if (err || ! file) { 50 | return request('https://slack-imgs.com/?url=null&width=360&height=250').pipe(res); 51 | } 52 | 53 | // TODO: check if file mode is hosted or external 54 | 55 | var url = file[req.params.variant]; 56 | 57 | if (! url) { 58 | return request('https://slack-imgs.com/?url=null&width=360&height=250').pipe(res); 59 | } 60 | 61 | request({ 62 | url: url, 63 | headers: { 64 | 'Authorization': 'Bearer ' + bot.config.token 65 | } 66 | }).pipe(res); 67 | }); 68 | }); 69 | 70 | var cache = { 71 | users: {}, 72 | bots: {}, 73 | channels: {}, 74 | }; 75 | 76 | // slackbot 77 | 78 | if (! process.env.SLACK_BOT_TOKEN) { 79 | _error('Error: Specify SLACK_BOT_TOKEN in environment'); 80 | process.exit(1); 81 | } 82 | 83 | var controller = Botkit.slackbot({ 84 | debug: true, 85 | }); 86 | 87 | var bot = controller.spawn({ 88 | simple_latest: true, 89 | no_unreads: true, 90 | token: process.env.SLACK_BOT_TOKEN 91 | }).startRTM(function(err, bot, res){ 92 | if (err || ! res.ok) { 93 | _error("Error with startRTM, crashing..."); 94 | process.exit(1); 95 | } 96 | 97 | cache.users = {}; 98 | _.each(res.users, function(item){ 99 | cache.users[item.id] = item; 100 | }); 101 | 102 | _.each(res.bots, function(item){ 103 | cache.bots[item.id] = item; 104 | }); 105 | 106 | cache.channels = {}; 107 | _.each(res.channels, function(item){ 108 | cache.channels[item.id] = item; 109 | }); 110 | }); 111 | 112 | // integrations 113 | 114 | controller.on('channel_created', function(bot, message){ 115 | cache_list('channels'); 116 | 117 | // TODO: update clients 118 | }); 119 | 120 | 121 | controller.on('channel_deleted', function(bot, message){ 122 | cache_list('channels'); 123 | 124 | // TODO: update clients 125 | }); 126 | 127 | 128 | controller.on('channel_rename', function(bot, message){ 129 | cache_list('channels'); 130 | 131 | // TODO: update clients 132 | }); 133 | 134 | 135 | controller.on('channel_archive', function(bot, message){ 136 | cache_list('channels'); 137 | 138 | // TODO: update clients 139 | }); 140 | 141 | 142 | controller.on('channel_unarchive', function(bot, message){ 143 | cache_list('channels'); 144 | 145 | // TODO: update clients 146 | }); 147 | 148 | 149 | controller.on('user_channel_join', function(bot, message){ 150 | cache_list('channels'); 151 | 152 | // TODO: update clients 153 | // _log(util.inspect(message)); 154 | }); 155 | 156 | 157 | controller.on('channel_leave', function(bot, message){ 158 | cache_list('channels'); 159 | 160 | // TODO: update clients 161 | // _log(util.inspect(message)); 162 | }); 163 | 164 | 165 | controller.on('team_join', function(bot, message){ 166 | bot.botkit.log("[TEAM JOIN] " + message.user.name); 167 | 168 | cache.users[message.user.id] = message.user; 169 | 170 | // TODO: update clients 171 | }); 172 | 173 | 174 | controller.on('user_change', function(bot, message){ 175 | bot.botkit.log("[USER CHANGE] " + message.user.name); 176 | 177 | cache.users[message.user.id] = message.user; 178 | 179 | // TODO: update clients 180 | }); 181 | 182 | 183 | controller.on('bot_added', function(bot, message){ 184 | bot.botkit.log("[BOT ADDED] " + message.bot.name); 185 | 186 | cache.bots[message.bot.id] = message.bot; 187 | 188 | // TODO: update clients 189 | }); 190 | 191 | 192 | controller.on('bot_changed', function(bot, message){ 193 | bot.botkit.log("[BOT UPDATED] " + message.bot.name); 194 | 195 | cache.bots[message.bot.id] = message.bot; 196 | 197 | // TODO: update clients 198 | }); 199 | 200 | 201 | controller.on('ambient', function(bot, message){ 202 | bot.botkit.log("[AMBIENT] " + message.text); 203 | 204 | io.emit('message', sanitized_message(message)); 205 | 206 | save_message(message.channel, message.ts, message); 207 | 208 | bot.api.reactions.add({ 209 | timestamp: message.ts, 210 | channel: message.channel, 211 | name: 'white_small_square', 212 | }, emoji_reaction_error_callback); 213 | }); 214 | 215 | 216 | controller.on('bot_message', function(bot, message){ 217 | bot.botkit.log("[BOT MESSAGE] " + message.text); 218 | 219 | io.emit('message', sanitized_message(message)); 220 | 221 | save_message(message.channel, message.ts, message); 222 | 223 | bot.api.reactions.add({ 224 | timestamp: message.ts, 225 | channel: message.channel, 226 | name: 'white_small_square', 227 | }, emoji_reaction_error_callback); 228 | }); 229 | 230 | 231 | controller.on('me_message', function(bot, message){ 232 | message.text = "/me " + message.text; 233 | 234 | bot.botkit.log("[ME MESSAGE] " + message.text); 235 | 236 | io.emit('message', sanitized_message(message)); 237 | 238 | save_message(message.channel, message.ts, message); 239 | 240 | bot.api.reactions.add({ 241 | timestamp: message.ts, 242 | channel: message.channel, 243 | name: 'white_small_square', 244 | }, emoji_reaction_error_callback); 245 | }); 246 | 247 | 248 | controller.on('message_changed', function(bot, message){ 249 | bot.botkit.log("[MESSAGE CHANGED] " + util.inspect(message)); 250 | 251 | // copy channel to actual message, so it won't get lost on edit 252 | message.message.channel = message.channel; 253 | 254 | // TODO: update clients 255 | 256 | update_message(message.channel, message.message.ts, message.message); 257 | 258 | if (message.file) { 259 | update_file(message.file); 260 | } 261 | 262 | bot.api.reactions.add({ 263 | timestamp: message.message.ts, 264 | channel: message.channel, 265 | name: 'small_orange_diamond', 266 | }, emoji_reaction_error_callback); 267 | }); 268 | 269 | 270 | controller.on('message_deleted', function(bot, message){ 271 | bot.botkit.log("[MESSAGE DELETED] " + util.inspect(message)); 272 | 273 | // TODO: update clients 274 | 275 | delete_message(message.channel, message.deleted_ts); 276 | 277 | bot.startPrivateConversation(message.previous_message, function(err, dm){ 278 | dm.say("I removed the message you deleted from the public record."); 279 | }); 280 | }); 281 | 282 | 283 | controller.on('user_typing', function(bot, message){ 284 | bot.botkit.log('[TYPING] ' + message.user); 285 | 286 | io.emit('typing', { 287 | channel: sanitized_channel(message.channel), 288 | user: sanitized_user(message.user) 289 | }); 290 | }); 291 | 292 | 293 | controller.on('file_share', function(bot, message){ 294 | _dump("[file_share]", message); 295 | 296 | io.emit('message', sanitized_message(message)); 297 | 298 | save_message(message.channel, message.ts, message); 299 | 300 | save_file(message.file); 301 | 302 | bot.api.reactions.add({ 303 | file: message.file.id, 304 | name: 'white_small_square', 305 | }, emoji_reaction_error_callback); 306 | }); 307 | 308 | 309 | controller.on('file_change', function(bot, message){ 310 | _dump("[file_change]", message); 311 | 312 | update_file(message.file); 313 | 314 | bot.api.reactions.add({ 315 | file: message.file.id, 316 | name: 'small_orange_diamond', 317 | }, emoji_reaction_error_callback); 318 | }); 319 | 320 | 321 | controller.on('file_deleted', function(bot, message){ 322 | _dump("[file_deleted]", message); 323 | 324 | delete_file(message.file_id); 325 | }); 326 | 327 | 328 | io.on('connection', function (socket) { 329 | socket.on('request_backfill', function(data){ 330 | _.each(_.keys(cache.channels), function(channel_id){ 331 | get_recent_messages(channel_id, 300, function(err, response){ 332 | if (err) throw err; 333 | 334 | _.each(response.reverse(), function(message){ 335 | var message = JSON.parse(message); 336 | 337 | message = sanitized_message(message); 338 | 339 | message.is_backfill = true; 340 | 341 | socket.emit('message', message); 342 | }); 343 | }); 344 | }); 345 | 346 | socket.emit('backfill_complete', true); 347 | }); 348 | }); 349 | 350 | controller.on('tick', function(bot, message){}); 351 | 352 | 353 | controller.hears(['shutdown'],'direct_message,direct_mention,mention',function(bot, message){ 354 | 355 | bot.startConversation(message, function(err, convo){ 356 | convo.ask('Are you sure you want me to shutdown?',[ 357 | { 358 | pattern: bot.utterances.yes, 359 | callback: function(response, convo) { 360 | convo.say('Bye!'); 361 | convo.next(); 362 | setTimeout(function() { 363 | process.exit(); 364 | },3000); 365 | } 366 | }, 367 | { 368 | pattern: bot.utterances.no, 369 | default: true, 370 | callback: function(response, convo) { 371 | convo.say('*Phew!*'); 372 | convo.next(); 373 | } 374 | } 375 | ]); 376 | }); 377 | }); 378 | 379 | 380 | controller.hears(['uptime'],'direct_message,direct_mention,mention',function(bot, message) { 381 | 382 | var hostname = os.hostname(); 383 | var uptime = process.uptime(); 384 | var unit = 'second'; 385 | 386 | if (uptime > 60) { 387 | uptime = uptime / 60; 388 | unit = 'minute'; 389 | } 390 | 391 | if (uptime > 60) { 392 | uptime = uptime / 60; 393 | unit = 'hour'; 394 | } 395 | 396 | if (uptime != 1) { 397 | unit = unit + 's'; 398 | } 399 | 400 | uptime = uptime + ' ' + unit; 401 | 402 | bot.reply(message,':robot_face: I am a bot named <@' + bot.identity.name + '>. I have been running for ' + uptime + ' on ' + hostname + '.'); 403 | }); 404 | 405 | // helpers 406 | 407 | function save_message(channel_id, ts, message) { 408 | redis_client.zadd("channels." + channel_id, ts, JSON.stringify(message), redis.print); 409 | 410 | // redis_client.zremrangebyscore("channels." + channel_id, ts, ts - (60 * 5)); // 86400 411 | } 412 | 413 | function delete_message(channel_id, ts) { 414 | redis_client.zremrangebyscore("channels." + channel_id, ts, ts, redis.print); 415 | } 416 | 417 | function update_message(channel_id, ts, message) { 418 | delete_message(channel_id, ts); 419 | save_message(channel_id, ts, message); 420 | } 421 | 422 | function get_recent_messages(channel_id, count, callback) { 423 | redis_client.zrevrangebyscore(["channels." + channel_id, "+inf", "-inf", "LIMIT", 0, count], callback) 424 | } 425 | 426 | function save_file(file) { 427 | redis_client.set("files." + file.id, JSON.stringify(file), redis.print); 428 | } 429 | 430 | function delete_file(file_id) { 431 | redis_client.del("files." + file_id, redis.print); 432 | } 433 | 434 | function update_file(file) { 435 | delete_file(file.id); 436 | save_file(file); 437 | } 438 | 439 | function get_file(file_id, callback) { 440 | redis_client.get("files." + file_id, callback) 441 | } 442 | 443 | function cache_list(variant) { 444 | var options = { 445 | api_method: variant, 446 | response_wrapper: variant 447 | }; 448 | 449 | // allow overriding abnormal param names 450 | 451 | if (variant == 'users') { 452 | options.response_wrapper = 'members'; 453 | } 454 | 455 | bot.api[options.api_method].list({}, function(err, res){ 456 | if (err || ! res.ok) bot.botkit.log("Error calling bot.api." + options.api_method + ".list"); 457 | 458 | cache[options.api_method] = {}; 459 | 460 | _.each(res[options.response_wrapper], function(item){ 461 | cache[options.api_method][item.id] = item; 462 | }); 463 | 464 | // _log(util.inspect(cache[options.api_method])); 465 | }); 466 | } 467 | 468 | function sanitized_message(message){ 469 | var example = { 470 | user: { }, // sanitized_user(message.channel) 471 | channel: { }, // sanitized_channel(message.user) 472 | 473 | ts: 1234567890.12345, 474 | 475 | attachments: { 476 | file: { 477 | byline: "", 478 | name: "", 479 | low_res: "", 480 | high_res: "", 481 | initial_comment: "" 482 | }, 483 | inline: [ 484 | { 485 | color: "red", 486 | 487 | pretext: "", 488 | 489 | author: { 490 | name: "", 491 | subname: "", 492 | icon: "" 493 | }, 494 | 495 | inline_title: "", 496 | inline_title_link: "", 497 | 498 | inline_text: "", 499 | 500 | fields: [ 501 | { 502 | field_title: "", 503 | field_value: "", 504 | short: true 505 | } 506 | ], 507 | 508 | image_url: "", 509 | thumb_url: "" 510 | } 511 | ] 512 | } 513 | }; 514 | 515 | 516 | var alt_payload = undefined; 517 | if (message.bot_id) alt_payload = message; 518 | 519 | var response = { 520 | ts: message.ts, 521 | user: sanitized_user(message.user || message.bot_id, alt_payload), 522 | channel: sanitized_channel(message.channel), 523 | text: reformat_message_text(message.text) 524 | }; 525 | 526 | if (message.file) { 527 | response.attachments = response.attachments || {}; 528 | response.attachments.file = sanitized_message_attachment_file(message.file); 529 | 530 | delete response.text; 531 | } 532 | 533 | if (_.size(message.attachments) > 0) { 534 | response.attachments = response.attachments || {}; 535 | response.attachments.inline = response.attachments.inline || []; 536 | 537 | _.each(message.attachments, function(attachment){ 538 | response.attachments.inline.push(sanitized_message_attachment_inline(attachment)); 539 | }); 540 | } 541 | 542 | return response; 543 | } 544 | 545 | function sanitized_user(user, alt_payload){ 546 | var user = cache.users[user] || cache.bots[user]; 547 | 548 | if (! user) { 549 | bot.botkit.log("Could not find cached user: " + user); 550 | return { name: 'Anonymous', profile: {} }; 551 | } 552 | 553 | user.is_bot = !! cache.bots[user.id]; 554 | 555 | user = _.pick(user, 'name', 'color', 'profile', 'icons', 'is_bot'); 556 | 557 | user.profile = _.pick(user.profile, 'first_name', 'last_name', 'real_name', 'image_72'); 558 | 559 | if (user.icons) { 560 | user.profile.image = user.icons.image_72; 561 | } else { 562 | user.profile.image = user.profile.image_72; 563 | } 564 | 565 | // allow overriding cache with alt payload (e.g. from bot_message) 566 | if (alt_payload && alt_payload.username) { 567 | user.name = alt_payload.username; 568 | } 569 | 570 | if (alt_payload && _.size(_.omit(alt_payload.icons, "emoji")) > 0) { 571 | user.profile.image = _.last(_.values(_.omit(alt_payload.icons, "emoji"))); 572 | } 573 | 574 | return user; 575 | } 576 | 577 | function sanitized_channel(channel){ 578 | var channel = cache.channels[channel]; 579 | 580 | if (! channel) { 581 | bot.botkit.log("Could not find cached channel: " + channel); 582 | return { name: 'channel', members: {} }; 583 | } 584 | 585 | channel = _.pick(channel, 'name', 'topic', 'members'); 586 | 587 | if (channel.topic) channel.topic = channel.topic.value; 588 | 589 | var members = {}; 590 | _.each(channel.members, function(id){ 591 | var user = sanitized_user(id); 592 | members[user.name] = user; 593 | }); 594 | channel.members = members; 595 | 596 | return channel; 597 | } 598 | 599 | function sanitized_message_attachment_file(file){ 600 | if (! file) { 601 | return false; 602 | } 603 | 604 | var response = { 605 | name: file.title, 606 | full_res: "/file/" + file.id + "/url_private." + file.filetype 607 | }; 608 | 609 | if (file.mode == "hosted" && s.startsWith(file.mimetype, "image/")) { 610 | response.low_res = "/file/" + file.id + "/thumb_360." + file.filetype; 611 | } else { 612 | response.download_url = "/file/" + file.id + "/url_private_download." + file.filetype; 613 | } 614 | 615 | var byline = ["uploaded"]; 616 | 617 | if (file.initial_comment) { 618 | byline.push("and commented on"); 619 | } 620 | 621 | if (file.mode == "hosted") { 622 | if (s.startsWith(file.mimetype, "image/")) { 623 | byline.push("an image"); 624 | } else { 625 | byline.push("a file"); 626 | } 627 | } else { 628 | // TODO: proper indefinite article 629 | byline.push("a " + file.pretty_type + " file"); 630 | } 631 | 632 | response.byline = byline.join(" "); 633 | 634 | if (file.initial_comment) { 635 | response.initial_comment = reformat_message_text(file.initial_comment.comment); 636 | } 637 | 638 | return response; 639 | } 640 | 641 | function sanitized_message_attachment_inline(attachment){ 642 | var response = _.pick(attachment, "color", "pretext", "fields", "image_url", "thumb_url"); 643 | 644 | // TODO: prefer video_html (over thumb_url) 645 | 646 | response.inline_title = attachment.title; 647 | response.inline_title_link = attachment.title_link; 648 | 649 | if (attachment.author_name || attachment.author_subname || attachment.author_icon) { 650 | response.author = { 651 | name: attachment.author_name, 652 | subname: attachment.author_subname, 653 | icon: attachment.author_icon 654 | }; 655 | } 656 | 657 | if (attachment.pretext) { 658 | response.pretext = reformat_message_text(attachment.pretext); 659 | } 660 | 661 | if (attachment.text) { 662 | response.inline_text = reformat_message_text(attachment.text); 663 | } 664 | 665 | if (response.color && ! s.startsWith(response.color, "#")) { 666 | response.color = "#" + response.color; 667 | } 668 | 669 | return response; 670 | } 671 | 672 | function reformat_message_text(text) { 673 | // https://api.slack.com/docs/formatting 674 | text = text.replace(/<([@#!])?([^>|]+)(?:\|([^>]+))?>/g, (function(_this) { 675 | return function(m, type, link, label) { 676 | var channel, user; 677 | 678 | switch (type) { 679 | case '@': 680 | if (label) return label; 681 | 682 | user = cache.users[link]; 683 | 684 | if (user) return "@" + user.name; 685 | 686 | break; 687 | case '#': 688 | if (label) return label; 689 | 690 | channel = cache.channels[link]; 691 | 692 | if (channel) return "\#" + channel.name; 693 | 694 | break; 695 | case '!': 696 | if (['channel','group','everyone','here'].indexOf(link) >= 0) { 697 | return "@" + link; 698 | } 699 | 700 | break; 701 | default: 702 | if (label) { 703 | return "" + label + ""; 704 | } else { 705 | return "" + link.replace(/^mailto:/, '') + ""; 706 | } 707 | } 708 | }; 709 | })(this)); 710 | 711 | // nl2br 712 | text = text.replace(/\n/g, "
"); 713 | 714 | // me_message 715 | if (text.indexOf("/me") === 0) { 716 | text = "" + text + ""; 717 | } 718 | 719 | return text; 720 | } 721 | 722 | function emoji_reaction_error_callback(err, res) { 723 | if (err) { 724 | bot.botkit.log('Failed to add emoji reaction :( ', err); 725 | } 726 | } 727 | 728 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snitch", 3 | "version": "1.0.0", 4 | "description": "Let people spy on your Slack", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/totallyviable/snitch.git" 8 | }, 9 | "keywords": [ 10 | "slack", 11 | "bot", 12 | "slackbot" 13 | ], 14 | "bugs": { 15 | "url": "https://github.com/totallyviable/snitch/issues" 16 | }, 17 | "main": "bot.js", 18 | "scripts": { 19 | "test": "echo \"Error: no test specified\" && exit 1" 20 | }, 21 | "author": "Totally Viable", 22 | "license": "MIT", 23 | "dependencies": { 24 | "botkit": "0.0.5", 25 | "express": "^4.13.3", 26 | "mustache": "^2.2.1", 27 | "redis": "^2.4.2", 28 | "request": "^2.69.0", 29 | "socket.io": "^1.4.3", 30 | "underscore": "^1.8.3", 31 | "underscore.string": "^3.2.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/totallyviable/snitch/ab6594b494b1d135c3c7983fe1f12a830874dfc8/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Totally Viable 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 42 | 43 | 44 | 46 | 47 | 78 | 79 | 84 | 85 | 209 | 210 | 305 | 306 | 315 | 316 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | font: 13px normal Helvetica, Arial, sans-serif; 9 | padding: 10px; 10 | background: #f4fdfb center fixed no-repeat url('/tv-bg-squiggle-matte.png'); 11 | } 12 | 13 | a, a:hover { 14 | color: #0ae5ab; 15 | } 16 | 17 | span.emoji { 18 | display: -moz-inline-box; 19 | -moz-box-orient: vertical; 20 | display: inline-block; 21 | vertical-align: baseline; 22 | *vertical-align: auto; 23 | *zoom: 1; 24 | *display: inline; 25 | width: 1em; 26 | height: 1em; 27 | background-size: 1em; 28 | background-repeat: no-repeat; 29 | text-indent: -9999px; 30 | background-position: 50%, 50%; 31 | background-size: contain; 32 | } 33 | 34 | span.emoji-sizer { 35 | line-height: 0.81em; 36 | font-size: 1em; 37 | margin: -2px 0; 38 | } 39 | 40 | span.emoji-outer { 41 | display: -moz-inline-box; 42 | display: inline-block; 43 | *display: inline; 44 | height: 1em; 45 | width: 1em; 46 | } 47 | 48 | span.emoji-inner { 49 | display: -moz-inline-box; 50 | display: inline-block; 51 | text-indent: -9999px; 52 | width: 100%; 53 | height: 100%; 54 | vertical-align: -2px; 55 | *vertical-align: auto; 56 | *zoom: 1; 57 | } 58 | 59 | img.emoji { 60 | width: 1em; 61 | height: 1em; 62 | } 63 | 64 | .img-rounded { 65 | border-radius: 4px; 66 | } 67 | 68 | .flexbox { 69 | display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ 70 | display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ 71 | display: -ms-flexbox; /* TWEENER - IE 10 */ 72 | display: -webkit-flex; /* NEW - Chrome */ 73 | display: flex; /* NEW, Spec - Opera 12.1, Firefox 20+ */ 74 | } 75 | 76 | #navbar_container { 77 | position: fixed; 78 | bottom: 0; 79 | left: 0; 80 | right: 0; 81 | background: #f4fdfb; 82 | } 83 | 84 | .navbar { 85 | font-size: 18px; 86 | background: #fff; 87 | } 88 | 89 | .navbar p { 90 | margin-left: 10px; 91 | margin-right: 10px; 92 | } 93 | 94 | .navbar .note { 95 | font-size: 14px; 96 | color: #bbb; 97 | font-style: italic; 98 | } 99 | 100 | .navbar .note a, .navbar .note a:hover { 101 | color: inherit; 102 | text-decoration: underline; 103 | } 104 | 105 | #message_container { 106 | } 107 | 108 | .message { 109 | } 110 | 111 | .message .avatar { 112 | display: block; 113 | border-radius: 5px; 114 | width: 50px; 115 | margin-right: 15px; 116 | } 117 | 118 | .message .meta { 119 | margin-bottom: 5px; 120 | } 121 | 122 | .message .meta * { 123 | display: inline-block; 124 | } 125 | 126 | .message .meta .username { 127 | font-weight: bold; 128 | } 129 | 130 | .message .meta .channel, 131 | .message .meta .timestamp { 132 | margin-left: 7px; 133 | color: #c3cac8; 134 | } 135 | 136 | .message .meta .is_bot { 137 | background: #F7F7F7; 138 | color: #B4B4B4; 139 | padding: 2px 4px; 140 | margin-left: 7px; 141 | font-size: 0.75em; 142 | vertical-align: top; 143 | } 144 | 145 | .message .text { 146 | font-size: 18px; 147 | 148 | /* https://css-tricks.com/snippets/css/prevent-long-urls-from-breaking-out-of-container */ 149 | overflow-wrap: break-word; 150 | word-wrap: break-word; 151 | 152 | -ms-word-break: break-all; 153 | /* This is the dangerous one in WebKit, as it breaks things wherever */ 154 | word-break: break-all; 155 | /* Instead use this non-standard one: */ 156 | word-break: break-word; 157 | 158 | /* Adds a hyphen where the word breaks, if supported (No Blink) */ 159 | -ms-hyphens: auto; 160 | -moz-hyphens: auto; 161 | -webkit-hyphens: auto; 162 | hyphens: auto; 163 | } 164 | 165 | .message .text .me_message { 166 | font-style: italic; 167 | } 168 | 169 | .message .text .attachment_fallback { 170 | padding-top: 0; 171 | padding-bottom: 0; 172 | margin-top: 15px; 173 | } 174 | 175 | .message .attachment { 176 | margin-top: 5px; 177 | } 178 | 179 | .message .attachment .upload_byline { 180 | color: #B4B4B4; 181 | } 182 | 183 | .message .attachment .upload { 184 | margin-top: 10px; 185 | } 186 | 187 | .message .attachment .download { 188 | margin: 20px 0 10px 0; 189 | } 190 | 191 | .message .attachment .download a { 192 | font-size: 22px; 193 | background: #0ae5ab; 194 | padding: 10px; 195 | color: #fff; 196 | } 197 | 198 | .message .attachment .wrapper { 199 | border-left: 4px solid rgba(0,0,0, 0.075); 200 | padding: 5px 0 5px 15px; 201 | margin-top: 10px; 202 | } 203 | 204 | .message .attachment .wrapper .upload img { 205 | border: 1px solid rgba(0,0,0, 0.075); 206 | } 207 | 208 | .message .attachment .wrapper .thumb img { 209 | max-width: 75px; 210 | max-height: 75px; 211 | } 212 | 213 | .message .attachment .initial_comment { 214 | margin-top: 10px; 215 | } 216 | 217 | .message .attachment .title { 218 | margin-bottom: 5px; 219 | font-weight: bold; 220 | } 221 | 222 | .message .attachment .title.author .subname { 223 | font-weight: normal; 224 | color: #B4B4B4; 225 | } 226 | 227 | .message .attachment .fields { 228 | margin-top: 10px; 229 | } 230 | 231 | .message .attachment .fields .field { 232 | margin-bottom: 5px; 233 | } 234 | 235 | .message .attachment .fields .field .title { 236 | margin: 0; 237 | } 238 | 239 | .divider { 240 | padding: 15px 0; 241 | } 242 | 243 | .divider .contents { 244 | border-bottom: 1px solid rgba(0,0,0, 0.1); 245 | } -------------------------------------------------------------------------------- /public/tv-bg-squiggle-matte.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/totallyviable/snitch/ab6594b494b1d135c3c7983fe1f12a830874dfc8/public/tv-bg-squiggle-matte.png -------------------------------------------------------------------------------- /public/tv-bg-squiggle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/totallyviable/snitch/ab6594b494b1d135c3c7983fe1f12a830874dfc8/public/tv-bg-squiggle.png --------------------------------------------------------------------------------