├── html ├── img │ ├── favicon.ico │ ├── glitch.png │ ├── patreon.png │ ├── twitterlogo.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-310x310.png │ ├── patreon_white.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── circle.svg │ ├── menu.svg │ ├── keyboard_arrow_up.svg │ ├── keyboard_arrow_down.svg │ ├── delete.svg │ ├── error.svg │ ├── close.svg │ ├── messages.svg │ ├── browserconfig.xml │ ├── star.svg │ ├── date.svg │ ├── vod.svg │ ├── edit.svg │ ├── timeout.svg │ ├── loop.svg │ ├── ban.svg │ ├── loadmore.svg │ ├── search.svg │ ├── manifest.json │ ├── help.svg │ └── safari-pinned-tab.svg ├── css │ ├── leaderboard.css │ ├── list.css │ ├── material-icons.css │ ├── settings.css │ ├── dark.css │ ├── animations.css │ ├── logviewer.css │ ├── base.css │ └── roboto-1-3-4-5-7.css ├── contact.html ├── bower.json ├── footer.html ├── moddinghelp.html ├── connect.html ├── js │ ├── list.js │ ├── leaderboard.js │ ├── modlogs.js │ ├── index.js │ ├── messageparser.js │ └── settings.js ├── list.html ├── leaderboard.html ├── modlogs.html ├── tos.html ├── about.html ├── index.html ├── settings.html └── channel.html ├── discord.js ├── LICENSE ├── appflow.txt ├── README.md ├── .gitignore ├── settings.default.json ├── package.json ├── ircbot.js ├── collections.js ├── pubsub.js ├── messagecompressor.js ├── api.js ├── db └── mysql.js └── bot.js /html/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/favicon.ico -------------------------------------------------------------------------------- /html/img/glitch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/glitch.png -------------------------------------------------------------------------------- /html/img/patreon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/patreon.png -------------------------------------------------------------------------------- /html/img/twitterlogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/twitterlogo.png -------------------------------------------------------------------------------- /html/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/favicon-16x16.png -------------------------------------------------------------------------------- /html/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/favicon-32x32.png -------------------------------------------------------------------------------- /html/img/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/mstile-310x310.png -------------------------------------------------------------------------------- /html/img/patreon_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/patreon_white.png -------------------------------------------------------------------------------- /discord.js: -------------------------------------------------------------------------------- 1 | var Discord = require('discord.js'); 2 | 3 | module.exports = function(settings) { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /html/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/apple-touch-icon.png -------------------------------------------------------------------------------- /html/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /html/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBenni/logviewer/HEAD/html/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /html/img/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /html/img/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/keyboard_arrow_up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/keyboard_arrow_down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/css/leaderboard.css: -------------------------------------------------------------------------------- 1 | .logviewer-user-row { 2 | height: 51px; 3 | } 4 | 5 | .logviewer-user-row.even { 6 | background-color: #E3E3E6; 7 | } 8 | 9 | .leaderboardbox { 10 | max-width: 800px; 11 | } 12 | 13 | .table-header { 14 | font-weight: bold; 15 | } -------------------------------------------------------------------------------- /html/img/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This work is licensed under the Creative Commons Attribution 4.0 International License. To view a copy of this license, visit http://creativecommons.org/licenses/by/4.0/ 2 | 3 | Permissions beyond the scope of this license may be available at business@cbenni.com 4 | -------------------------------------------------------------------------------- /html/img/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/messages.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /html/img/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /html/img/date.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/vod.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/timeout.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /html/img/loop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/ban.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/loadmore.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/img/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Logviewer", 3 | "icons": [ 4 | { 5 | "src": "\/html\/img\/android-chrome-192x192.png?v=xQoAW3RK4w", 6 | "sizes": "192x192", 7 | "type": "image\/png" 8 | }, 9 | { 10 | "src": "\/html\/img\/android-chrome-512x512.png?v=xQoAW3RK4w", 11 | "sizes": "512x512", 12 | "type": "image\/png" 13 | } 14 | ], 15 | "theme_color": "#ffffff", 16 | "display": "standalone" 17 | } 18 | -------------------------------------------------------------------------------- /html/img/help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/contact.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | Contact 5 |

6 | @cbenni_o
7 | cbenni 8 |
9 | 10 | Let's Kappa! 11 |
-------------------------------------------------------------------------------- /html/css/list.css: -------------------------------------------------------------------------------- 1 | .content.index-page-content { 2 | width: 75%; 3 | } 4 | 5 | 6 | .channel-list-item { 7 | display: inline-block; 8 | } 9 | 10 | .channel-live-indicator.live md-icon svg { 11 | fill: red; 12 | } 13 | 14 | h1.bigtitle { 15 | font-weight: 100; 16 | font-size: 72px; 17 | margin-bottom: 0; 18 | } 19 | 20 | h5.bigtitle { 21 | margin-top: 0; 22 | } 23 | 24 | @media (max-width:599px) { 25 | h1.bigtitle { 26 | font-size: 50px; 27 | } 28 | } 29 | 30 | a.greylink { 31 | color: #999; 32 | } -------------------------------------------------------------------------------- /appflow.txt: -------------------------------------------------------------------------------- 1 | DISCORD 2 | grant flow 3 | auth bot -> failed attempt -> return connection link http://cbenni.com/:channel/connect/?type=discord&id=SERVERID&descr=SERVER_NAME -> click connect -> add connection object 4 | 5 | consume flow 6 | bot gets request -> gets channel -> gets connection for SERVERID -> returns logs 7 | 8 | 9 | SLACK 10 | grant flow 11 | add /command -> failed attempt -> return connection link http://cbenni.com/:channel/connect/?type=slack&descr=TEAM_DOMAIN -> enter token -> click connect -> add connection object 12 | 13 | consume flow 14 | /command is executed -> webhook fires -> -------------------------------------------------------------------------------- /html/css/material-icons.css: -------------------------------------------------------------------------------- 1 | /* fallback */ 2 | @font-face { 3 | font-family: 'Material Icons'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url(https://fonts.gstatic.com/s/materialicons/v16/2fcrYFNaTjcS6g4U3t-Y5ZjZjT5FdEJ140U2DJYC3mY.woff2) format('woff2'); 7 | } 8 | 9 | .material-icons { 10 | font-family: 'Material Icons'; 11 | font-weight: normal; 12 | font-style: normal; 13 | font-size: 24px; 14 | line-height: 1; 15 | letter-spacing: normal; 16 | text-transform: none; 17 | display: inline-block; 18 | white-space: nowrap; 19 | word-wrap: normal; 20 | direction: ltr; 21 | -webkit-font-feature-settings: 'liga'; 22 | -webkit-font-smoothing: antialiased; 23 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # logviewer 2 | View chat logs of individuals in twitch chat 3 | 4 | ## Set up 5 | I generally discourage setting this up on your own, as you will not receive any support by me. This is an end-user product meant to run on my server, so this guide is meant for possible collaborators. 6 | 1) Install a MySQL server (or write a custom database connector) 7 | 2) Copy settings.default.json to settings.json and fill in the gaps 8 | 3) run `npm install` 9 | 4) run `node index.js` 10 | 11 | Optional: 12 | Run TMoohI and use that to proxy your connections for quicker restarts. You will have to modify [settings.json/bot/server](https://github.com/CBenni/logviewer/blob/master/settings.default.json#L16) for this to work. 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | html/bower_components/ 30 | html/bower_modules/ 31 | tools/ 32 | settings.json 33 | run_forever 34 | mods.json 35 | *.concat.js 36 | *.concat.html 37 | *.concat.css 38 | -------------------------------------------------------------------------------- /html/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logviewer-website", 3 | "homepage": "https://github.com/CBenni/logviewer", 4 | "authors": [ 5 | "CBenni " 6 | ], 7 | "description": "the website portion of the logviewer", 8 | "main": "index.html", 9 | "moduleType": [], 10 | "license": "MIT", 11 | "private": true, 12 | "ignore": [ 13 | "**/.*", 14 | "node_modules", 15 | "bower_components", 16 | "test", 17 | "tests" 18 | ], 19 | "dependencies": { 20 | "angular-ui-router": "ui-router#^0.2.18", 21 | "angular": "^1.5.5", 22 | "angular-sanitize": "^1.5.5", 23 | "angular-animate": "^1.5.5", 24 | "angular-material": "^1.0.7", 25 | "angular-cookies": "^1.5.5", 26 | "angular-socket-io": "^0.7.0", 27 | "socket.io-client": "^1.4.6", 28 | "angular-linkify": "^1.2.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /settings.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "type": "mysql", 4 | "host": "localhost", 5 | "user": "root", 6 | "database": "logviewer", 7 | "password": "" 8 | }, 9 | "auth": { 10 | "client_id": "", 11 | "client_secret": "", 12 | "baseurl": "http://127.0.0.1:8080/", 13 | "secure_cookies": true 14 | }, 15 | "bot": { 16 | "server": "irc.chat.twitch.tv:80", 17 | "nick": "", 18 | "id": 123456, 19 | "oauth": "", 20 | "modcheckinterval": 2 21 | }, 22 | "index": { 23 | "html": "/html/index.html" 24 | }, 25 | "logging": { 26 | "file": { 27 | "filename": "logviewer.log", 28 | "level": "info" 29 | }, 30 | "console": { 31 | "level": "debug" 32 | } 33 | }, 34 | "discord": { 35 | "client_id": "", 36 | "token": "" 37 | }, 38 | "pubsub": { 39 | "server": "wss://pubsub-edge.twitch.tv/v1", 40 | "maxtopics": 49 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /html/footer.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logviewer", 3 | "version": "1.0.0", 4 | "description": "Tool for keeping and retrieving logs of individuals in twitch chat", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/CBenni/logviewer.git" 12 | }, 13 | "keywords": [ 14 | "twitch", 15 | "chat" 16 | ], 17 | "author": "CBenni", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/CBenni/logviewer/issues" 21 | }, 22 | "homepage": "https://github.com/CBenni/logviewer#readme", 23 | "dependencies": { 24 | "body-parser": "^1.15.0", 25 | "compression": "^1.6.0", 26 | "cookie-parser": "^1.4.1", 27 | "express": "^4.13.3", 28 | "http": "0.0.0", 29 | "lodash": "^4.13.1", 30 | "mysql": "^2.10.0", 31 | "request": "^2.72.0", 32 | "socket.io": "^1.4.4", 33 | "strftime": "^0.9.2", 34 | "tls": "0.0.1", 35 | "winston": "^2.2.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /html/css/settings.css: -------------------------------------------------------------------------------- 1 | md-select.inlineselect { 2 | margin: 0; 3 | display: inline-block; 4 | } 5 | 6 | .event-log { 7 | height: 30vh; 8 | max-height: 30vh; 9 | min-height: 100px; 10 | line-height: 1.5em; 11 | overflow: auto; 12 | border: 1px rgba(194,194,194, 0.4) solid; 13 | box-shadow: 0 1px 10px 0px rgba(0, 0, 0, 0.14); 14 | margin-bottom: 10px; 15 | } 16 | 17 | .event-item { 18 | padding: 0; 19 | } 20 | 21 | .event-time { 22 | font-size: small; 23 | } 24 | 25 | .event-icon { 26 | border-radius: 3px; 27 | padding: 3px; 28 | color: white; 29 | font-size: small; 30 | } 31 | 32 | .event-setting { 33 | background-color: #B94A48; 34 | } 35 | 36 | .event-level { 37 | background-color: #3A87AD; 38 | } 39 | 40 | .event-comment { 41 | background-color: #230346; 42 | } 43 | 44 | .event-system { 45 | background-color: #F5970A; 46 | } 47 | 48 | .event-channel { 49 | background-color: #468847; 50 | } 51 | 52 | .event-command { 53 | background-color: #0B3311; 54 | } 55 | 56 | .betatag { 57 | color: #FF4444; 58 | font-variant: small-caps; 59 | line-height: 0; 60 | } 61 | 62 | .settings-column { 63 | min-width: 300px; 64 | } 65 | 66 | .hint-text { 67 | font-style: italic; 68 | } -------------------------------------------------------------------------------- /html/moddinghelp.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Why can't I enable mod logs?

4 | In order to use the mod logs feature, you need to make the user {{globalSettings.botname}} a moderator of your channel.
5 | To do that, type /mod {{globalSettings.botname}} in your channel's chat or click here.
6 | Keep in mind that it can take up to several minutes to detect moderator status. check now 7 | 8 |

Why that?

9 | Moderator logs are restricted to moderators and higher, so to receive these events, the logviewer has to be a mod in your channel.
10 | IT WILL NEVER POST OR USE COMMANDS IN YOUR CHANNEL, EVER - it is just there to listen for mod logs. 11 | 12 |

But what if the logviewer account gets hacked?

13 | I do my best to secure the account by all means necessary. It is Two-Factor-Authenticated and has a strong password, and the server is protected as good as I can. 14 |
15 | Let's Kappa! 16 |
17 | -------------------------------------------------------------------------------- /html/css/dark.css: -------------------------------------------------------------------------------- 1 | html, body, a, input, 2 | .footer, md-dialog.md-default-theme, md-dialog, 3 | .footer a, .footer a:visited, .footer a:hover, .footer a:active, 4 | .contacts a, .contacts a:visited, .contacts a:hover, .contacts a:active, 5 | md-input-container .md-placeholder, md-input-container label:not(.md-no-float):not(.md-container-ignore), 6 | md-input-container.md-default-theme .md-input, md-input-container .md-input { 7 | color: white; 8 | } 9 | 10 | html, body, #main, md-dialog.md-default-theme, md-dialog { 11 | background-color: #363636; 12 | } 13 | 14 | .header, .footer { 15 | background-color: #1E1E1E; 16 | } 17 | 18 | md-input-container.md-default-theme .md-input, md-input-container .md-input, 19 | md-select.md-default-theme .md-select-value, md-select .md-select-value { 20 | border-color: rgba(255,255,255,0.12); 21 | } 22 | 23 | .logviewer-chat-text.status-msg { 24 | color: #ccc; 25 | } 26 | 27 | .logviewer-comment { 28 | background-color: #666; 29 | } 30 | 31 | .logviewer-comment:before { 32 | border-right-color: #666; 33 | } 34 | 35 | md-icon svg { 36 | fill: white; 37 | } 38 | 39 | .md-button.videobutton[disabled] md-icon svg { 40 | fill: rgba(255,255,255,0.46); 41 | } 42 | 43 | .topbanner { 44 | padding: 8px; 45 | background-color: #258920; 46 | } 47 | 48 | md-tabs .md-tab { 49 | color: rgba(255,255,255,0.54); 50 | } 51 | 52 | md-tabs .md-tab.md-active { 53 | color: white; 54 | } 55 | 56 | md-datepicker { 57 | background: none; 58 | } 59 | 60 | .md-datepicker-input { 61 | color: white; 62 | } 63 | 64 | .logviewer-user-row.even { 65 | background-color: #1E1E1E; 66 | } -------------------------------------------------------------------------------- /html/connect.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | loading... 4 |
5 |
6 |
7 |
8 | 9 |
10 |
11 | You currently do not have access to change app connections of the channel {{channel}}.
12 | If you have {{10 | aAnAccountType }} account, then you can Login with twitch 13 |
14 |
15 |
16 |

Allow {{app.name}} to access data from the channel {{channel}}?

17 | Apps have a level (just like normal users) governing what they have access to. Most apps will require access to logs and comments, so make sure to provide the app with a level that allows it to perform the features it provides. 18 |
    19 |
  • 20 |
21 | 22 | SAVE 23 | 24 |
25 |
26 |
-------------------------------------------------------------------------------- /html/js/list.js: -------------------------------------------------------------------------------- 1 | var logviewerApp = angular.module("logviewerApp"); 2 | 3 | 4 | logviewerApp.controller("ChannelListController", function($rootScope, $scope, $http, $stateParams, $window){ 5 | $scope.channels = []; 6 | $scope.channellimit = 80; 7 | $scope.channelSearch = {}; 8 | 9 | 10 | // todo: dynamic channellimit based on window width+height. Average item width is ~ 130px, height 51px 11 | var calcChannellimit = function () { 12 | var availableWidth = 0.7 * window.innerWidth; 13 | var availableHeight = window.innerHeight - 450; // 450px get used on the header, footer, title, show more and search 14 | 15 | $scope.channellimit = Math.max(5, Math.floor(availableHeight * availableWidth / (130 * 51))); 16 | } 17 | 18 | angular.element($window).bind('resize', function(){ 19 | $scope.$apply(function(){ 20 | if(isFinite($scope.channellimit)) calcChannellimit(); 21 | }); 22 | }); 23 | calcChannellimit(); 24 | 25 | var updateChannels = function(){ 26 | $http.get("/api/channels/").then(function(response) { 27 | $scope.channels = response.data; 28 | }); 29 | } 30 | 31 | // update every 2 minutes 32 | var interval = setInterval(updateChannels,2*60000); 33 | 34 | $scope.$on('$destroy', function() { 35 | clearInterval(interval); 36 | }); 37 | updateChannels(); 38 | }); 39 | 40 | logviewerApp.filter("orderChannels", function() { 41 | return function(channels) { 42 | return channels.slice().sort(function(a,b){ 43 | if(a.live < b.live) return 1; 44 | if(a.live > b.live) return -1; 45 | if(a.ispremium < b.ispremium) return 1; 46 | if(a.ispremium > b.ispremium) return -1; 47 | if(a.name > b.name) return 1; 48 | if(a.name < b.name) return -1; 49 | }); 50 | } 51 | }); -------------------------------------------------------------------------------- /ircbot.js: -------------------------------------------------------------------------------- 1 | var net = require('net'); 2 | var events = require('events'); 3 | var winston = require('winston'); 4 | var parseIRCMessage = require("./messagecompressor").parseIRCMessage; 5 | 6 | var rx = /^(?:@([^ ]+) )?(?:[:](\S+) )?(\S+)(?: (?!:)(.+?))?(?: [:](.+))?$/; 7 | var rx2 = /([^=;]+)=([^;]*)/g; 8 | var rx3 = /\r\n|\r|\n/; 9 | var STATE_V3 = 1 10 | var STATE_PREFIX = 2 11 | var STATE_COMMAND = 3 12 | var STATE_PARAM = 4 13 | var STATE_TRAILING = 5 14 | 15 | 16 | function IRCBot(host, port) { 17 | var self = this; 18 | self.client = new net.Socket(); 19 | 20 | self.send = function(data) { 21 | if(data.indexOf("\n") >= 0) { 22 | winston.warn("Tried to send newline character!"); 23 | return; 24 | } 25 | winston.debug("--> "+data); 26 | self.client.write(data+'\n'); 27 | } 28 | 29 | self._buffer = ""; 30 | self.connect = function() { 31 | self.client.connect(port, host, function() { 32 | self.emit('connect'); 33 | }); 34 | } 35 | 36 | var buffer = new Buffer(''); 37 | 38 | self.client.on('data', function(chunk) { 39 | if (typeof (chunk) === 'string') { 40 | buffer += chunk; 41 | } else { 42 | buffer = Buffer.concat([buffer, chunk]); 43 | } 44 | 45 | var lines = buffer.toString().split(rx3); 46 | 47 | if (lines.pop()) { 48 | return; 49 | } else { 50 | buffer = new Buffer(''); 51 | } 52 | 53 | lines.forEach(function(line) { 54 | if(line.length>0) { 55 | var parsed = parseIRCMessage(line); 56 | parsed[STATE_COMMAND] == "PING" && self.send("PONG"); 57 | try { 58 | self.emit('raw', parsed); 59 | self.emit(parsed[STATE_COMMAND], parsed); 60 | } catch (error) { 61 | winston.error(error); 62 | } 63 | } 64 | }); 65 | }); 66 | 67 | self.client.on('close', function() { 68 | self.emit('disconnect'); 69 | }); 70 | 71 | 72 | // client.destroy(); 73 | } 74 | IRCBot.prototype = new events.EventEmitter; 75 | exports = module.exports = IRCBot; -------------------------------------------------------------------------------- /html/list.html: -------------------------------------------------------------------------------- 1 |
2 | Spoon me on GitHub 3 |
4 |

Logviewer

5 |
Denying people unbans since 2014
6 |
7 |
8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | Premium channel 16 | {{channelObj.name}} 17 | 18 |
19 |
20 | No channels found. If you have access and want to add {{channelSearch.name}} to the logviewer, click here 21 |
22 |
23 | ▽ Show more 24 |
25 |
26 |
27 |
28 |
-------------------------------------------------------------------------------- /html/css/animations.css: -------------------------------------------------------------------------------- 1 | /* "Load more..." spinner */ 2 | 3 | @-moz-keyframes logviewer-spin { 4 | from { -moz-transform: rotate(0deg); } 5 | to { -moz-transform: rotate(360deg); } 6 | } 7 | @-webkit-keyframes logviewer-spin { 8 | from { -webkit-transform: rotate(0deg); } 9 | to { -webkit-transform: rotate(360deg); } 10 | } 11 | @keyframes logviewer-spin { 12 | from {transform:rotate(0deg);} 13 | to {transform:rotate(360deg);} 14 | } 15 | 16 | .logviewer-spin-icon md-icon { 17 | transition: 2s; 18 | } 19 | 20 | .logviewer-spin-icon:hover md-icon { 21 | transform: rotate(360deg); 22 | } 23 | 24 | .logviewer-loading .logviewer-spin-icon md-icon { 25 | -moz-transition: none; 26 | -webkit-transition: none; 27 | -o-transition: all 0 none; 28 | transition: none; 29 | animation-duration: 0.1s; 30 | animation-name: logviewer-spin; 31 | animation-iteration-count: infinite; 32 | } 33 | 34 | 35 | 36 | /* "More messages above/below" infinite arrows */ 37 | @keyframes logviewer-infinitearrows-up { 38 | from {transform:translateY(2em);} 39 | to {transform:translateY(-2em);} 40 | } 41 | @keyframes logviewer-infinitearrows-down { 42 | from {transform:translateY(-2em);} 43 | to {transform:translateY(2em);} 44 | } 45 | 46 | .logviewer-arrows-up:hover md-icon { 47 | animation-name: logviewer-infinitearrows-up; 48 | animation-iteration-count: infinite; 49 | animation-timing-function: linear; 50 | animation-fill-mode: forwards; 51 | animation-duration: 800ms; 52 | animation-delay: -400ms; 53 | } 54 | 55 | .logviewer-arrows-down:hover md-icon { 56 | animation-name: logviewer-infinitearrows-down; 57 | animation-iteration-count: infinite; 58 | animation-timing-function: linear; 59 | animation-fill-mode: forwards; 60 | animation-duration: 800ms; 61 | animation-delay: -400ms; 62 | } 63 | 64 | .logviewer-loading .logviewer-arrows-up md-icon { 65 | animation-name: logviewer-infinitearrows-up; 66 | animation-iteration-count: infinite; 67 | animation-timing-function: linear; 68 | animation-fill-mode: forwards; 69 | animation-duration: 100ms; 70 | animation-delay: -50ms; 71 | } 72 | 73 | .logviewer-loading .logviewer-arrows-down md-icon { 74 | animation-name: logviewer-infinitearrows-down; 75 | animation-iteration-count: infinite; 76 | animation-timing-function: linear; 77 | animation-fill-mode: forwards; 78 | animation-duration: 100ms; 79 | animation-delay: -50ms; 80 | } -------------------------------------------------------------------------------- /html/leaderboard.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | loading... 4 |
5 |
6 |
7 |
8 | 9 |
10 |
11 | You currently do not have access to change settings of the channel {{channel}}.
12 | If you have {{channelsettings.viewlogs | aAnAccountType }} account, then you can Login with twitch 13 |
14 |
15 | 16 | 17 |
18 |

Leaderboards

19 |
20 |
21 |
22 |
User
23 |
Messages
24 |
Timeouts
25 |
Bans
26 |
Messages
27 |
Timeouts
28 |
Bans
29 |
30 | 31 |
32 |
{{row.index+1}}
33 | 34 |
{{row.user.messages}}
35 |
{{row.user.timeouts}}
36 |
{{row.user.bans}}
37 |
38 |
39 |
40 |
41 | 42 | 43 |
44 |
-------------------------------------------------------------------------------- /html/js/leaderboard.js: -------------------------------------------------------------------------------- 1 | if (!String.prototype.startsWith) { 2 | String.prototype.startsWith = function(searchString, position){ 3 | position = position || 0; 4 | return this.substr(position, searchString.length) === searchString; 5 | }; 6 | } 7 | 8 | logviewerApp.controller("LeaderboardController", function($scope, $http, $stateParams, $rootScope, $sce, logviewerSocket, $mdDialog, $timeout, $q){ 9 | $scope.channel = $stateParams.channel.toLowerCase(); 10 | $scope.channelsettings = null; 11 | $scope.userObject = null; 12 | $scope.newcomments = {}; 13 | $scope.editingComment = {id:-1}; 14 | $scope.loadStatus = 0; 15 | $scope.videos = []; 16 | $scope.highlights = []; 17 | 18 | 19 | $http.get("/api/channel/"+$scope.channel+"/?token="+$rootScope.auth.token).then(function(response){ 20 | $scope.channelsettings = response.data.channel; 21 | $scope.userObject = response.data.me; 22 | $scope.loadStatus = response.data.channel==null?-1:-1+2*response.data.channel.active; 23 | if($stateParams.user) { 24 | $scope.addUser($stateParams.user); 25 | } 26 | }, function(response){ 27 | $scope.loadStatus = -1; 28 | }); 29 | 30 | // virtualRepeat model 31 | var infiniteScroller = function () { 32 | this.PAGE_SIZE = 250; 33 | this.totallength = 0; 34 | this.endofusers = null; 35 | this.topindex = 0; 36 | this.error = ""; 37 | this.pages = []; 38 | } 39 | 40 | infiniteScroller.prototype.getItemAtIndex = function(offset) { 41 | var self = this; 42 | var pageindex = Math.floor(offset/this.PAGE_SIZE); 43 | // always make sure we have the next page loaded 44 | if(self.pages[pageindex+1] === undefined) { 45 | this.loadPage(pageindex+1); 46 | } 47 | var page = self.pages[pageindex]; 48 | if (page) { 49 | return page[offset%this.PAGE_SIZE]; 50 | } else if(page === undefined) { 51 | this.loadPage(pageindex); 52 | return null; 53 | } 54 | } 55 | 56 | infiniteScroller.prototype.getLength = function() { 57 | if(this.endofusers) return this.totallength; 58 | else return this.totallength+10; 59 | }; 60 | 61 | infiniteScroller.prototype.loadPage = function(pageindex) { 62 | var self = this; 63 | console.log("Getting users page "+pageindex); 64 | self.pages[pageindex] = null; 65 | $http.get("/api/leaderboard/" + $scope.channel,{ 66 | params: { 67 | offset: pageindex*this.PAGE_SIZE, 68 | limit: this.PAGE_SIZE, 69 | token: $rootScope.auth.token 70 | } 71 | }).then(function(response){ 72 | console.log("Got users page "+pageindex); 73 | var page = self.pages[pageindex] = new Array(response.data.length); 74 | for(var i=0;i 0) { 78 | self.totallength = Math.max(self.totallength, pageindex*self.PAGE_SIZE+response.data.length); 79 | } 80 | if(response.data.length < self.PAGE_SIZE) { 81 | self.endofusers = true; 82 | console.log("end of users reached. Row count: "+self.totallength); 83 | } 84 | },function(response){ 85 | console.log(response); 86 | }); 87 | } 88 | 89 | $scope.allUsers = new infiniteScroller(); 90 | }); -------------------------------------------------------------------------------- /html/js/modlogs.js: -------------------------------------------------------------------------------- 1 | logviewerApp.controller("ModLogController", function($scope, $http, $stateParams, $rootScope, $sce, logviewerSocket, $mdDialog, $timeout, $q){ 2 | $scope.channel = $stateParams.channel.toLowerCase(); 3 | $scope.channelsettings = null; 4 | $scope.userObject = null; 5 | $scope.newcomments = {}; 6 | $scope.editingComment = {id:-1}; 7 | $scope.loadStatus = 0; 8 | $scope.filters = {commands: {}, include: "", exclude: ""}; 9 | $scope.commands = ["timeout","ban","unban","untimeout","slow","slowoff","subscribers","subscribersoff","followers","followersoff","emoteonly","emoteonlyoff","clear","host","unhost","commercial","mod","unmod"]; 10 | for(var i=0;i<$scope.commands.length;++i) { 11 | $scope.filters.commands[$scope.commands[i]] = "1"; 12 | } 13 | $scope.filters.commands.timeout = undefined; 14 | 15 | 16 | $http.get("/api/channel/"+$scope.channel+"/?token="+$rootScope.auth.token).then(function(response){ 17 | $scope.channelsettings = response.data.channel; 18 | $scope.userObject = response.data.me; 19 | $scope.loadStatus = response.data.channel==null?-1:-1+2*response.data.channel.active; 20 | if($stateParams.user) { 21 | $scope.addUser($stateParams.user); 22 | } 23 | }, function(response){ 24 | $scope.loadStatus = -1; 25 | }); 26 | 27 | 28 | // virtualRepeat model 29 | var infiniteScroller = function (filters) { 30 | this.filters = angular.copy(filters); 31 | this.PAGE_SIZE = 250; 32 | this.totallength = 0; 33 | this.endofusers = null; 34 | this.topindex = 0; 35 | this.error = ""; 36 | this.pages = []; 37 | } 38 | 39 | infiniteScroller.prototype.getItemAtIndex = function(offset) { 40 | var self = this; 41 | var pageindex = Math.floor(offset/this.PAGE_SIZE); 42 | // always make sure we have the next page loaded 43 | if(self.pages[pageindex+1] === undefined) { 44 | this.loadPage(pageindex+1); 45 | } 46 | var page = self.pages[pageindex]; 47 | if (page) { 48 | return page[offset%this.PAGE_SIZE]; 49 | } else if(page === undefined) { 50 | this.loadPage(pageindex); 51 | return null; 52 | } 53 | } 54 | 55 | infiniteScroller.prototype.getLength = function() { 56 | if(this.endofusers) return this.totallength; 57 | else return this.totallength+10; 58 | }; 59 | 60 | infiniteScroller.prototype.loadPage = function(pageindex) { 61 | var self = this; 62 | console.log("Getting users page "+pageindex); 63 | self.pages[pageindex] = null; 64 | $http.get("/api/leaderboard/" + $scope.channel,{ 65 | params: { 66 | offset: pageindex*this.PAGE_SIZE, 67 | limit: this.PAGE_SIZE, 68 | token: $rootScope.auth.token 69 | } 70 | }).then(function(response){ 71 | console.log("Got users page "+pageindex); 72 | var page = self.pages[pageindex] = new Array(response.data.length); 73 | for(var i=0;i 0) { 77 | self.totallength = Math.max(self.totallength, pageindex*self.PAGE_SIZE+response.data.length); 78 | } 79 | if(response.data.length < self.PAGE_SIZE) { 80 | self.endofusers = true; 81 | console.log("end of users reached. Row count: "+self.totallength); 82 | } 83 | },function(response){ 84 | console.log(response); 85 | }); 86 | } 87 | 88 | $scope.allUsers = new infiniteScroller(); 89 | 90 | $scope.setFilters() { 91 | $scope.allUsers = new infiniteScroller($scope.filters); 92 | } 93 | }); -------------------------------------------------------------------------------- /collections.js: -------------------------------------------------------------------------------- 1 | /* 2 | Holds an indexed collection of objects 3 | @param indexes A list of properties to be indexed. Omit to autoindex. 4 | */ 5 | 6 | function IndexedCollection(indexes) { 7 | let self = this; 8 | if(indexes && !indexes.length) throw "Bad value for indexes"; 9 | self.indexes = indexes; 10 | self.index = {}; // self.index[prop][value] is an array containing all elements in this IndexedCollection that have the property `prop` set to `value` 11 | self.items = []; 12 | if(indexes) for(let i=0;i item" 14 | } 15 | } 16 | 17 | /* 18 | Adds an item to the IndexedCollection 19 | @param item the object to be added 20 | */ 21 | const hasOwn = Object.prototype.hasOwnProperty; 22 | IndexedCollection.prototype.add = function(item) { 23 | let self = this; 24 | if(self.indexes === undefined) { 25 | let props = Object.keys(item); 26 | for(let i=0;i 2 |
3 | loading... 4 |
5 |
6 |
7 |
8 | 9 |
10 |
11 | You currently do not have access to change settings of the channel {{channel}}.
12 | If you have {{channelsettings.viewmodlogs | aAnAccountType }} account, then you can Login with twitch 13 |
14 |
15 | 16 | 17 |
18 | 19 | 20 | 21 |

Moderator activity log

22 |
23 |
Filters
24 |
25 |

Commands

26 |
27 | {{command}} 28 |
29 |

User

30 | Include: 42 | {{item}} 43 | 44 | Exclude: 45 |
46 |
47 |
48 |
49 |
Time
50 |
Moderator
51 |
Command
52 |
Arguments
53 |
54 | 55 |
56 |
{{row.time | secondsTimestamp | date : 'yyyy/MM/dd hh:mm a'}}
57 |
{{row.user}}
58 |
{{row.command}}
59 |
{{row.args}}
60 |
61 |
62 |
63 |
64 |
65 | 66 | 67 | Performance analysis Coming soon 68 | 69 | 70 |
71 |
72 | 73 | 74 |
75 | -------------------------------------------------------------------------------- /pubsub.js: -------------------------------------------------------------------------------- 1 | var winston = require('winston'); 2 | var WebSocket = require('ws'); 3 | var request = require("request"); 4 | var events = require('events'); 5 | 6 | function pubsub(settings, db, io) { 7 | this.settings = settings; 8 | this.db = db; 9 | this.io = io; 10 | this.connections = []; 11 | this.currenthead = 0; 12 | this.topics2conn = {}; 13 | } 14 | 15 | pubsub.prototype = new events.EventEmitter; 16 | var connID = 0; 17 | function pubsubConnection(ps) { 18 | var self = this; 19 | self.ws = new WebSocket(ps.settings.pubsub.server); 20 | self.open = false; 21 | self.topics = []; 22 | self.buffer = []; 23 | self.id = connID++; 24 | 25 | self.ws.on("open", function() { 26 | winston.info("PubSub connected"); 27 | self.open = true; 28 | for(var i=0;i_< 83 | connection = conn; 84 | break; 85 | } 86 | } 87 | // no connection found, try to create a new one 88 | if(!connection) { 89 | // add a new connection 90 | var connection = self.addConnection(); 91 | } 92 | 93 | if(connection) { 94 | // add the topic 95 | self.topics2conn[topics[i]] = connection; 96 | connection.topics.push(topics[i]); 97 | connection.send(JSON.stringify({"type":"LISTEN","data":{"topics":[topics[i]], "auth_token": oauth}})); 98 | } else { 99 | winston.error("Pubsub limit exceeded!"); 100 | } 101 | } 102 | } 103 | 104 | pubsub.prototype.unlisten = function(topics) { 105 | var self = this; 106 | for(var i=0;i{ 43 | $rootScope.emote = globalEmotes[Math.floor(Math.random()*globalEmotes.length)]; 44 | }); 45 | }); 46 | }); 47 | 48 | app.controller("mainctrl", function($rootScope,$scope,$http,$location,$cookies,$stateParams,$mdDialog){ 49 | $rootScope.auth = { name: $cookies.get("login")||"", token: $cookies.get("token")||"" }; 50 | $scope.$stateParams = $stateParams; 51 | $rootScope.$stateParams = $stateParams; 52 | $rootScope.globalSettings = settings; 53 | $scope.login = function() { 54 | window.location.href = "https://api.twitch.tv/kraken/oauth2/authorize" 55 | +"?response_type=code" 56 | +"&client_id="+settings.auth.client_id 57 | +"&redirect_uri="+settings.auth.baseurl+"/api/login" 58 | +"&scope=" 59 | +"&state="+window.location.pathname; 60 | } 61 | 62 | $scope.logout = function() { 63 | $http.get("/api/logout/?token="+$rootScope.auth.token).then(function(result) { 64 | window.location.reload(); 65 | }); 66 | } 67 | 68 | 69 | $scope.showDialog = function(ev, tpl) { 70 | console.log($rootScope.globalSettings); 71 | $mdDialog.show({ 72 | controller: DialogController, 73 | templateUrl: tpl, 74 | parent: angular.element(document.body), 75 | targetEvent: ev, 76 | clickOutsideToClose: true 77 | }); 78 | } 79 | 80 | $scope.userSettings = JSON.parse(localStorage.logviewerUserSettings || 81 | '{"dark": false, "chat": true}' 82 | ); 83 | $scope.saveMode = function() { 84 | localStorage.logviewerUserSettings = JSON.stringify($scope.userSettings); 85 | } 86 | 87 | // preload the global emotes 88 | $rootScope.globalEmotes = new Promise((r,j)=>{ 89 | $http.get("https://api.twitch.tv/kraken/chat/emoticon_images?emotesets=0,33,457&client_id="+settings.auth.client_id, {cache: true}).then(function(result) { 90 | var allemotes = []; 91 | var emotesets = Object.keys(result.data.emoticon_sets); 92 | // flatten response 93 | for(var i=0;i"); 97 | emoteset[j].url = "//static-cdn.jtvnw.net/emoticons/v1/" + emoteset[j].id + "/3.0" 98 | allemotes.push(emoteset[j]); 99 | } 100 | } 101 | r(allemotes); 102 | }); 103 | }); 104 | }); 105 | 106 | 107 | function DialogController($scope, $mdDialog, $location, $http, $stateParams) { 108 | $scope.location = $location; 109 | $scope.globalSettings = settings; 110 | 111 | $scope.hide = function() { 112 | $mdDialog.hide(); 113 | }; 114 | $scope.cancel = function() { 115 | $mdDialog.cancel(); 116 | }; 117 | $scope.answer = function(answer) { 118 | $mdDialog.hide(answer); 119 | }; 120 | 121 | // other functionality 122 | $scope.checkModded = function() { 123 | $http.get("/api/checkmodded/"+$stateParams.channel); 124 | } 125 | } 126 | 127 | app.directive('clickOutside', function ($document) { 128 | return { 129 | restrict: 'A', 130 | scope: { 131 | clickOutside: '&' 132 | }, 133 | link: function (scope, el, attr) { 134 | 135 | $document.on('click', function (e) { 136 | if (el !== e.target && !el[0].contains(e.target)) { 137 | scope.$apply(function () { 138 | scope.$eval(scope.clickOutside); 139 | }); 140 | } 141 | }); 142 | } 143 | } 144 | 145 | }); -------------------------------------------------------------------------------- /html/tos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | Logviewer Terms and Conditions of Use 5 |

6 | 7 |

8 | 1. Terms 9 |

10 | 11 |

12 | By accessing this web application, you are agreeing to be bound by these 13 | web application Terms and Conditions of Use, all applicable laws and regulations, 14 | and agree that you are responsible for compliance with any applicable local 15 | laws. If you do not agree with any of these terms, you are prohibited from 16 | using or accessing this application. The materials contained in this web application are 17 | protected by applicable copyright and trade mark law. 18 |

19 | 20 |

21 | 2. Use License 22 |

23 | 24 | Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License 25 | Copyright (c) 2016 CBenni

26 | 27 | Creative Commons License
Logviewer by CBenni is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
Based on a work at https://github.com/CBenni/logviewer.
Permissions beyond the scope of this license may be available at business@cbenni.com.

28 | 29 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 30 |

Third party licenses

31 |
    32 |
  • angular.js, angular-material and related libraries are released under the MIT license, stated above.
  • 33 |
  • Robots lovingly delivered by Robohash.org
  • 34 |
35 | The logviewer is not affiliated with twitch.tv or any other company that provides services, APIs or software used to create this product, unless stated otherwise. 36 |

37 | Privacy Policy 38 |

39 | I use Google Analytics to track the usage of the application, purely to improve the service where possible. All information this third-party service collects is therefore anonymous. 40 |

The information I gather is exclusively used to provide the service and is not used, given or sold to any third-party for any other purpose and is securely stored on my server. 41 |

42 | SLA 43 |

44 | No service license agreement is provided with this application, although I attempt to have the highest uptime I can achieve with the means I have access to. 45 |

46 | Donation Policy 47 |

48 | The logviewer is currently free for all users. I however reserve the right to sell additional features or options in a freemium model in the future, although no such system is currently planned. All monetary contributions are therefore considered donations and do not grant you any service license, uptime or liability agreement. 49 |

50 | By donating to my PayPal, you are claiming that the money being donated is your own money and you are willingly and voluntarily donating this money. You acknowledge that you are receiving no items in return for this donation, and that the money donated is non-refundable. 51 |

52 |

53 | API Policy 54 |

55 |

56 | The API is free and open for everyone to use and supports CORS and JSONP. If you use the API to integrate the logviewer into your custom software, websites, apps or services, please adhere to the rules stated below: 57 |

    58 |
  • The API is provided under a "fair-use" license. This means no api scraping, polling, DoSing, spamming, ... - try to keep each request to my API triggered by a user action. It is preferred to cache requests client-side
  • 59 |
  • Access to the API is restricted to purely non-commercial services and products, unless an extended license has been explicitely obtained. A user has to have access to whatever service you provide without payment, subscription or credit card information
  • 60 |
  • When displaying logs or stats of users, please always link back to cbenni.com - preferably with deep links like https://cbenni.com/esl_csgo/?user=cbenni
  • 61 |
  • The API is subject to change, and while breaking changes are rare, they might occur at any point in time - I take no responsibility or liability for changes that break your applications. 62 |
63 | If an application intentionally violates these terms, I take the liberty to permanently block it from access to my API in the manner I decide. Any future attempts to access or abuse my page, API or services will be prohibited and ultimately prosecuted. 64 |

65 | 66 |
67 | 68 | Let's Kappa! 69 |
70 | -------------------------------------------------------------------------------- /html/css/logviewer.css: -------------------------------------------------------------------------------- 1 | /* layout */ 2 | div { outline-style:none; } 3 | 4 | #chat { 5 | width: 20%; 6 | min-width: 250px; 7 | height: calc(100% - 50px); 8 | position: fixed; 9 | right: 0; 10 | padding: 0; 11 | box-sizing: border-box; 12 | overflow: hidden; 13 | } 14 | 15 | #chat iframe { 16 | height: 100%; 17 | width: 100%; 18 | } 19 | 20 | @media (max-width:960px) { 21 | #chat { 22 | display: none; 23 | } 24 | } 25 | @media (min-width:960px) { 26 | #main.largecolumn { 27 | max-width: calc(100% - 250px); 28 | } 29 | } 30 | 31 | #main.rhs { 32 | float: right; 33 | } 34 | 35 | #chat.lhs { 36 | left: 0; 37 | right: inherit; 38 | } 39 | 40 | .topbanner { 41 | padding: 8px; 42 | background-color: #53d910; 43 | text-align: center; 44 | width: 500px; 45 | margin: 0px auto; 46 | } 47 | 48 | /* user view */ 49 | 50 | .userlogsview 51 | { 52 | margin: 15px 0; 53 | } 54 | 55 | .logview .userheader { 56 | max-width: 500px; 57 | } 58 | 59 | .logview .username 60 | { 61 | font-size: large; 62 | font-weight: bold; 63 | } 64 | 65 | .logview 66 | { 67 | border: 1px rgba(194,194,194, 0.4) solid; 68 | box-shadow: 0 1px 10px 0px rgba(0, 0, 0, 0.14); 69 | margin-bottom: 10px; 70 | } 71 | 72 | .btn-close:hover { 73 | color: #999; 74 | } 75 | 76 | .panel-heading { 77 | position: relative; 78 | } 79 | 80 | .userstats { 81 | text-align: center; 82 | margin: 0 5px; 83 | } 84 | 85 | .logviewer-comment .text { 86 | white-space: pre-wrap; 87 | } 88 | 89 | .writecomment { 90 | margin-top: 20px; 91 | font-size: 14px; 92 | } 93 | 94 | .writecomment md-input-container { 95 | width: 50%; 96 | min-width: 200px; 97 | } 98 | 99 | .comment-editing-buttons .editing-button { 100 | margin: 0; 101 | min-height: 0; 102 | height: 20px; 103 | min-width: 0; 104 | width: 20px; 105 | padding: 0; 106 | } 107 | 108 | .comment-editing-buttons .editing-button md-icon { 109 | font-size: 16px; 110 | height: 20px; 111 | width: 20px; 112 | } 113 | 114 | .comment-editing md-input-container { 115 | width: 100%; 116 | } 117 | 118 | .comment-editing .md-button { 119 | float: right; 120 | } 121 | 122 | /* Add animation */ 123 | .logview.ng-enter.ng-enter-active, 124 | .logview.ng-leave { 125 | opacity: 1; 126 | -webkit-transition: opacity 100ms linear; 127 | -moz-transition: opacity 100ms linear; 128 | transition: opacity 100ms linear; 129 | } 130 | 131 | /* Remove animation */ 132 | .logview.ng-leave.ng-leave-active, 133 | .logview.ng-enter { 134 | opacity: 0; 135 | -webkit-transition: all 100ms linear; 136 | -moz-transition: all 100ms linear; 137 | transition: all 100ms linear; 138 | } 139 | 140 | /* chat messages */ 141 | 142 | /* more messages buttons */ 143 | .logviewer-round-button { 144 | padding: 0px 20px; 145 | border-radius: 20px; 146 | } 147 | 148 | div.logviewer-more-messages md-button { 149 | margin-top: 0; 150 | } 151 | 152 | div.logviewer-more-messages .md-primary:hover, div.logviewer-more-messages .md-primary:focus { 153 | background-color: #2674F2 !important; 154 | } 155 | 156 | /* "More above/below" */ 157 | /* chat messages */ 158 | 159 | .logview .logviewer-message { 160 | font-size: 15px; 161 | } 162 | 163 | span.logviewer-time { 164 | font-size: 0.9em; 165 | color: #666; 166 | margin-right: 2px; 167 | } 168 | 169 | span.logviewer-chat-action { 170 | font-style: italic; 171 | } 172 | 173 | span.logviewer-colon { 174 | padding-right: 5px; 175 | } 176 | 177 | img.logviewer-badge { 178 | margin-right: 2px; 179 | height: 1em; 180 | } 181 | 182 | .logviewer-message { 183 | word-break: break-word; 184 | } 185 | 186 | img.emote { 187 | height: 1.5em; 188 | margin-top: -0.25em; 189 | margin-bottom: -0.25em; 190 | } 191 | 192 | .logviewer-chat-message { 193 | min-height: 20px; 194 | } 195 | 196 | .logviewer-disabled-message { 197 | opacity: 0.3; 198 | } 199 | 200 | .logviewer-selected-message { 201 | background-color: rgba(105,105,105,0.25); 202 | } 203 | 204 | .logviewer-clickable-message { 205 | cursor: pointer; 206 | } 207 | 208 | .logviewer-chat-text.status-msg { 209 | color: #333; 210 | } 211 | 212 | /* comments */ 213 | .logviewer-comment { 214 | background-color: #eee; 215 | border-radius:5px; 216 | margin: 20px; 217 | padding: 5px; 218 | max-width:500px; 219 | position: relative; 220 | } 221 | 222 | .logviewer-comment img { 223 | height: 34px; 224 | width: 34px; 225 | border-radius: 25px; 226 | margin: 0 5px 0 0; 227 | } 228 | 229 | .logviewer-comment:before { 230 | content: ""; 231 | position: absolute; 232 | top: 15px; 233 | left: -20px; 234 | border: 10px solid transparent; 235 | border-right-color: #eee; 236 | display: block; 237 | } 238 | 239 | .logviewer-comment .author { 240 | font-weight: bold; 241 | } 242 | 243 | .logviewer-comment .age { 244 | font-size: 12px; 245 | } 246 | 247 | a.videobutton.md-button.md-icon-button { 248 | height: 20px; 249 | margin: -3px 0; 250 | padding: 0; 251 | line-height: 0; 252 | min-height: 0; 253 | border-radius: 0; 254 | } 255 | 256 | .vod-tooltip { 257 | background-color: #333; 258 | padding: 5px; 259 | font-size: 10pt; 260 | } 261 | 262 | .md-button.videobutton md-icon svg { 263 | fill: rgb(63,81,181); 264 | } 265 | 266 | .md-button.videobutton md-icon svg { 267 | fill: rgb(63,81,181); 268 | } 269 | 270 | .md-button.videobutton.highlight-button md-icon svg { 271 | fill: #e3b338; 272 | } 273 | 274 | .md-button.videobutton[disabled] md-icon svg { 275 | fill: rgba(0,0,0,0.54); 276 | } 277 | 278 | md-tooltip .md-content.md-show, md-tooltip .md-content.md-show-add-active { 279 | height: auto; 280 | font-size: 12px; 281 | } 282 | 283 | span.twitchbot, span.logviewer-modlog-text { 284 | font-style: italic; 285 | } -------------------------------------------------------------------------------- /html/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | About 5 |

6 | The logviewer is a tool, created by CBenni, that allows users to check the chat logs of individuals in twitch chat of certain channels. 7 | 8 |

How to use it

9 | Simply go to the channel page you wish to check logs for, enter a name in the username box and press the "ADD" button, which adds a log view for this user. 10 |

11 | Alternatively, you can whisper the logviewer account from any twitch chat or the whisper menu to check for logs, with the following: 12 | /w logviewer #channel username 13 | where #channel is the channel name (for example, #nl_kripp) and username is the username you want to check. It will respond with the newest logs of this users and a direct link 14 | 15 |

Slack integration

16 | You can add a slash command to look up chat logs in your Slack team: 17 |
    18 |
  1. Add Slacks custom slash commands app to your server
  2. 19 |
  3. Add a new configuration and call it something like lv
  4. 20 |
  5. Set the URL to {{location.protocol()+"://"+location.host()+(location.port()=="80"?"":":"+location.port())}}/api/slack/?default_channel={{$parent.$stateParams.channel || "YOUR_CHANNEL_NAME"}} and change the method to GET
  6. 21 |
  7. If the channel doesn't have logs readable by "everyone" (see the settingssettings), then add a token to the URL. Contact me for details. 22 |
  8. Customize the command as you like
  9. 23 |
  10. Save
  11. 24 |
25 |

Usage

26 | To check a users logs, you can now use the slash command you just created, for example:
27 | /lv username - gets the last 10 lines of log for the user username in the default channel
28 | /lv username 3 - gets the last 3 lines of log for the user username in the default channel
29 | /lv username channel - gets the last 10 lines of log for the user username in the channel channel
30 | /lv username channel 3 - gets the last 3 lines of log for the user username in the channel channel
31 | Keep in mind only you can see the response! 32 |

Discord integration

33 | Coming soon™ 34 | 35 |

FAQ

36 |
    37 |
  • I want my channel to be logged, can you add me?

    38 | You can add your channel yourself: 39 | Go to your settings and enable it! 40 | Log in, go to your settings and enable it! 41 |
  • 42 |
  • I want another channel to be logged, can you add it?

    43 | I do not log channels without explicit permission by the broadcaster or someone entitled to give permission (this includes official channel managers, group members for shared channels, but usually excludes moderators) 44 | If you fulfill this condition but do not have access to the channel account (in which case you can add it yourself), you can contact me. 45 |
  • 46 |
  • How long are logs saved?

    47 | As long as my server costs are covered by the voluntary donations. Contact me if you want to make an agreement on permanent log storage. 48 |
  • 49 |
  • A certain users logs don't show up, what happened?

    50 | There are multiple things that can result in this: 51 |
      52 |
    • The user hasn't talked in chat while the logviewer was enabled in the channel.
    • 53 |
    • The user hasn't talked for too long and his logs have been deleted.
    • 54 |
    • A downtime has caused certain messages not to be recorded.
    • 55 |
    • Twitch chat has been known to "drop" messages, albeit this has become very rare in recent times.
    • 56 |
    57 |
  • 58 |
  • Can you add/improve a feature?

    59 | I am always happy to listen to feedback, requests and suggestions, so please contact me! 60 |
  • 61 |
  • Can you remove a feature?

    62 | The feature set of the logviewer has been carefully designed and chosen, and in most cases, features are built-in for good. If you feel strongly about something, contact me and I might add a toggle for it. 63 |
  • 64 |
  • Can you import my old logs (from another page)?

    65 | I have an importer from overrustlelogs, so if you have logs to be imported from there, contact me about it. If it is some other kind of log, talk to me, but don't expect me to write a parser for all kinds of logs. 66 |
  • 67 |
  • Why is the user "logviewer" in my channel? Do I need to mod it?

    68 | The chat logger connects via twitch chat using the account "logviewer" (and no other username or variation of it). It currently only needs moderator permissions if you want to use the "mod logs" feature and will never talk in chat. It is there to collect logs and receive and store your moderator list as well as notify you of its presence in the user list. DO NOT BAN IT, as this will break the bot for you.
    69 | It is protected by Two-Factor-Authentication, meaning it is essentially impossible that it will ever get hacked. If you notice suspicious activity, please notify me immediately. 70 |
  • 71 |
  • That icon is great! Who made it?

    72 | The icon was made by whsky - I love it! 73 |
  • 74 |
75 | 76 |
77 | 78 | Let's Kappa! 79 |
-------------------------------------------------------------------------------- /html/css/base.css: -------------------------------------------------------------------------------- 1 | #main { 2 | float: left; 3 | width: 100%; 4 | min-height: calc(100% - 50px); 5 | } 6 | 7 | #main.largecolumn { 8 | width: 80%; 9 | } 10 | 11 | @media (max-width:960px) { 12 | #main.largecolumn { 13 | width: 100%; 14 | } 15 | } 16 | 17 | .content { 18 | width: 85%; 19 | margin-bottom: 20px; 20 | } 21 | 22 | @media (max-width:1200px) { 23 | .content { 24 | float: none; 25 | width: 90%; 26 | } 27 | } 28 | 29 | .panel { 30 | border: 0; 31 | } 32 | 33 | 34 | ::-webkit-scrollbar { 35 | max-width: 10px; 36 | min-width: 4px; 37 | width: 1%; 38 | height: 4px; 39 | transition: all 0.1s linear; 40 | border-radius: 50%; 41 | } 42 | 43 | 44 | ::-webkit-scrollbar-track { 45 | background: rgba(0, 0, 0, 0); 46 | } 47 | 48 | 49 | ::-webkit-scrollbar-track:hover { 50 | background: rgba(0, 0, 0, .2); 51 | } 52 | 53 | 54 | ::-webkit-scrollbar-thumb { 55 | background: #a5abb1; 56 | } 57 | 58 | 59 | [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { 60 | display: none !important; 61 | } 62 | 63 | 64 | 65 | .md-button.md-raised.twitch-button { 66 | background-color: #6441a5; 67 | color: white; 68 | } 69 | 70 | .md-button.md-raised:not([disabled]):hover.twitch-button { 71 | background-color: #B9A3E3; 72 | } 73 | 74 | .header { 75 | height: 50px; 76 | width: 100%; 77 | background-color: #19191F; 78 | } 79 | 80 | .header a, .header a:hover, .header a:visited, .header a:active { 81 | text-decoration: none; 82 | margin: 0 10px; 83 | font-size: 20px; 84 | opacity: 0.7; 85 | color: white; 86 | } 87 | 88 | .header a.active { 89 | opacity: 1; 90 | } 91 | 92 | .usermenu { 93 | background-color: #19191F; 94 | position: absolute; 95 | top: 50px; 96 | left: 0; 97 | padding: 8px 15px;; 98 | width: 200px; 99 | z-index: 999; 100 | color: white; 101 | } 102 | 103 | .fab-nobackground { 104 | background-color: transparent !important; 105 | } 106 | 107 | 108 | .nobottom .md-errors-spacer, md-autocomplete[md-floating-label] md-input-container .md-errors-spacer { 109 | display: none; 110 | } 111 | 112 | md-input-container.nobottom { 113 | margin-bottom: 0; 114 | padding-bottom: 0; 115 | } 116 | 117 | md-input-container.notop { 118 | margin-top: 0; 119 | } 120 | 121 | .md-button.md-small { 122 | line-height: 20px; 123 | min-height: 0; 124 | min-width: 0; 125 | font-size: 12px; 126 | padding: 5px 10px; 127 | } 128 | 129 | md-icon svg { 130 | fill: rgba(0,0,0,0.54); 131 | } 132 | 133 | .md-button.md-primary.md-raised md-icon svg { 134 | fill: white; 135 | } 136 | 137 | .menu-button md-icon svg { 138 | fill: white; 139 | } 140 | 141 | md-checkbox.darkbg .md-icon { 142 | border-color: rgba(255,255,255,0.54); 143 | } 144 | 145 | .hide-gt-s { 146 | display: none; 147 | } 148 | @media (max-width:700px) { 149 | .md-button.md-small-xs { 150 | line-height: 20px; 151 | min-height: 0; 152 | min-width: 0; 153 | font-size: 12px; 154 | padding: 5px 10px; 155 | } 156 | 157 | .header a, .header a:hover, .header a:visited, .header a:active { 158 | margin: 0 7px; 159 | font-size: 15px; 160 | } 161 | 162 | .hide-s { 163 | display: none; 164 | } 165 | 166 | .hide-gt-s { 167 | display: inherit; 168 | } 169 | } 170 | 171 | div.big-error-icon md-icon { 172 | font-size: 60px; 173 | } 174 | 175 | div.big-error-icon md-icon svg { 176 | fill: #c00; 177 | } 178 | 179 | div.big-error-icon { 180 | width: 60px; 181 | height: 60px; 182 | margin: 10px; 183 | } 184 | 185 | div.big-error-icon md-icon svg { 186 | width: 60px; 187 | height: 60px; 188 | } 189 | 190 | /* footer */ 191 | div[ng-include] { 192 | width: 100%; 193 | } 194 | 195 | .footer { 196 | width: 100%; 197 | background-color: white; 198 | height: 36px; 199 | } 200 | 201 | .footer a, .footer a:visited, .footer a:hover, .footer a:active { 202 | text-decoration: none; 203 | color: #66757f; 204 | font-weight: bold; 205 | font-family: 'Open Sans', sans-serif; 206 | } 207 | 208 | .donate-btn { 209 | height: 100%; 210 | padding: 0 6px; 211 | } 212 | 213 | .donate-btn a { 214 | height: 21px; 215 | } 216 | 217 | .donate-btn a img { 218 | height: 100%; 219 | } 220 | 221 | .pixel { 222 | display: none; 223 | } 224 | 225 | .contacts a, .contacts a:visited, .contacts a:hover, .contacts a:active { 226 | text-decoration: none; 227 | color: #66757f; 228 | font-weight: bold; 229 | font-family: 'Open Sans', sans-serif; 230 | } 231 | 232 | .rightbutton { 233 | float: right; 234 | margin: 0; 235 | padding 0; 236 | } 237 | 238 | .closedialog { 239 | display: block; 240 | order: 2; 241 | } 242 | 243 | div.inline { 244 | display: inline-block; 245 | } 246 | 247 | img.logo { 248 | padding: 0; 249 | width: 20px; 250 | vertical-align: middle; 251 | } 252 | 253 | #forkongithub a { 254 | background: #000; 255 | color: #fff; 256 | text-decoration: none; 257 | text-align: center; 258 | font-weight: bold; 259 | padding: 5px 40px; 260 | font-size: 1rem; 261 | line-height: 2rem; 262 | position: relative; 263 | transition: 0.5s; 264 | } 265 | 266 | #forkongithub a:hover { 267 | background: #c11; 268 | color: #fff; 269 | } 270 | 271 | #forkongithub a::before, #forkongithub a::after { 272 | content: ""; 273 | width: 100%; 274 | display: block; 275 | position: absolute; 276 | top: 1px; 277 | left: 0; 278 | height: 1px; 279 | background: #fff; 280 | } 281 | 282 | #forkongithub a::after { 283 | bottom: 1px; 284 | top: auto; 285 | } 286 | 287 | @media screen and (min-width: 700px) { 288 | #forkongithub { 289 | position: absolute; 290 | display: block; 291 | left: 0; 292 | width: 200px; 293 | overflow: hidden; 294 | height: 200px; 295 | } 296 | 297 | #forkongithub a { 298 | width: 200px; 299 | position: absolute; 300 | top: 60px; 301 | left: -60px; 302 | transform: rotate(-45deg); 303 | -webkit-transform: rotate(-45deg); 304 | -ms-transform: rotate(-45deg); 305 | -moz-transform: rotate(-45deg); 306 | -o-transform: rotate(-45deg); 307 | box-shadow: 4px 4px 10px rgba(0,0,0,0.8); 308 | } 309 | } 310 | 311 | .leftpad { 312 | padding-left: 8px; 313 | } -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Logviewer 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 46 | 47 | 48 | 49 | 50 |
51 |
52 | 53 | 54 |
55 | Dark mode
56 | Enable chat
57 | Chat on the left 58 |
59 |
60 | Home 61 | Logs 62 | Settings 63 | Leaderboard new! 64 | 65 |
66 |
67 |
68 | Log out ({{::auth.name}}) 69 |
70 |
71 | Login with twitch 72 |
73 |
74 | 75 |
76 | 77 | 78 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /html/settings.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | loading... 4 |
5 |
6 |
7 |
8 | 9 |
10 |
11 | You currently do not have access to change settings of the channel {{channel}}.
12 | If you have {{10 | aAnAccountType }} account, then you can Login with twitch 13 |
14 |
15 |
16 |
17 |

Event log

18 |
19 |
20 | {{event.time | secondsTimestamp | date : 'yyyy/MM/dd hh:mm a'}} {{event.action}} {{event.user}} {{event.desc}} 21 |
22 |
23 | No items found. 24 |
25 |
26 |
27 |
28 |
29 |

Settings

30 | Enable the logviewer
31 | Enable moderator logs beta 32 | 33 | 34 | 35 |

Permissions

36 | 37 | everyone 38 | logged in users 39 | regulars 40 | moderators 41 | super-moderators 42 | managers 43 | can read logs
44 | 45 | everyone 46 | logged in users 47 | regulars 48 | moderators 49 | super-moderators 50 | managers 51 | can view mod logs
52 | 53 | everyone 54 | logged in users 55 | regulars 56 | moderators 57 | super-moderators 58 | managers 59 | can read comments
60 | 61 | logged in users 62 | regulars 63 | moderators 64 | super-moderators 65 | managers 66 | can write comments
67 | 68 | logged in users 69 | regulars 70 | moderators 71 | super-moderators 72 | managers 73 | can delete comments
74 | 75 |
76 |
77 |

Users

78 | 79 | 80 | 81 | 82 | 83 | Hint: Moderators are detected automatically! 84 | 85 | 90 | 104 | 105 |
UsernameLevel
86 | 87 | 88 | 89 | 91 | 92 | 93 | banned 94 | default 95 | regular 96 | moderator 97 | super-moderator 98 | manager 99 | admin 100 | dev 101 | 102 | 103 |
106 |
107 |
108 |
109 | 110 | SAVE 111 | 112 |
113 |
114 |
115 |
-------------------------------------------------------------------------------- /html/js/messageparser.js: -------------------------------------------------------------------------------- 1 | var rx = /^(?:@([^ ]+) )?(?:[:](\S+) )?(\S+)(?: (?!:)(.+?))?(?: [:](.+))?$/; 2 | var rx2 = /([^=;]+)=([^;]*)/g; 3 | var STATE_V3 = 1; 4 | var STATE_PREFIX = 2; 5 | var STATE_COMMAND = 3; 6 | var STATE_PARAM = 4; 7 | var STATE_TRAILING = 5; 8 | function parseIRCMessage(message) { 9 | var data = rx.exec(message); 10 | var tagdata = data[STATE_V3]; 11 | if (tagdata) { 12 | var tags = {}; 13 | do { 14 | m = rx2.exec(tagdata); 15 | if (m) { 16 | tags[m[1]] = m[2]; 17 | } 18 | } while (m); 19 | data[STATE_V3] = tags; 20 | } 21 | return data; 22 | } 23 | 24 | function splitWithTail(str,delim,count){ 25 | var parts = str.split(delim); 26 | var tail = parts.slice(count).join(delim); 27 | var result = parts.slice(0,count); 28 | result.push(tail); 29 | return result; 30 | } 31 | 32 | function getPrivmsgInfo(parsedmessage) { 33 | var tags = parsedmessage[STATE_V3] || {}; 34 | var nick = parsedmessage[STATE_PREFIX].match(/(\w+)/)[1]; 35 | var channel = parsedmessage[STATE_PARAM]; 36 | var badges = []; 37 | if(tags.badges) badges = tags.badges.split(","); 38 | 39 | var text = parsedmessage[STATE_TRAILING]; 40 | var isaction = false; 41 | var actionmatch = /^\u0001ACTION (.*)\u0001$/.exec(text); 42 | if(actionmatch != null) { 43 | isaction = true; 44 | text = actionmatch[1]; 45 | } 46 | var emoteparsertext = text+""; 47 | var surrogates = []; 48 | 49 | for (var i = 0; i < emoteparsertext.length; ++i) { 50 | var charcode = emoteparsertext.charCodeAt(i); 51 | if (charcode <= 0xDBFF && charcode >= 0xD800) { 52 | surrogates.push([charcode, emoteparsertext.charCodeAt(i + 1)]); 53 | ++i; 54 | } 55 | } 56 | // Replace surrogates while calculating emotes 57 | for (var i = 0; i < surrogates.length; ++i) { 58 | emoteparsertext = emoteparsertext.replace(String.fromCharCode(surrogates[i][0], surrogates[i][1]), String.fromCharCode(0xE000 + i)); 59 | } 60 | 61 | 62 | var emotes = []; 63 | if(tags["emotes"]) { 64 | var emotelists = tags["emotes"].split("/"); 65 | for(var i=0;i": ">", 106 | '"': '"', 107 | "'": ''', 108 | "/": '/' 109 | }; 110 | 111 | function escapeHtml(string) { 112 | return String(string).replace(/[&<>"'\/]/g, function (s) { 113 | return entityMap[s]; 114 | }); 115 | } 116 | 117 | DEFAULTCOLORS = ['#e391b8', '#e091ce', '#da91de', '#c291db', '#ab91d9', '#9691d6', '#91a0d4', '#91b2d1', '#91c2cf', '#91ccc7', '#91c9b4', '#90c7a2', '#90c492', '#9dc290', '#aabf8f', '#b5bd8f', '#bab58f', '#b8a68e', '#b5998e', '#b38d8d'] 118 | function renderMessage(messageinfo, badges) { 119 | var result = "" 120 | for(var i=0;i' 126 | } 127 | } 128 | } 129 | 130 | var color = messageinfo.tags["color"]; 131 | if(color == "") { 132 | color = DEFAULTCOLORS[sdbmCode(messageinfo.nick)%(DEFAULTCOLORS.length)]; 133 | } 134 | var display_name = messageinfo.tags["display-name"]; 135 | if(display_name == "") { 136 | display_name = messageinfo.nick; 137 | } 138 | 139 | 140 | var message = messageinfo.textWithSurrogatesInPUAs; 141 | if(display_name == "jtv" || display_name == "twitchnotify" || display_name == "Twitchnotify") { 142 | result += '' 143 | } 144 | else if (display_name == "twitchbot") { 145 | result += 'Message was flagged for moderator attention: ' 146 | } 147 | else if(messageinfo.isaction) { 148 | result += ''+display_name+' ' 149 | } 150 | else { 151 | result += ''+display_name+': '; 152 | } 153 | 154 | 155 | // replace emotes 156 | 157 | // sort in descending order 158 | var emotes = messageinfo.emotes.sort(function(a,b){ return a.start-b.start; }); 159 | var position = 0; 160 | for(var i=0;i'; 165 | } 166 | result += escapeHtml(message.substring(position)); 167 | // close span tag 168 | result += ''; 169 | 170 | 171 | // Put surrogate pairs back in 172 | for (var i = 0; i < messageinfo.surrogates.length; ++i) { 173 | result = result.replace(String.fromCharCode(0xE000 + i), String.fromCharCode(messageinfo.surrogates[i][0], messageinfo.surrogates[i][1])); 174 | } 175 | result = result.replace(/[\uE000-\uF8FF]/g, function (x) { 176 | return String.fromCharCode(0xD800 + (x.charCodeAt(0) - 0xE000)); 177 | }); 178 | 179 | return result; 180 | } 181 | -------------------------------------------------------------------------------- /messagecompressor.js: -------------------------------------------------------------------------------- 1 | var winston = require('winston'); 2 | 3 | var TAGS = 1 4 | var PREFIX = 2 5 | var COMMAND = 3 6 | var PARAM = 4 7 | var TRAILING = 5 8 | 9 | function toTitleCase(str) 10 | { 11 | return str.replace(/([^a-z0-9]|\b)[a-z]/g,function(x){return x.toUpperCase()}); 12 | } 13 | 14 | function compressEmotes(emotes) { 15 | /* 16 | * Compresses the emotes tag 17 | * 2:0-1,3-4,12-13,18-19,21-22 18 | * is compressed to 19 | * 2:1,0,3,12,18,21 20 | * so the format is 21 | * emote1id:length1,start1,start2,start3:length2,start4,start5/emote2id:length3,start6 22 | */ 23 | var res = ""; 24 | var emotesplit = emotes.split("/"); 25 | for(var i=0;i 97 | * where ircv3info is 98 | * 99 | * with emotes compressed 100 | */ 101 | var res = ""; 102 | if(data[TAGS]) { 103 | var defaults = {"color":"","emotes":"","display-name":toTitleCase(user),"badges":""}; 104 | for(var i=0;i 0) { 63 | return true; 64 | } 65 | var changedlevels = getListChanges(oldlevels,$scope.levels); 66 | return changedlevels.length!=0; 67 | } 68 | 69 | var errorToast = function(reason) { 70 | var reasonString = { 71 | 403: "Error: Access denied.", 72 | 404: "Error: Channel not found." 73 | }[reason.status] || "An unknown error occurred. Please try again later."; 74 | $mdToast.show($mdToast.simple({ 75 | parent: "#main", 76 | textContent: reasonString, 77 | position: "top right", 78 | hideDelay: 3000 79 | })); 80 | }; 81 | 82 | var saveSettings = function() { 83 | var changedsettings = getChanges(oldsettings,$scope.settings); 84 | var changed = []; 85 | if(Object.keys(changedsettings).length > 0) { 86 | changed.push("settings"); 87 | return new Promise(function(r,j) { 88 | $http.post("/api/settings/"+$stateParams.channel, {token: $rootScope.auth.token, settings: changedsettings}).then(function(){r(changed);},j); 89 | }); 90 | } else { 91 | return Promise.resolve(changed); 92 | } 93 | } 94 | 95 | var saveLevels = function(changed) { 96 | var changedlevels = getListChanges(oldlevels,$scope.levels); 97 | if(changedlevels.length!=0) { 98 | changed.push("levels"); 99 | return new Promise(function(r,j) { 100 | $http.post("/api/levels/"+$stateParams.channel, {token: $rootScope.auth.token, levels: changedlevels}).then(function(){r(changed);},j); 101 | }); 102 | } else { 103 | return Promise.resolve(changed); 104 | } 105 | } 106 | 107 | $scope.save = function() { 108 | saveSettings() 109 | .then(saveLevels) 110 | .then(function(changed){ 111 | oldsettings = angular.copy($scope.settings); 112 | oldlevels = angular.copy($scope.levels); 113 | $mdToast.show($mdToast.simple({ 114 | parent: "#main", 115 | textContent: changed.join(" and ") + " saved", 116 | position: "top right", 117 | hideDelay: 3000 118 | })); 119 | }, errorToast); 120 | } 121 | 122 | $scope.addEmptyRow = function() { 123 | if($scope.levels.length > 0) { 124 | var lastrow = $scope.levels[$scope.levels.length - 1]; 125 | if(lastrow.nick != "") { 126 | $scope.levels.push({nick: "", level: 0}); 127 | } 128 | } 129 | else { 130 | $scope.levels.push({nick: "", level: 0}); 131 | } 132 | } 133 | 134 | $scope.events = []; 135 | 136 | var prettyToggle = { 137 | "active": "the logviewer", 138 | "modlogs": "mod logs" 139 | }; 140 | 141 | var prettyPerm = { 142 | "viewlogs": "view logs", 143 | "viewmodlogs": "view mod logs", 144 | "viewcomments": "view comments", 145 | "writecomments": "write comments", 146 | "deletecomments": "delete comments" 147 | }; 148 | 149 | var userGroup = { 150 | "-10": "banned", 151 | 0: "everyone", 152 | 1: "logged in user", 153 | 2: "regular", 154 | 5: "moderator", 155 | 7: "super-moderator", 156 | 10: "manager", 157 | 50: "admin", 158 | 1337: "developer" 159 | }; 160 | var commentAction = { 161 | "add": "added", 162 | "edit": "edited", 163 | "delete": "deleted" 164 | }; 165 | var eventParser = { 166 | "channel": function(event) { 167 | if(event.name == "add") return "added the logviewer to the channel."; 168 | }, 169 | "setting": function(event) { 170 | if(prettyToggle[event.name]) { 171 | if(event.data == "1") { 172 | return "enabled "+prettyToggle[event.name]+"." 173 | } else { 174 | return "disabled "+prettyToggle[event.name]+"." 175 | } 176 | } else if(prettyPerm[event.name]) { 177 | return "set "+prettyPerm[event.name]+" to "+(userGroup[event.data] || event.data)+(event.data > 0?"s and higher only":""); 178 | } 179 | }, 180 | "level": function(event) { 181 | return "set the user level of "+event.name+" to "+userGroup[event.data]; 182 | }, 183 | "comment": function(event) { 184 | var comment = JSON.parse(event.data); 185 | if(comment.author != event.user) { 186 | return commentAction[event.name]+" a comment by "+comment.author; 187 | } 188 | }, 189 | "system": function(event) { 190 | return event.data; 191 | }, 192 | "command": function(event) { 193 | return "used command "+event.data; 194 | } 195 | } 196 | 197 | var addEvent = function(ev) { 198 | if(eventParser[ev.action]) { 199 | var desc = eventParser[ev.action](ev); 200 | if(desc) { 201 | $scope.events.push({action: ev.action, user: ev.user, desc: desc, time: ev.time}); 202 | } 203 | } 204 | } 205 | 206 | $http.jsonp("/api/events/"+$stateParams.channel+"/?token="+$rootScope.auth.token+"&callback=JSON_CALLBACK").then(function(result) { 207 | $scope.events = []; 208 | for(var i=0;i 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 129 | 130 | 132 | 133 | 135 | 137 | 138 | 140 | 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /html/css/roboto-1-3-4-5-7.css: -------------------------------------------------------------------------------- 1 | /* cyrillic-ext */ 2 | @font-face { 3 | font-family: 'Roboto'; 4 | font-style: normal; 5 | font-weight: 100; 6 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/ty9dfvLAziwdqQ2dHoyjphTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); 7 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 8 | } 9 | /* cyrillic */ 10 | @font-face { 11 | font-family: 'Roboto'; 12 | font-style: normal; 13 | font-weight: 100; 14 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/frNV30OaYdlFRtH2VnZZdhTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); 15 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 16 | } 17 | /* greek-ext */ 18 | @font-face { 19 | font-family: 'Roboto'; 20 | font-style: normal; 21 | font-weight: 100; 22 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/gwVJDERN2Amz39wrSoZ7FxTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); 23 | unicode-range: U+1F00-1FFF; 24 | } 25 | /* greek */ 26 | @font-face { 27 | font-family: 'Roboto'; 28 | font-style: normal; 29 | font-weight: 100; 30 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/aZMswpodYeVhtRvuABJWvBTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); 31 | unicode-range: U+0370-03FF; 32 | } 33 | /* vietnamese */ 34 | @font-face { 35 | font-family: 'Roboto'; 36 | font-style: normal; 37 | font-weight: 100; 38 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/VvXUGKZXbHtX_S_VCTLpGhTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); 39 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 40 | } 41 | /* latin-ext */ 42 | @font-face { 43 | font-family: 'Roboto'; 44 | font-style: normal; 45 | font-weight: 100; 46 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/e7MeVAyvogMqFwwl61PKhBTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); 47 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 48 | } 49 | /* latin */ 50 | @font-face { 51 | font-family: 'Roboto'; 52 | font-style: normal; 53 | font-weight: 100; 54 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/2tsd397wLxj96qwHyNIkxPesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); 55 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 56 | } 57 | /* cyrillic-ext */ 58 | @font-face { 59 | font-family: 'Roboto'; 60 | font-style: normal; 61 | font-weight: 300; 62 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/0eC6fl06luXEYWpBSJvXCBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 63 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 64 | } 65 | /* cyrillic */ 66 | @font-face { 67 | font-family: 'Roboto'; 68 | font-style: normal; 69 | font-weight: 300; 70 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/Fl4y0QdOxyyTHEGMXX8kcRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 71 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 72 | } 73 | /* greek-ext */ 74 | @font-face { 75 | font-family: 'Roboto'; 76 | font-style: normal; 77 | font-weight: 300; 78 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/-L14Jk06m6pUHB-5mXQQnRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 79 | unicode-range: U+1F00-1FFF; 80 | } 81 | /* greek */ 82 | @font-face { 83 | font-family: 'Roboto'; 84 | font-style: normal; 85 | font-weight: 300; 86 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/I3S1wsgSg9YCurV6PUkTORJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 87 | unicode-range: U+0370-03FF; 88 | } 89 | /* vietnamese */ 90 | @font-face { 91 | font-family: 'Roboto'; 92 | font-style: normal; 93 | font-weight: 300; 94 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/NYDWBdD4gIq26G5XYbHsFBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 95 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 96 | } 97 | /* latin-ext */ 98 | @font-face { 99 | font-family: 'Roboto'; 100 | font-style: normal; 101 | font-weight: 300; 102 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/Pru33qjShpZSmG3z6VYwnRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 103 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 104 | } 105 | /* latin */ 106 | @font-face { 107 | font-family: 'Roboto'; 108 | font-style: normal; 109 | font-weight: 300; 110 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/Hgo13k-tfSpn0qi1SFdUfVtXRa8TVwTICgirnJhmVJw.woff2) format('woff2'); 111 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 112 | } 113 | /* cyrillic-ext */ 114 | @font-face { 115 | font-family: 'Roboto'; 116 | font-style: normal; 117 | font-weight: 400; 118 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); 119 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 120 | } 121 | /* cyrillic */ 122 | @font-face { 123 | font-family: 'Roboto'; 124 | font-style: normal; 125 | font-weight: 400; 126 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); 127 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 128 | } 129 | /* greek-ext */ 130 | @font-face { 131 | font-family: 'Roboto'; 132 | font-style: normal; 133 | font-weight: 400; 134 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); 135 | unicode-range: U+1F00-1FFF; 136 | } 137 | /* greek */ 138 | @font-face { 139 | font-family: 'Roboto'; 140 | font-style: normal; 141 | font-weight: 400; 142 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/u0TOpm082MNkS5K0Q4rhqvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); 143 | unicode-range: U+0370-03FF; 144 | } 145 | /* vietnamese */ 146 | @font-face { 147 | font-family: 'Roboto'; 148 | font-style: normal; 149 | font-weight: 400; 150 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); 151 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 152 | } 153 | /* latin-ext */ 154 | @font-face { 155 | font-family: 'Roboto'; 156 | font-style: normal; 157 | font-weight: 400; 158 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); 159 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 160 | } 161 | /* latin */ 162 | @font-face { 163 | font-family: 'Roboto'; 164 | font-style: normal; 165 | font-weight: 400; 166 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/CWB0XYA8bzo0kSThX0UTuA.woff2) format('woff2'); 167 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 168 | } 169 | /* cyrillic-ext */ 170 | @font-face { 171 | font-family: 'Roboto'; 172 | font-style: normal; 173 | font-weight: 500; 174 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 175 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 176 | } 177 | /* cyrillic */ 178 | @font-face { 179 | font-family: 'Roboto'; 180 | font-style: normal; 181 | font-weight: 500; 182 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 183 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 184 | } 185 | /* greek-ext */ 186 | @font-face { 187 | font-family: 'Roboto'; 188 | font-style: normal; 189 | font-weight: 500; 190 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 191 | unicode-range: U+1F00-1FFF; 192 | } 193 | /* greek */ 194 | @font-face { 195 | font-family: 'Roboto'; 196 | font-style: normal; 197 | font-weight: 500; 198 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 199 | unicode-range: U+0370-03FF; 200 | } 201 | /* vietnamese */ 202 | @font-face { 203 | font-family: 'Roboto'; 204 | font-style: normal; 205 | font-weight: 500; 206 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 207 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 208 | } 209 | /* latin-ext */ 210 | @font-face { 211 | font-family: 'Roboto'; 212 | font-style: normal; 213 | font-weight: 500; 214 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 215 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 216 | } 217 | /* latin */ 218 | @font-face { 219 | font-family: 'Roboto'; 220 | font-style: normal; 221 | font-weight: 500; 222 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2) format('woff2'); 223 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 224 | } 225 | /* cyrillic-ext */ 226 | @font-face { 227 | font-family: 'Roboto'; 228 | font-style: normal; 229 | font-weight: 700; 230 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/77FXFjRbGzN4aCrSFhlh3hJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 231 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 232 | } 233 | /* cyrillic */ 234 | @font-face { 235 | font-family: 'Roboto'; 236 | font-style: normal; 237 | font-weight: 700; 238 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/isZ-wbCXNKAbnjo6_TwHThJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 239 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 240 | } 241 | /* greek-ext */ 242 | @font-face { 243 | font-family: 'Roboto'; 244 | font-style: normal; 245 | font-weight: 700; 246 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/UX6i4JxQDm3fVTc1CPuwqhJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 247 | unicode-range: U+1F00-1FFF; 248 | } 249 | /* greek */ 250 | @font-face { 251 | font-family: 'Roboto'; 252 | font-style: normal; 253 | font-weight: 700; 254 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/jSN2CGVDbcVyCnfJfjSdfBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 255 | unicode-range: U+0370-03FF; 256 | } 257 | /* vietnamese */ 258 | @font-face { 259 | font-family: 'Roboto'; 260 | font-style: normal; 261 | font-weight: 700; 262 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/PwZc-YbIL414wB9rB1IAPRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 263 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 264 | } 265 | /* latin-ext */ 266 | @font-face { 267 | font-family: 'Roboto'; 268 | font-style: normal; 269 | font-weight: 700; 270 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/97uahxiqZRoncBaCEI3aWxJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); 271 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 272 | } 273 | /* latin */ 274 | @font-face { 275 | font-family: 'Roboto'; 276 | font-style: normal; 277 | font-weight: 700; 278 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/d-6IYplOFocCacKzxwXSOFtXRa8TVwTICgirnJhmVJw.woff2) format('woff2'); 279 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 280 | } -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | var winston = require('winston'); 2 | var request = require('request'); 3 | var messagecompressor = require('./messagecompressor'); 4 | 5 | function API(settings, db, bot, io) { 6 | this.settings = settings; 7 | this.db = db; 8 | this.bot = bot; 9 | this.io = io; 10 | this.streaming = {}; 11 | this.checkStreams(); 12 | setInterval(API.prototype.checkStreams.bind(this), 60*1000); 13 | } 14 | 15 | // generic helpers 16 | 17 | // returns the smallest absolute maximum of the inputs 18 | // for example, absMinMax(5,10,-10) would return -10 19 | function absMinMax(){ 20 | var best=0; 21 | for(var i=0;i{ 38 | request.get({url: url, headers: headers}, function (error, response, body) { 39 | if(error) j(error, response); 40 | else { 41 | try { 42 | r(JSON.parse(body), response); 43 | } catch(e) { 44 | j(e, response); 45 | } 46 | } 47 | }); 48 | }); 49 | } 50 | 51 | API.prototype.adminLog = function(channel, user, action, key, data) { 52 | var t = Math.floor(Date.now()/1000); 53 | this.io.to("events-"+channel).emit("adminlog", {channel: channel, user: user, action: action, name: key, data: data, time: t}); 54 | this.db.adminLog(channel, user, action, key, data); 55 | winston.debug("Emitting adminlog: "+action+" on channel "+channel); 56 | } 57 | 58 | API.prototype.getChannelID = function(channelname) { 59 | var self = this; 60 | return self.twitchGet("https://api.twitch.tv/kraken/users?login="+channelname) 61 | } 62 | 63 | // helper function: gets both the channel object and the user level of the specified token 64 | API.prototype.getChannelObjAndLevel = function(channelname, token, callback) { 65 | var self = this; 66 | channelname = channelname.toLowerCase(); 67 | self.db.getChannel({name: channelname}, function(channelObj) { 68 | if(channelObj) { 69 | self.getLevel(channelObj, token, function(level, username){ 70 | callback(null, channelObj, level, username); 71 | }); 72 | } else { 73 | self.getLevel({name: channelname, id: }, token, function(level, username){ 74 | callback({status: 404, message: "Channel "+channelname+" not found."}, null, level, username); 75 | }); 76 | 77 | } 78 | }); 79 | } 80 | 81 | 82 | // gets the level of a user by name 83 | API.prototype.getUserLevel = function(channelObj,id,callback) { 84 | var self = this; 85 | var reslvl = null; 86 | var templvl = 0; 87 | if(name) { 88 | if(channelObj) { 89 | if(self.bot.userlevels[channelObj.id] && self.bot.userlevels[channelObj.id][name]) templvl = self.bot.userlevels[channelObj.id][name]; 90 | if(channelObj.id === id) templvl = 10; 91 | self.db.getUserLevel(channelObj, id, function(lv){ 92 | if(reslvl === null) { 93 | reslvl = lv; 94 | } else { 95 | callback(absMinMax(1,reslvl,lv,templvl)); 96 | } 97 | }); 98 | } else { 99 | reslvl = 0; 100 | } 101 | 102 | self.db.getUserLevel({id: self.settings.bot.id, name: self.settings.bot.nick}, id, function(lv){ 103 | if(reslvl === null) { 104 | reslvl = lv; 105 | } else { 106 | callback(absMinMax(1,reslvl,lv,templvl)); 107 | } 108 | }); 109 | } else callback(0); 110 | } 111 | 112 | // gets the level of a user by token 113 | API.prototype.getLevel = function(channelObj, token, callback) { 114 | var self = this; 115 | if(!token || token === "") callback(0); 116 | else { 117 | self.db.getAuthUser(token, function(name, id){ 118 | self.getUserLevel(channelObj,id,function(level){ 119 | callback(level,name,id); 120 | }); 121 | }); 122 | } 123 | } 124 | 125 | // updates a channel settings 126 | var allowedsettings = ["active","modlogs","viewlogs","viewmodlogs","viewcomments","writecomments","deletecomments"]; 127 | API.prototype.updateSettings = function(channelObj, user, settings, callback) { 128 | var self = this; 129 | var error; 130 | var async = false; // TODO: promises 131 | console.log(settings); 132 | 133 | for(var i=0;i= channelObj.editcomments || comment.author == username) { 289 | var time = Math.floor(Date.now()/1000); 290 | comment.edited = time; 291 | comment.text = newcomment.text; 292 | self.adminLog(channelObj.name, username, "comment", "edit", JSON.stringify(comment)); 293 | self.db.updateComment(channelObj.name, comment.id, comment.text); 294 | // only send back stuff needed for identification and changes 295 | self.io.to("comments-"+channelObj.name+"-"+comment.topic).emit("comment-update", comment); 296 | callback(); 297 | } else { 298 | callback({"status": 403, "message":"Can only edit own comments"}); 299 | return; 300 | } 301 | } else { 302 | callback({"status": 404, "message":"Comment not found"}); 303 | } 304 | }); 305 | } else { 306 | if(newcomment.topic === undefined) { 307 | callback({"status":400, "message":"Missing parameter topic."}); 308 | } else if(newcomment.text === undefined) { 309 | callback({"status":400, "message":"Missing parameter text."}); 310 | } else if(level >= channelObj.writecomments) { 311 | var time = Math.floor(Date.now()/1000); 312 | self.db.addComment(channelObj.name, username, newcomment.topic, newcomment.text, function(id){ 313 | var comment = {id: id, added: time, edited: time, channel: channelObj.name, author: username, topic: newcomment.topic, text: newcomment.text}; 314 | self.adminLog(channelObj.name, username, "comment", "add", JSON.stringify(comment)); 315 | self.io.to("comments-"+channelObj.name+"-"+newcomment.topic).emit("comment-add", comment); 316 | }); 317 | callback(); 318 | } else { 319 | callback({"status":403, "message":"Cannot write comments for this channel"}); 320 | return; 321 | } 322 | } 323 | } 324 | 325 | API.prototype.getChannels = function(callback){ 326 | var self = this; 327 | self.db.getChannelList(function(channels) { 328 | for(var i=0;ix.name); 345 | self.twitchGet("https://api.twitch.tv/kraken/streams?limit=100&channel="+channelChunk.join(",")).then(function(data){ 346 | // reset streams 347 | for(let j=0;j 2 |
3 | loading... 4 |
5 |
6 |
7 |
8 | 9 |
10 |
11 | Channel {{::channel}} not found.
12 | If you want to add the logviewer to the channel, then head over to the 13 | settings. 14 |
15 |
16 |
17 |
18 |
19 | 20 |
21 |
22 | You currently do not have access to view logs of the channel {{::channel}}.
23 | If you have {{::channelsettings.viewlogs | aAnAccountType}} account, then you can Login with twitch 24 |
25 |
26 |
27 | 28 | 29 | 30 |

User logs for channel {{::channelsettings.name}}

31 |

Logs for channel {{::channelsettings.name}}

32 |
33 | 58 |
59 |
60 |
61 |
62 |
63 |
64 | {{user.data.nick}} 65 |
66 | Messages: {{user.data.messages}} 67 | Timeouts: {{user.data.timeouts}} 68 | Bans: {{user.data.bans}} 69 | Messages {{user.data.messages}} 70 | Timeouts {{user.data.timeouts}} 71 | Bans {{user.data.bans}} 72 |
73 |
74 | 75 | 76 | 77 |
78 |
79 |
80 |
81 | 82 | Load more 83 | 84 |
85 |
86 | 87 |
88 | 89 | 90 | Load more 91 | 92 |
93 | 94 |
95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | {{messageBefore.time | secondsTimestamp | date : 'yyyy/MM/dd hh:mm a'}} 104 | 105 | 106 | (By: {{modLogList(messageBefore.modlog)}}) 107 | 108 | 109 | 110 | 111 | 112 |
{{user}}{{modLogDisplay(time)}}
113 |
114 |
115 |
116 | 117 |
124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | {{message.time | secondsTimestamp | date : 'yyyy/MM/dd hh:mm a'}} 133 | 134 | 135 | (By: {{modLogList(message.modlog)}}) 136 | 137 | 138 | 139 | 140 | 141 |
{{user}}{{modLogDisplay(time)}}
142 |
143 |
144 |
145 | 146 |
147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | {{messageAfter.time | secondsTimestamp | date : 'yyyy/MM/dd hh:mm a'}} 156 | 157 | 158 | (By: {{modLogList(messageAfter.modlog)}}) 159 | 160 | 161 | 162 | 163 | 164 |
{{user}}{{modLogDisplay(time)}}
165 |
166 |
167 |
168 | 169 |
170 | 171 | 172 | Load more 173 | 174 |
175 |
176 | 177 |
178 | 179 | Load newer 180 | 181 |
182 |
183 |
No recorded chat messages found.
184 |
Loading...
185 |
186 |
187 | 188 |
189 |
190 |
191 |
192 | {{::comment.author}} {{comment|commentAge}} 193 |
194 |
195 | 196 | 197 | 198 | 199 | 200 | 201 |
202 |
203 |
204 |
205 | 206 | 207 | 208 | CANCEL 209 | SAVE 210 |
211 |
212 |
213 |
214 | 215 | 216 | 217 | 218 | SEND 219 |
220 |
221 | You currently do not have permission to read comments. 222 | If you have {{::channelsettings.viewcomments | aAnAccountType}} account, then please log in 223 |
224 |
225 | You currently do not have permission to write comments. 226 | If you have {{::channelsettings.writecomments | aAnAccountType}} account, then please log in 227 |
228 |
229 |
230 |
231 |
232 | 233 |
234 | 235 | Full log 236 | 237 | 238 |

Full logs for channel {{::channelsettings.name}}

239 |

Logs for channel {{::channelsettings.name}}

240 | Jump to date: 241 | 242 | 243 |
244 |
245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | {{message.time | secondsTimestamp | date : 'yyyy/MM/dd hh:mm a'}} 254 | 255 | 256 | (By: {{modLogList(message.modlog)}}) 257 | 258 | 259 | 260 | 261 | 262 |
{{user}}{{modLogDisplay(time)}}
263 |
264 |
265 |
266 |
267 |
268 |
End of logs reached.
269 |
270 |
271 |
272 |
273 |
274 |
275 | 276 |
277 | 278 |
279 | -------------------------------------------------------------------------------- /db/mysql.js: -------------------------------------------------------------------------------- 1 | var mysql = require('mysql'); 2 | var winston = require('winston'); 3 | 4 | module.exports = function MySQLDatabaseConnector(settings) { 5 | var self = this; 6 | 7 | self.pool = mysql.createPool({ 8 | connectionLimit: 100, 9 | host: settings.host, 10 | port: settings.port || 3306, 11 | user: settings.user, 12 | database: settings.database, 13 | password: settings.password, 14 | charset: "utf8mb4_unicode_ci" 15 | }); 16 | self.pool.getConnection(function(err, connection) { 17 | if(err) { 18 | winston.error('Error connecting to MySQL database: ' + err.stack); 19 | return; 20 | } 21 | // create the channels table if it doesnt exist 22 | connection.query("CREATE TABLE IF NOT EXISTS channels (" 23 | +"id int(10) UNSIGNED PRIMARY KEY," 24 | +"name varchar(32)," 25 | +"active tinyint(4) UNSIGNED NOT NULL DEFAULT '0'," 26 | +"modlogs tinyint(4) UNSIGNED NOT NULL DEFAULT '0'," 27 | +"viewlogs tinyint(4) UNSIGNED NOT NULL DEFAULT '0'," 28 | +"viewmodlogs tinyint(4) UNSIGNED NOT NULL DEFAULT '5'," 29 | +"viewcomments tinyint(4) UNSIGNED NOT NULL DEFAULT '5'," 30 | +"writecomments tinyint(4) UNSIGNED NOT NULL DEFAULT '5'," 31 | +"deletecomments tinyint(4) UNSIGNED NOT NULL DEFAULT '10'," 32 | +"color varchar(32) NULL," 33 | +"premium BIGINT UNSIGNED NOT NULL," // time of expiration of premium features 34 | +"`max-age` int(10) UNSIGNED NOT NULL DEFAULT '2678400'," // currently unused 35 | +"INDEX channels_by_name (name ASC)" 36 | +")"); 37 | // create the auth table if it doesnt exist 38 | connection.query("CREATE TABLE IF NOT EXISTS auth (" 39 | +"token varchar(64) PRIMARY KEY," 40 | +"userid INT UNSIGNED NULL," 41 | +"name varchar(32)," 42 | +"expires BIGINT UNSIGNED" 43 | +")"); 44 | // create the comment table if it doesnt exist 45 | connection.query("CREATE TABLE IF NOT EXISTS comments (" 46 | +"id INT NOT NULL AUTO_INCREMENT," 47 | +"added BIGINT UNSIGNED NOT NULL," 48 | +"edited BIGINT UNSIGNED NOT NULL," 49 | +"channel VARCHAR(32) NULL," 50 | +"author VARCHAR(32) NULL," 51 | +"topic VARCHAR(64) NULL," 52 | +"text TEXT NULL COLLATE utf8mb4_unicode_ci," 53 | +"PRIMARY KEY (id)," 54 | +"INDEX comments_by_channel_and_topic (channel ASC, topic ASC)" 55 | +")"); 56 | // create the alias table if it doesnt exist 57 | connection.query("CREATE TABLE IF NOT EXISTS aliases (" 58 | +"alias varchar(32) PRIMARY KEY," 59 | +"id INT UNSIGNED" 60 | +")"); 61 | 62 | // create the logviewer tables if they dont exist 63 | self.ensureTablesExist({name: "logviewer"}); 64 | 65 | /* create the integrations table if it doesnt exist */ 66 | connection.query("CREATE TABLE IF NOT EXISTS connections (" 67 | +"id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY," 68 | +"channel VARCHAR(32) NULL," 69 | +"level INT DEFAULT '0'," // access level of the connection 70 | +"app VARCHAR(32) NOT NULL PRIMARY KEY," // name of the app (for example "Slack") 71 | // identifier of the application (used to identify the location the request came from) 72 | // essentially the user name (for example, a Slack connection uses the slash command token to identify ) 73 | +"data VARCHAR(256) NULL," 74 | +"description TEXT NULL" // Full-text description 75 | +")"); 76 | 77 | /* create the integrations table if it doesnt exist */ 78 | connection.query("CREATE TABLE IF NOT EXISTS apps (" 79 | +"id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY," 80 | +"scopes VARCHAR(64) NULL," // optimal scopes this app needs 81 | +"name VARCHAR(32) NOT NULL PRIMARY KEY," // name of the app (for example "Slack") 82 | +"redirect_url VARCHAR(256) NULL," // url to redirect to after authenticating 83 | +"description TEXT NULL" // Full-text description 84 | +")"); 85 | 86 | /* create the admin log table if it doesnt exist */ 87 | connection.query("CREATE TABLE IF NOT EXISTS adminlog (" 88 | +"id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY," 89 | +"channel VARCHAR(32) NULL," 90 | +"time BIGINT UNSIGNED NOT NULL," 91 | +"user VARCHAR(32) NULL," 92 | +"userid INT UNSIGNED NULL," 93 | +"action VARCHAR(32) NULL," 94 | +"name VARCHAR(256) NULL," 95 | +"data TEXT NULL," 96 | +"INDEX adminlog_channel (channel ASC)" 97 | +")"); 98 | 99 | /*CREATE TABLE `logviewer`.`modlogs` ( 100 | `id` BIGINT NOT NULL AUTO_INCREMENT, 101 | `channelid` VARCHAR(32) NOT NULL, 102 | `time` BIGINT NULL, 103 | `user` VARCHAR(32) NULL, 104 | `command` VARCHAR(32) NULL, 105 | `args` VARCHAR(256) NULL, 106 | PRIMARY KEY (`id`), 107 | INDEX `user` (`channelid` ASC, `user` ASC, `time` ASC), 108 | INDEX `channel` (`channelid` ASC, `time` ASC)); 109 | */ 110 | /* create the modlog table if it doesnt exist */ 111 | connection.query("CREATE TABLE IF NOT EXISTS modlog (" 112 | +"id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY," 113 | +"channelid INT(10) UNSIGNED NULL," 114 | +"time BIGINT UNSIGNED NOT NULL," 115 | +"user VARCHAR(32) NULL," 116 | +"userid INT UNSIGNED NULL," 117 | +"command VARCHAR(32) NULL," 118 | +"args TEXT NULL," 119 | +"INDEX modlog_channel (channel ASC, id ASC)," 120 | +"INDEX modlog_user (channel ASC, id ASC, userid ASC)," 121 | +"INDEX modlog_command (channel ASC, id ASC, command ASC)," 122 | +")"); 123 | 124 | 125 | }); 126 | 127 | self.ensureTablesExist = function(channelObj) { 128 | self.pool.query("CREATE TABLE IF NOT EXISTS chat_"+channelObj.id+" (" 129 | +"id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY," 130 | +"time BIGINT UNSIGNED NOT NULL," 131 | +"nick VARCHAR(32) NOT NULL," 132 | +"userid INT UNSIGNED NULL," 133 | +"text VARCHAR(2047) COLLATE utf8mb4_unicode_ci NOT NULL," 134 | +"modlog VARCHAR(1024) DEFAULT NULL," 135 | +"INDEX (userid, time)," 136 | +"INDEX (time)," 137 | +"INDEX (modlog(1), id DESC)" 138 | +")"); 139 | self.pool.query("CREATE TABLE IF NOT EXISTS users_"+channelObj.id+" (" 140 | +"id INT UNSIGNED NULL PRIMARY KEY," 141 | +"nick VARCHAR(32) NOT NULL," 142 | +"messages INT UNSIGNED DEFAULT '0'," 143 | +"timeouts INT DEFAULT '0'," 144 | +"bans INT UNSIGNED DEFAULT '0'," 145 | +"level INT DEFAULT '0'," 146 | +"INDEX (id ASC)," 147 | +"INDEX (messages DESC)," 148 | +"INDEX (level DESC)" 149 | +")"); 150 | } 151 | 152 | /** 153 | * Gets the full list of complete channel objects 154 | */ 155 | self.getChannels = function(callback) { 156 | self.pool.query("SELECT * FROM channels WHERE active=1",function(error, results, fields){ 157 | callback(results); 158 | }); 159 | } 160 | 161 | /** 162 | * Gets a display-ready list of channel objects from the database 163 | */ 164 | self.getChannelList = function(callback) { 165 | self.pool.query("SELECT name, id, color, premium > ? AS ispremium FROM channels WHERE active=1",[Math.floor(Date.now()/1000)],function(error, results, fields){ 166 | callback(results); 167 | }); 168 | } 169 | 170 | /** 171 | * Gets a list of aliases. Probably unused. 172 | */ 173 | self.getAliases = function(callback) { 174 | self.pool.query("SELECT name, id, alias FROM aliases",function(error, results, fields){ 175 | callback(results); 176 | }); 177 | } 178 | 179 | /** 180 | * Gets a complete channel object from a partial channel object (name OR id). It follows aliases if only a channelname is specified. 181 | * @param active If true, only active channels are returned. 182 | */ 183 | self.getChannel = function(channelObj, active, callback) { 184 | if(channelObj.id) { 185 | self.pool.query("SELECT * FROM channels WHERE id=? AND active=1",[channelObj.id],function(error, results, fields){ 186 | if(results.length == 0 || (active && results[0].active != 1)) callback(null); 187 | else { 188 | callback(results[0]) 189 | } 190 | }); 191 | } else if(channelObj.name) { 192 | self.pool.query("SELECT * FROM channels WHERE name=?",[channelObj.name],function(error, results, fields){ 193 | if(results.length == 0) { 194 | self.pool.query("SELECT id FROM aliases WHERE alias=?",[channelObj.name],function(error, results, fields){ 195 | if(results.length == 0) { 196 | callback(null); 197 | } else { 198 | self.getChannel(results[0], active, callback); 199 | } 200 | }); 201 | } 202 | else { 203 | if(active && results[0].active != 1) callback(null); 204 | else callback(results[0]); 205 | } 206 | }); 207 | } else { 208 | callback(null); 209 | } 210 | } 211 | 212 | /** 213 | * Add a partial channel object (name+id) to the database 214 | */ 215 | self.addChannel = function(channelObj, callback) { 216 | self.ensureTablesExist(channelObj); 217 | self.pool.query("INSERT INTO channels (name, id) VALUES (?,?)",[channelObj.name, channelObj.id],function(error, result){ 218 | if(error) { 219 | winston.error("Couldnt add channel! "+error); 220 | } else { 221 | self.pool.query("SELECT * FROM channels WHERE name=?",[channelObj.name], function(error, results, fields){ 222 | if(error || results.length == 0) { 223 | winston.error("Channel wasnt added properly! "+(error || "No results returned...")); 224 | } else { 225 | callback(results[0]); 226 | } 227 | }); 228 | } 229 | }); 230 | } 231 | 232 | /** 233 | * Insert a line of chat into the database 234 | */ 235 | self.addLine = function(channelObj, nick, userid, time, message, modlog, callback) { 236 | self.pool.query("INSERT INTO ?? (time,nick,userid,text,modlog) VALUES (?,?,?,?)",["chat_"+channelObj.id, Math.floor(time/1000), nick, userid, message, modlog?JSON.stringify(modlog):null], function(error, result) { 237 | if(error) { 238 | winston.error("addModLog: Could not insert! "+error); 239 | return; 240 | } 241 | if(callback) callback(result.insertId); 242 | }); 243 | } 244 | 245 | /** 246 | * Get filtered modlogs 247 | */ 248 | self.getModLogs = function(channelObj, filters, id, limit, callback) { 249 | if(filters.include.length > 0) { 250 | self.pool.query("SELECT * FROM modlog WHERE channelid=? AND command IN (?) AND user IN (?) AND id < ? LIMIT ? ORDER BY id DESC", [channelObj.id, filters.commands, filters.include, id, limit], function(error, results) { 251 | if(error) { 252 | winston.error("getModLogs: Error retrieving mod logs! "+error); 253 | return; 254 | } 255 | callback(results); 256 | }); 257 | } else { 258 | self.pool.query("SELECT * FROM modlog WHERE channelid=? AND command IN (?) AND user NOT IN (?) LIMIT ? OFFSET ?", [channelObj.id, allowed_commands, limit, filters.exclude, offset], function(error, results) { 259 | if(error) { 260 | winston.error("getModLogs: Error retrieving mod logs! "+error); 261 | return; 262 | } 263 | callback(results); 264 | }); 265 | } 266 | } 267 | 268 | self.updateStats = function(channelObj, nick, userid, values) { 269 | var changes = ""; 270 | var params = {nick: nick, id: userid}; 271 | if(values.timeouts) { 272 | changes += ", timeouts = timeouts + "+parseInt(values.timeouts); 273 | params.timeouts = values.timeouts; 274 | } 275 | if(values.bans) { 276 | changes += ", bans = bans + "+parseInt(values.bans); 277 | params.bans = values.bans; 278 | } 279 | if(values.messages) { 280 | changes += ", messages = messages + "+parseInt(values.messages); 281 | params.messages = values.messages; 282 | } 283 | self.pool.query("INSERT INTO ?? SET ? ON DUPLICATE KEY UPDATE nick=?"+changes,["users_"+channelObj.id, params, nick], function(error, result) { 284 | if(error) winston.error(error); 285 | }); 286 | }; 287 | 288 | self.updateTimeout = function(channelObj, userid, id, time, message, modlog) { 289 | // we use the pool for this instead of the pool 290 | self.pool.query("UPDATE ?? SET time=?, text=?, modlog=? WHERE userid=? AND id=?",["chat_"+channelObj.id, Math.floor(time/1000), message, JSON.stringify(modlog), userid, id]); 291 | } 292 | 293 | function parseModLogs(list){ 294 | for(var i=0;i 0) { 320 | if(userid) { 321 | self.pool.query("SELECT id,time,nick,userid,text"+(modlogs?",modlog":"")+" FROM ?? WHERE userid=? AND id < ? ORDER BY id DESC LIMIT ?", ["chat_"+channelObj.id, userid, id, before], function(error, results, fields) { 322 | if(results) beforeRes = results.reverse(); 323 | else beforeRes = []; 324 | parseModLogs(beforeRes); 325 | if(afterRes !== null) callback(beforeRes, afterRes); 326 | }); 327 | } else { 328 | // we exclude twitchnotify when not checking a specific user 329 | self.pool.query("SELECT id,time,nick,userid,text"+(modlogs?",modlog":"")+" FROM ?? WHERE id < ? ORDER BY id DESC LIMIT ?", ["chat_"+channelObj.id, id, before], function(error, results, fields) { 330 | if(results) beforeRes = results.reverse(); 331 | else beforeRes = []; 332 | parseModLogs(beforeRes); 333 | if(afterRes !== null) callback(beforeRes, afterRes); 334 | }); 335 | } 336 | } else { beforeRes = []; } 337 | // after 338 | if(after > 0) { 339 | if(userid) { 340 | self.pool.query("SELECT id,time,nick,userid,text"+(modlogs?",modlog":"")+" FROM ?? WHERE userid=? AND id > ? ORDER BY id ASC LIMIT ?", ["chat_"+channelObj.id, userid, id, after], function(error, results, fields) { 341 | if(results) afterRes = results; 342 | else afterRes = []; 343 | parseModLogs(afterRes); 344 | if(beforeRes !== null) callback(beforeRes, afterRes); 345 | }); 346 | } else { 347 | // we exclude twitchnotify when not checking a specific user 348 | self.pool.query("SELECT id,time,nick,userid,text"+(modlogs?",modlog":"")+" FROM ?? WHERE id > ? ORDER BY id ASC LIMIT ?", ["chat_"+channelObj.id, id, after], function(error, results, fields) { 349 | if(results) afterRes = results; 350 | else afterRes = []; 351 | parseModLogs(afterRes); 352 | if(beforeRes !== null) callback(beforeRes, afterRes); 353 | }); 354 | } 355 | } else { 356 | afterRes = []; 357 | if(beforeRes !== null) callback(beforeRes, afterRes); 358 | } 359 | } 360 | 361 | self.getLogsByTime = function(channelObj, time, before, after, modlogs, callback) { 362 | var beforeRes = null; 363 | var afterRes = null; 364 | // before 365 | if(before > 0) { 366 | self.pool.query("SELECT id,time,nick,userid,text"+(modlogs?",modlog":"")+" FROM ?? WHERE time < ? ORDER BY time DESC LIMIT ?", ["chat_"+channelObj.id, time, before], function(error, results, fields) { 367 | if(results) beforeRes = results.reverse(); 368 | else beforeRes = []; 369 | parseModLogs(beforeRes); 370 | if(afterRes !== null) callback(beforeRes, afterRes); 371 | }); 372 | } else { beforeRes = []; } 373 | // after 374 | if(after > 0) { 375 | // we exclude twitchnotify when not checking a specific user 376 | self.pool.query("SELECT id,time,nick,userid,text"+(modlogs?",modlog":"")+" FROM ?? WHERE time >= ? ORDER BY time ASC LIMIT ?", ["chat_"+channelObj.id, time, after], function(error, results, fields) { 377 | if(results) afterRes = results; 378 | else afterRes = []; 379 | parseModLogs(afterRes); 380 | if(beforeRes !== null) callback(beforeRes, afterRes); 381 | }); 382 | } else { 383 | afterRes = []; 384 | if(beforeRes !== null) callback(beforeRes, afterRes); 385 | } 386 | } 387 | 388 | self.getUserStats = function(channelObj, userid, ranking, callback) { 389 | self.pool.query("SELECT nick, id, messages, timeouts, bans FROM ?? WHERE id = ?", ["users_"+channelObj.id, userid], function(error, results, fields) { 390 | var stats = results[0] || {nick: null, id: userid, timeouts:0, messages: 0, bans: 0}; 391 | if(ranking) { 392 | console.log(stats); 393 | self.pool.query("SELECT COUNT(*)+1 as rank FROM ?? WHERE messages > ?", ["users_"+channelObj.id, stats.messages], function(error, results, fields) { 394 | stats.rank = results[0].rank; 395 | callback(stats); 396 | }); 397 | } 398 | else callback(stats); 399 | }); 400 | } 401 | 402 | self.getAuthUser = function(token, callback) { 403 | self.pool.query("SELECT userid, name FROM auth WHERE token=? AND expires > ?",[token,Math.floor(Date.now()/1000)], function(error, results, fields) { 404 | if(results && results.length>0) callback(results[0]); 405 | else callback(null); 406 | }); 407 | } 408 | 409 | self.getUserLevel = function(channelObj, userid, callback) { 410 | self.pool.query("SELECT level FROM ?? WHERE id = ?", ["users_"+channelObj.id, userid], function(error, results, fields) { 411 | if(results && results.length>0) callback(results[0].level || 0); 412 | else callback(0); 413 | }); 414 | } 415 | 416 | self.setLevel = function(channelObj, userid, nick, level) { 417 | self.pool.query("INSERT INTO ?? (id,nick,level) VALUES (?,?) ON DUPLICATE KEY UPDATE nick = ?, level = ?",["users_"+channelObj.id, userid, nick, level, nick, level]); 418 | } 419 | 420 | self.getLevels = function(channelObj, callback) { 421 | self.pool.query("SELECT id,nick,level FROM ?? WHERE level != 0", ["users_"+channelObj.id], function(error, results, fields) { 422 | callback(results); 423 | }); 424 | } 425 | 426 | self.storeToken = function(userid, name, token, expires) { 427 | self.pool.query("INSERT INTO auth (userid, name, token, expires) VALUES (?,?,?,?)",[userid,name,token,expires]); 428 | } 429 | 430 | self.deleteToken = function(token) { 431 | self.pool.query("DELETE FROM auth WHERE token=?",[token]); 432 | } 433 | 434 | self.checkAndRefreshToken = function(userid, token, expires, callback) { 435 | self.pool.query("UPDATE auth SET expires=? WHERE userid=? AND token=? AND expires > ?",[expires,userid,token,Math.floor(Date.now()/1000)], function(error, result) { 436 | if(callback) callback(result.affectedRows > 0); 437 | }); 438 | } 439 | 440 | self.setSetting = function(channelObj, key, val) { 441 | self.pool.query("UPDATE channels SET ??=? WHERE id=?",[key,val,channelObj.id]); 442 | } 443 | 444 | self.getComments = function(channelObj,topic,callback) { 445 | self.pool.query("SELECT * FROM comments WHERE channelid=? AND topic=?",[channelObj.id,topic],function(error,results,fields) { 446 | callback(results); 447 | }); 448 | } 449 | 450 | self.getComment = function(channelObj,id,callback) { 451 | self.pool.query("SELECT * FROM comments WHERE id=? AND channelid=?",[id,channelObj.id],function(error,results,fields) { 452 | callback(results[0]); 453 | }); 454 | } 455 | 456 | self.addComment = function(channelObj, author, topic, text, callback) { 457 | var d = Math.floor(Date.now()/1000); 458 | self.pool.query("INSERT INTO comments(added,edited,channelid,author,topic,text) VALUES (?,?,?,?,?,?)", [d,d,channelObj.id,author,topic,text], function(error, result) { 459 | if(callback) callback(result.insertId); 460 | }); 461 | } 462 | 463 | self.updateComment = function(channelObj,id,newtext) { 464 | self.pool.query("UPDATE comments SET text=?, edited=? WHERE id=? AND channelid=?",[newtext,Math.floor(Date.now()/1000),id,channelObj.id]); 465 | } 466 | 467 | self.deleteComment = function(channelObj,id) { 468 | self.pool.query("DELETE FROM comments WHERE id=? AND channelid=?",[id,channelObj.id]); 469 | } 470 | 471 | self.findUser = function(channelObj, query, callback) { 472 | var searchString = query.replace("_","\\_").replace("*","%")+"%"; 473 | searchString = searchString.replace(/%{2,}/g,"%"); 474 | self.pool.query("SELECT nick, id FROM ?? WHERE nick LIKE ? LIMIT 11",["users_"+channelObj.id, searchString], function(error,results,fields) { 475 | callback(results); 476 | }); 477 | } 478 | 479 | /* "CREATE TABLE IF NOT EXISTS adminlog (" 480 | +"time BIGINT UNSIGNED NOT NULL," 481 | +"channel VARCHAR(32) NULL," 482 | +"user VARCHAR(32) NULL," 483 | +"action VARCHAR(32) NULL," -> setting/level/(dis)connect/(add/edit/remove) comment 484 | +"key VARCHAR(32) NULL," -> setting/user/connection name/comment id 485 | +"data VARCHAR(256) NULL" -> new value/level/key/comment text 486 | +")" */ 487 | self.adminLog = function(channelObj, user, action, key, data) { 488 | var d = Math.floor(Date.now()/1000); 489 | self.pool.query("INSERT INTO adminlog(time,channelid,user,action,name,data) VALUES (?,?,?,?,?,?)", [d,channelObj.id,user,action,key,data]); 490 | } 491 | 492 | self.getEvents = function(channelObj, limit, callback) { 493 | self.pool.query("SELECT * FROM (SELECT * FROM adminlog WHERE channelid=? ORDER BY id DESC LIMIT ?) sub ORDER BY id ASC",[channelObj.id,limit], function(error,results,fields) { 494 | if(error) { 495 | winston.error("getEvents: Select failed! "+error); 496 | callback([]); 497 | } 498 | else callback(results); 499 | }); 500 | } 501 | 502 | self.getLeaderboard = function(channelObj, offset, limit, callback) { 503 | self.pool.query("SELECT nick, id, messages, timeouts, bans FROM ?? ORDER BY messages DESC LIMIT ? OFFSET ?",["users_"+channelObj.id,limit,offset], function(error,results,fields) { 504 | if(error) { 505 | winston.error("getLeaderboard: Select failed! "+error); 506 | callback([]); 507 | } 508 | else callback(results); 509 | }); 510 | } 511 | 512 | // connections 513 | /* 514 | id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, 515 | channel VARCHAR(32) NULL, 516 | active tinyint(4) unsigned NOT NULL DEFAULT '1', 517 | type VARCHAR(32) NOT NULL PRIMARY KEY, // name of the app (for example "Slack") 518 | data VARCHAR(256) NULL, // identifier of the application (used to identify the location the request came from) 519 | description TEXT NULL // Full-text description 520 | 521 | self.getIntegrations = function(channel, callback) { 522 | self.pool.query("SELECT * FROM connections WHERE channel=?",[channel], function(error,results,fields) { 523 | callback(results); 524 | }); 525 | } 526 | 527 | self.getIntegration = function(channel, id, callback) { 528 | self.pool.query("SELECT * FROM connections WHERE channel=? AND id=?",["users_"+channelObj.id, searchString], function(error,results,fields) { 529 | callback(results[0]); 530 | }); 531 | } 532 | 533 | self.addConnection = function(channel, active, type, data, description, callback) { 534 | self.pool.query("INSERT INTO connections(channel, active, type, data, description) VALUES (?,?,?,?,?)",[channel, active, type, data, description], function(error, result) { 535 | if(error) { 536 | winston.error("addLine: Could not insert! "+error); 537 | return; 538 | } 539 | if(callback) callback(result.insertId); 540 | }); 541 | } 542 | 543 | self.updateConnection = function(channel, id, active, type, data, description, callback) { 544 | self.pool.query("UPDATE connections SET active=?, type=?, data=?, description=? WHERE id=? AND channel=?",[active, type, data, description, id, channel], function(error,results,fields) { 545 | if(callback) callback(results); 546 | }); 547 | } 548 | 549 | self.removeConnection = function(channel, id, callback, callback) { 550 | self.pool.query("DELETE FROM connections WHERE channel=? AND id=?",[channel, id], function(error,results,fields) { 551 | if(callback) callback(results); 552 | }); 553 | } 554 | */ 555 | // error handling 556 | self.pool.on('error', function(err) { 557 | winston.error(err); 558 | }); 559 | } 560 | 561 | -------------------------------------------------------------------------------- /bot.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var events = require('events'); 3 | var winston = require('winston'); 4 | 5 | var ircbot = require('./ircbot'); 6 | var pubsub = require('./pubsub'); 7 | var messagecompressor = require('./messagecompressor'); 8 | var TAGS = 1 9 | var PREFIX = 2 10 | var COMMAND = 3 11 | var PARAM = 4 12 | var TRAILING = 5 13 | var TWITCHNOTIFYID = 46325627; 14 | var JTVID = 14027; 15 | function logviewerBot(settings, db, io) { 16 | var self = this; 17 | self.settings = settings; 18 | self.nick = settings.bot.nick; 19 | self.API = null; 20 | self.db = db; 21 | self.io = io; 22 | 23 | self.pubsub = new pubsub(settings, db, io); 24 | 25 | var messagecompressor = require('./messagecompressor'); 26 | 27 | var host = "irc.chat.twitch.tv"; 28 | var port = 6667; 29 | var hostandport = /([^:^\/]+)(?:[:/](\d+))?/.exec(settings.bot.server); 30 | if(hostandport) { 31 | if(hostandport[1]) { 32 | host = hostandport[1]; 33 | } 34 | if(hostandport[2]) { 35 | port = parseInt(hostandport[2]); 36 | } 37 | } 38 | self.bot = new ircbot(host, port); 39 | self.userlevels = {}; // temporary user levels (mod etc) 40 | self.channels = []; 41 | self.id2channelObj = {}; 42 | self.name2channelObj = {}; 43 | 44 | 45 | self.bot.on("connect", function(){ 46 | self.bot.send("CAP REQ :twitch.tv/tags twitch.tv/commands"); 47 | var oauth = settings.bot.oauth; 48 | if(!oauth.startsWith("oauth:")) oauth = "oauth:"+oauth; 49 | self.bot.send("PASS "+oauth); 50 | self.bot.send("NICK "+settings.bot.nick); 51 | db.getChannels(function(channels){ 52 | for(var i=0;i " + text); 76 | 77 | // if the user is a mod, set his level to 5 78 | if(data[TAGS] && data[TAGS]["mod"] === "1" && self.userlevels[channel]) { 79 | self.userlevels[channel][user] = 5; 80 | } 81 | 82 | // remove the user from the recent timeouts (unless they were VERY recent (<2s ago) 83 | var oldtimeout = (self.timeouts[channel] && self.timeouts[channel][user]) || (self.oldtimeouts[channel] && self.oldtimeouts[channel][user]); 84 | if(oldtimeout) { 85 | var age = Date.now()/1000 - oldtimeout.time; 86 | if(age >= 2) { 87 | if(self.timeouts[channel] && self.timeouts[channel][user]) self.timeouts[channel][user] = undefined; 88 | if(self.oldtimeouts[channel] && self.oldtimeouts[channel][user]) self.oldtimeouts[channel][user] = undefined; 89 | } 90 | } 91 | 92 | if(self.timeouts[channel] && self.timeouts[channel][user]) { 93 | var now = 94 | self.timeouts[channel][user] = undefined; 95 | } 96 | if(self.oldtimeouts[channel] && self.oldtimeouts[channel][user]) self.oldtimeouts[channel][user] = undefined; 97 | 98 | 99 | var time = Math.floor(Date.now()/1000); 100 | 101 | setTimeout(()=>{ 102 | var modlog; 103 | if(data[TAGS] && data[TAGS]["id"]) { 104 | var allowedMessage = allowedMessages[data[TAGS]["id"]] || oldAllowedMessages[data[TAGS]["id"]]; 105 | if(allowedMessage) { 106 | modlog = allowedMessage.modlog; 107 | } 108 | } 109 | db.addLine(channel, user, data[TAGS]["user-id"], Date.now(), messagecompressor.compressMessage(user, data), modlog, function(id) { 110 | var emittedMsg = {id: id, time: time, nick: user, text: data[0]}; 111 | io.to("logs-"+channel+"-"+user).emit("log-add", emittedMsg); 112 | emittedMsg.modlog = modlog; 113 | io.to("logs-"+channel+"-"+user+"-modlogs").emit("log-add", emittedMsg); 114 | }); 115 | db.updateStats(channel, user, {messages: 1}); 116 | }, 10); 117 | }); 118 | 119 | self.bot.on("USERNOTICE", function(data){ 120 | if(data[TAGS] && data[TAGS]["msg-id"]=="resub" || data[TAGS]["msg-id"]=="sub") { 121 | var time = Math.floor(Date.now()/1000); 122 | var channel = data[PARAM].slice(1); 123 | var text = data[TAGS]["system-msg"].replace(/\\s/g," "); 124 | if(data[TRAILING]) text += " Message: "+data[TRAILING]; 125 | var sub = data[TAGS]["login"]; 126 | db.addLine(channel, "twitchnotify", TWITCHNOTIFYID, Date.now(), "dtwitchnotify "+text, null, function(id) { 127 | var irccmd = `@display-name=twitchnotify;color=;subscriber=0;turbo=0;user-type=;emotes=;mod=0 :${sub}!${sub}@${sub}.tmi.twitch.tv PRIVMSG #${channel} :${text}`; 128 | io.to("logs-"+channel+"-twitchnotify").emit("log-add", {id: id, time: time, nick: "twitchnotify", text: irccmd}); 129 | io.to("logs-"+channel+"-twitchnotify-modlogs").emit("log-add", {id: id, time: time, nick: "twitchnotify", text: irccmd}); 130 | }); 131 | db.updateStats(channel, "twitchnotify", {messages: 1}); 132 | db.addLine(channel, sub, data[TAGS]["user-id"], Date.now(), "dtwitchnotify "+text, null, function(id) { 133 | var irccmd = `@display-name=twitchnotify;color=;subscriber=0;turbo=0;user-type=;emotes=;mod=0 :${sub}!${sub}@${sub}.tmi.twitch.tv PRIVMSG #${channel} :${text}`; 134 | io.to("logs-"+channel+"-"+sub).emit("log-add", {id: id, time: time, nick: sub, text: irccmd}); 135 | io.to("logs-"+channel+"-"+sub+"-modlogs").emit("log-add", {id: id, time: time, nick: sub, text: irccmd}); 136 | }); 137 | } 138 | }); 139 | 140 | // Everything having to do with timeouts/bans 141 | var ROTATECYCLE = 30000; 142 | var MAXDIFF = 5000; 143 | 144 | self.timeouts = {}; 145 | self.oldtimeouts = {}; 146 | 147 | function rotateTimeouts(){ 148 | self.oldtimeouts = self.timeouts; 149 | self.timeouts = {}; 150 | } 151 | setInterval(rotateTimeouts, ROTATECYCLE); 152 | 153 | var formatTimespan = function(timespan) { 154 | var age = Math.round(timespan); 155 | var periods = [ 156 | {abbr:"y", len: 3600*24*365}, 157 | {abbr:"m", len: 3600*24*30}, 158 | {abbr:"d", len: 3600*24}, 159 | {abbr:" hrs", len: 3600}, 160 | {abbr:" min", len: 60}, 161 | {abbr:" sec", len: 1}, 162 | ]; 163 | var res = ""; 164 | var count = 0; 165 | for(var i=0;i= periods[i].len) { 167 | var pval = Math.floor(age / periods[i].len); 168 | age = age % periods[i].len; 169 | res += (res?" ":"")+pval+periods[i].abbr; 170 | count ++; 171 | if(count >= 2) break; 172 | } 173 | } 174 | return res; 175 | } 176 | 177 | function formatCount(i) { 178 | return i<=1?"":" ("+i+" times)"; 179 | } 180 | 181 | function formatTimeout(user, timeout) { 182 | if(isFinite(timeout.duration)){ 183 | // timeout 184 | if(timeout.reasons.length==0) 185 | return "<"+user+" has been timed out for "+formatTimespan(timeout.duration)+formatCount(timeout.count)+">" 186 | else if(timeout.reasons.length==1) 187 | return "<"+user+" has been timed out for "+formatTimespan(timeout.duration)+". Reason: "+timeout.reasons.join(", ")+formatCount(timeout.count)+">" 188 | else 189 | return "<"+user+" has been timed out for "+formatTimespan(timeout.duration)+". Reasons: "+timeout.reasons.join(", ")+formatCount(timeout.count)+">" 190 | } else { 191 | // banned 192 | if(timeout.reasons.length==0) 193 | return "<"+user+" has been banned>" 194 | else if(timeout.reasons.length==1) 195 | return "<"+user+" has been banned. Reason: "+timeout.reasons.join(", ")+">" 196 | else 197 | return "<"+user+" has been banned. Reasons: "+timeout.reasons.join(", ")+">" 198 | } 199 | } 200 | 201 | function emitTimeout(type, channelObj, user, timeout) { 202 | var irccmd = `@display-name=jtv;color=;subscriber=0;turbo=0;user-type=;emotes=;badges= :${user}!${user}@${user}.tmi.twitch.tv PRIVMSG #${channelObj.name} :${timeout.text}` 203 | var time = Math.floor(timeout.time.getTime()/1000); 204 | io.to("logs-"+channelObj.id+"-"+user).emit(type, {id: timeout.id, time: time, nick: user, text: irccmd}); 205 | io.to("logs-"+channelObj.id+"-"+user+"-modlogs").emit(type, {id: timeout.id, time: time, nick: user, modlog: timeout.modlog, text: irccmd}); 206 | } 207 | 208 | function doTimeout(channelObj, mod, modid, user, userid, duration, reason, inc) { 209 | // search for the user in the recent timeouts 210 | var oldtimeout = (self.timeouts[channelObj.id] && self.timeouts[channelObj.id][userid]) || (self.oldtimeouts[channelObj.id] && self.oldtimeouts[channelObj.id][user]); 211 | var now = new Date(); 212 | if(self.timeouts[channelObj.id] === undefined) self.timeouts[channelObj.id] = {}; 213 | duration = parseInt(duration) || Infinity; 214 | 215 | if(oldtimeout) { 216 | // if a reason is specified and its new, we add it 217 | if(reason && oldtimeout.reasons.indexOf(reason)<0) { 218 | oldtimeout.reasons.push(reason); 219 | } 220 | 221 | if(mod) oldtimeout.modlog[mod] = duration; 222 | if(isFinite(oldtimeout.duration) && !isFinite(duration)) { 223 | // a user that was timed out got banned now. 224 | db.updateStats(channelObj, user, {timeouts: -1, bans: 1}); 225 | } 226 | 227 | 228 | var oldends = oldtimeout.time.getTime()+oldtimeout.duration*1000; 229 | var newends = now.getTime()+duration*1000; 230 | // only completely update significant changes in the end of the timeout 231 | if(Math.abs(oldends-newends) > MAXDIFF) { 232 | oldtimeout.time = now; 233 | oldtimeout.duration = duration; 234 | } 235 | 236 | oldtimeout.count += inc; 237 | oldtimeout.text = formatTimeout(user, oldtimeout); 238 | // put it into the primary rotation again 239 | self.timeouts[channelObj.id][user] = oldtimeout; 240 | 241 | // update the database 242 | if(oldtimeout.id) { 243 | db.updateTimeout(channelObj, user, oldtimeout.id, now.getTime(), "djtv "+oldtimeout.text, oldtimeout.modlog); 244 | // emit timeout via websockets 245 | emitTimeout("log-update", channelObj, user, oldtimeout); 246 | } 247 | else oldtimeout.dirty = true; 248 | 249 | } else { 250 | var modlog = {}; 251 | if(mod) modlog[mod] = duration; 252 | var timeout = {time: now, duration: duration, reasons: reason?[reason]:[], count: inc, modlog: modlog}; 253 | 254 | timeout.text = formatTimeout(user, timeout); 255 | // add the timeout to the cache with an empty id 256 | self.timeouts[channelObj.id][user] = timeout; 257 | db.addTimeout(channelObj, user, now.getTime(), "djtv "+timeout.text, modlog, function(id){ 258 | timeout.id = id; 259 | // if the timeout was dirty, update it again... 260 | if(timeout.dirty) { 261 | db.updateTimeout(channelObj, user, id, timeout.time.getTime(), "djtv "+timeout.text, timeout.modlog); 262 | } 263 | // emit timeout via websockets 264 | emitTimeout("log-add", channelObj, user, timeout); 265 | }); 266 | if(isFinite(duration)) db.updateStats(channelObj, user, {timeouts:1}); 267 | else db.updateStats(channelObj, user, {bans:1}); 268 | } 269 | } 270 | 271 | function doUnban(channelObj, mod, modid, type, user, userid) { 272 | var modlog = {}; 273 | modlog[mod] = -1; 274 | var text = `<${user} has been ${type}>`; 275 | db.addLine(channelObj, user, userid, Date.now(), "djtv "+text, modlog, null, function(id){ 276 | var irccmd = `@display-name=jtv;color=;subscriber=0;turbo=0;user-type=;emotes=;badges= :${user}!${user}@${user}.tmi.twitch.tv PRIVMSG #${channelObj.name} :${text}`; 277 | io.to("logs-"+channelObj.id+"-"+userid).emit("log-add", {id: id, time: Math.floor(Date.now()/1000), nick: user, text: irccmd}); 278 | io.to("logs-"+channelObj.id+"-"+userid+"-modlogs").emit("log-add", {id: id, time: Math.floor(Date.now()/1000), nick: user, modlog: modlog, text: irccmd}); 279 | }); 280 | } 281 | 282 | function emitRejectedMessage(type, channelObj, command) { 283 | var user = command.args[0]; 284 | var message = command.args[1]; 285 | var irccmd = `@display-name=twitchbot;color=;subscriber=0;turbo=0;user-type=;emotes=;badges= :${user}!${user}@${user}.tmi.twitch.tv PRIVMSG #${channelObj.name} :${message}` 286 | 287 | var emittedMsg = {id: command.id, time: Math.floor(command.time/1000), nick: user, text: irccmd}; 288 | io.to("logs-"+channelObj.id+"-"+user).emit(type, emittedMsg); 289 | emittedMsg.modlog = command.modlog; 290 | console.log("Emitting "+JSON.stringify(emittedMsg)); 291 | console.log("to logs-"+channelObj.id+"-"+user+"-modlogs") 292 | io.to("logs-"+channelObj.id+"-"+user+"-modlogs").emit(type, emittedMsg); 293 | } 294 | 295 | var rejectedMessages = {}; 296 | var oldRejectedMessages = {}; 297 | var allowedMessages = {}; 298 | var oldAllowedMessages = {}; 299 | function doReject(channelObj, user, command) { 300 | command.time = Date.now(); 301 | command.modlog = {}; 302 | command.modlog[user] = "reject"; 303 | db.addTimeout(channelObj, command.args[0], command.time, "dtwitchbot " + command.args[1], command.modlog, function(id){ 304 | command.id = id; 305 | emitRejectedMessage("log-add", channelObj, command); 306 | if(command.dirty) { 307 | db.updateTimeout(channelObj, command.args[0], command.id, command.time, "dtwitchbot " + command.args[1] , command.modlog); 308 | } 309 | }); 310 | rejectedMessages[command.msg_id] = command; 311 | } 312 | 313 | function doModerate(channelObj, user, command) { 314 | var action = command.moderation_action.split("_")[0]; 315 | var oldModlog = rejectedMessages[command.msg_id] || oldRejectedMessages[command.msg_id]; 316 | if(oldModlog) { 317 | oldModlog.modlog[user] = action; 318 | if(oldModlog.id) { 319 | db.updateTimeout(channelObj, oldModlog.args[0], oldModlog.id, oldModlog.time, "dtwitchbot " + oldModlog.args[1], oldModlog.modlog); 320 | emitRejectedMessage("log-update", channelObj, oldModlog); 321 | } else { 322 | oldModlog.dirty = true; 323 | } 324 | } else { 325 | command.time = Date.now(); 326 | command.modlog = {}; 327 | command.modlog[user] = action; 328 | allowedMessages[command.msg_id] = command; 329 | } 330 | } 331 | 332 | setInterval(function() { 333 | oldRejectedMessages = rejectedMessages; 334 | oldAllowedMessages = allowedMessages; 335 | rejectedMessages = {}; 336 | allowedMessages = {}; 337 | }, 300000); 338 | 339 | 340 | self.bot.on("CLEARCHAT", function(data){ 341 | let user = data[TRAILING]; 342 | let userid = data[TAGS]["target-user-id"]; 343 | let channelObj = {name: data[PARAM].slice(1), id: data[TAGS]["room-id"]; 344 | if(user && user.length > 0) { 345 | let duration,reason; 346 | if(data[TAGS]) { 347 | if(data[TAGS]["ban-duration"]) duration = data[TAGS]["ban-duration"]; 348 | if(data[TAGS]["ban-reason"]) reason = data[TAGS]["ban-reason"].replace(/\\s/g," "); 349 | } 350 | doTimeout(channelObj, undefined, user, duration, reason, 1); 351 | } else { 352 | winston.debug("#" + channelObj.name + " "); 353 | db.addTimeout(channelObj, "jtv", Date.now(), "djtv "); 354 | } 355 | }); 356 | 357 | var lastSave = Date.now(); 358 | self.bot.on("NOTICE", function(data){ 359 | //:tmi.twitch.tv NOTICE #ox33 :The moderators of this room are: 0x33, andyroid, anoetictv 360 | //@msg-id=msg_banned :tmi.twitch.tv NOTICE #frankerzbenni :You are permanently banned from talking in frankerzbenni. 361 | let channel = data[PARAM].slice(1); 362 | if(data[TAGS] && (data[TAGS]["msg-id"] === "room_mods" || data[TAGS]["msg-id"] === "no_mods")) { 363 | let users = []; 364 | if(data[TAGS]["msg-id"] === "room_mods") { 365 | let m = /The moderators of this \w+ are: (.*)/.exec(data[TRAILING]); 366 | users = m[1].match(/\w+/g); 367 | } 368 | 369 | 370 | // check if the moderation status of the bot has changed 371 | var ismodded = users.indexOf(self.nick) >= 0; 372 | if(self.userlevels[channel] && ismodded * 5 != (self.userlevels[channel][self.nick] || 0)) { 373 | // emit the moderation status changed event via ws 374 | self.io.to("events-"+channel).emit("ismodded", ismodded); 375 | 376 | if(!ismodded) { 377 | let channelObj = self.findChannelObj({name: channel}); 378 | // disable mod log setting 379 | winston.info("Got unmodded in "+channel+" - "+JSON.stringify(channelObj)+" unlistening from mod logs"); 380 | self.disableModLogs(channelObj); 381 | self.db.setSetting(channel, "modlogs", "0"); 382 | self.API.adminLog(channel, "", "system", "modlogs-disabled", "Detected that the bot is no longer modded in your channel. Disabled mod logs."); 383 | } 384 | } 385 | 386 | let userlist = {}; 387 | for(let i=0;i 60*1000) { 397 | // write to file every minute 398 | lastSave = Date.now(); 399 | fs.writeFile("mods.json", JSON.stringify(self.userlevels), "utf-8"); 400 | } 401 | } else if(data[TAGS] && data[TAGS]["msg-id"] === "msg_banned"){ 402 | // we were banned from the channel, leave it. 403 | let channelObj = self.findChannelObj({name: channel}); 404 | self.emit("moderator-list-"+channel, []); // emit an empty mod list in case we were waiting for those); 405 | db.setSetting(channel, "active", "0"); 406 | self.partChannel(channelObj); 407 | if(self.API) { 408 | self.API.adminLog(channel, "", "system", "banned", "Detected that the bot is banned from the channel. Disabled the logviewer."); 409 | } 410 | } else { 411 | db.addLine(channel, "jtv", 0, Date.now(), "djtv "+data[TRAILING], null); 412 | } 413 | }); 414 | var regexes_channel_user = 415 | [ 416 | /^#(\w+)\s+(\w+)$/, 417 | /^(\w+)\s+(\w+)$/, 418 | /^logof (\w+)\s+(\w+)$/, 419 | /^!logs? (\w+)\s+(\w+)$/, 420 | ]; 421 | var regexes_user_channel = 422 | [ 423 | /^(\w+)\s+#(\w+)$/, 424 | /^(\w+)\s+(\w+)/, 425 | /^(\w+) in (\w+)$/, 426 | /^logof (\w+)\s+(\w+)$/, 427 | /^!logs? (\w+)\s+(\w+)$/, 428 | ]; 429 | 430 | var getLogs = function(channel, nick, requestedby, callback) { 431 | db.getActiveChannel(channel, function(channelObj) { 432 | if(!channelObj) 433 | { 434 | callback(undefined, nick); 435 | } 436 | if(channelObj.viewlogs > 0) { 437 | db.getUserLevel(channelObj.name, requestedby, function(level){ 438 | if(level >= channelObj.viewlogs) { 439 | db.getLogsByNick(channelObj.name, nick, 2, false, function(messages){ 440 | for(var i=0;i 0) { 471 | self.checkMods(self.channels[currentchannel%(self.channels.length)]); 472 | currentchannel++; 473 | } 474 | } 475 | setInterval(checkNextMods,(settings.bot.modcheckinterval || 2) * 1000); 476 | 477 | self.bot.connect(); 478 | 479 | // react to mod logs, if present 480 | self.pubsub.on("MESSAGE", function(message, flags) { 481 | winston.debug("Handling pubsub message "+JSON.stringify(message)); 482 | let topic = message.data.topic.split("."); 483 | if(topic[0] == "chat_moderator_actions") { 484 | var channelid = topic[2]; 485 | var channelObj = self.findChannelObj({id: channelid}); 486 | var channel = channelObj.name; 487 | var command = JSON.parse(message.data.message).data; 488 | winston.debug(command); 489 | var user = command.created_by; 490 | if(command.moderation_action == "timeout") { 491 | doTimeout(channel, user, command.args[0].toLowerCase(), command.args[1] || 600, command.args[2] || "", 0); 492 | } else if(command.moderation_action == "ban") { 493 | doTimeout(channel, user, command.args[0].toLowerCase(), Infinity, command.args[1] || "", 0); 494 | } else if(command.moderation_action == "unban") { 495 | doUnban(channel, user, "unbanned", command.args[0].toLowerCase()); 496 | } else if(command.moderation_action == "untimeout") { 497 | doUnban(channel, user, "untimed out", command.args[0].toLowerCase()); 498 | } else if(command.moderation_action == "automod_rejected") { 499 | doReject(channel, user, command); 500 | } else if(command.moderation_action == "denied_automod_message" || command.moderation_action == "allowed_automod_message") { 501 | doModerate(channel, user, command); 502 | } else { 503 | var text = "/"+command.moderation_action; 504 | if(command.args) text += " "+command.args.join(" "); 505 | var modlog = {}; 506 | modlog[user] = ""; 507 | db.addLine(channel, "jtv", 0, Date.now(), "djtv "+text, modlog, null, function(id) { 508 | var time = Math.floor(Date.now()/1000); 509 | io.to("logs-"+channel+"-"+user).emit("log-add", {id: id, time: time, nick: "jtv", text: `@display-name=jtv;color=;subscriber=0;turbo=0;user-type=;emotes=;badges= :jtv!jtv@jtv.tmi.twitch.tv PRIVMSG #${channel} :${text}`}); 510 | io.to("logs-"+channel+"-"+user+"-modlogs").emit("log-add", {id: id, time: time, nick: user, modlog: modlog, text: `@display-name=jtv;color=;subscriber=0;turbo=0;user-type=;emotes=;badges= :${user}!${user}@${user}.tmi.twitch.tv PRIVMSG #${channel} :${text}`}); 511 | }); 512 | self.API.adminLog(channel, user, "command", command.moderation_action, text); 513 | } 514 | } 515 | }); 516 | } 517 | 518 | logviewerBot.prototype = new events.EventEmitter; 519 | 520 | logviewerBot.prototype.findChannelObj = function(channel) { 521 | var self = this; 522 | var channelObj = self.id2channelObj[channel.id]; 523 | if(channelObj) return channelObj; 524 | channelObj = self.name2channelObj[channel.name]; 525 | if(channelObj) return channelObj; 526 | if(self.channels.indexOf(channel) >= 0) return channel; 527 | else return null; 528 | } 529 | 530 | logviewerBot.prototype.joinChannel = function(channelObj) { 531 | winston.info("Joining channel "+JSON.stringify(channelObj)); 532 | var self = this; 533 | if(self.findChannelObj(channelObj)) return; 534 | self.channels.push(channelObj); 535 | self.bot.send("JOIN #"+channelObj.name); 536 | self.bot.send("PRIVMSG #"+channelObj.name+" :.mods"); 537 | self.db.ensureTablesExist(channelObj.name); 538 | self.id2channelObj[channelObj.id] = channelObj; 539 | self.name2channelObj[channelObj.name] = channelObj; 540 | } 541 | 542 | logviewerBot.prototype.partChannel = function(channelObj) { 543 | var self = this; 544 | channelObj = self.findChannelObj(channelObj); 545 | let index = self.channels.indexOf(channelObj); 546 | winston.info("Leaving channel "+JSON.stringify(channelObj)); 547 | if(index >= 0) { 548 | self.channels.splice(index,1)[0]; 549 | self.bot.send("PART #"+channelObj.name); 550 | delete self.id2channelObj[channelObj.id]; 551 | delete self.name2channelObj[channelObj.name]; 552 | } else { 553 | self.bot.send("PART #"+channelObj.name); 554 | winston.error("Tried to leave channel "+channelObj.name+" that wasnt joined"); 555 | } 556 | } 557 | 558 | logviewerBot.prototype.checkMods = function(channelObj) { 559 | var self = this; 560 | self.bot.send("PRIVMSG #"+channelObj.name+" :/mods"); 561 | } 562 | 563 | // checks if the logviewer bot is modded in a channel 564 | logviewerBot.prototype.isModded = function(channelObj, callback, force, cacheonly) { 565 | if(!channelObj) { 566 | callback(false); 567 | return; 568 | } 569 | var self = this; 570 | var channel = channelObj.name; 571 | if(self.userlevels[channel] && !force) { 572 | winston.debug("Used cached mod list for channel "+channel+": "+JSON.stringify(self.userlevels[channel])); 573 | callback(self.userlevels[channel][self.nick] == 5); 574 | } else if(!cacheonly) { 575 | winston.debug("Waiting for mod list for channel "+channel); 576 | if(force) self.checkMods(channelObj); 577 | self.once("moderator-list-"+channel, function(list){ 578 | if(list.indexOf(self.nick) >= 0) { 579 | callback(true); 580 | } else { 581 | callback(false); 582 | } 583 | }); 584 | self.checkMods(channelObj); 585 | } else { 586 | callback(false); 587 | } 588 | 589 | } 590 | 591 | logviewerBot.prototype.enableModLogs = function(channelObj, callback) { 592 | var self = this; 593 | 594 | self.isModded(channelObj, function(isModded) { 595 | if(isModded) { 596 | // we gucci, subscribe to pubsub 597 | winston.debug("Enabling mod logs for "+JSON.stringify(channelObj)); 598 | self.pubsub.listenModLogs(channelObj); 599 | channelObj.modlogs = "1"; 600 | if(callback) callback(true); 601 | } else { 602 | winston.debug("Bot is not modded in "+channelObj.name); 603 | if(callback) callback(false); 604 | } 605 | }); 606 | } 607 | 608 | logviewerBot.prototype.disableModLogs = function(channelObj) { 609 | var self = this; 610 | //channelObj = self.findChannelObj(channelObj); 611 | self.pubsub.unlistenModLogs(channelObj); 612 | channelObj.modlogs = "0"; 613 | } 614 | 615 | module.exports = logviewerBot; 616 | --------------------------------------------------------------------------------