├── .gitignore ├── .vscode └── settings.json ├── ERD.png ├── LICENSE ├── README.md ├── client ├── .editorconfig ├── .gitignore ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── src │ ├── API_URL.js │ ├── App.vue │ ├── assets │ │ └── logo.png │ ├── components │ │ ├── Button.vue │ │ ├── CommandForm.vue │ │ ├── HelloWorld.vue │ │ ├── NavBar.vue │ │ └── Toggle.vue │ ├── lib │ │ └── api.js │ ├── main.js │ ├── router │ │ └── index.js │ ├── store │ │ └── index.js │ ├── styles │ │ └── variables.scss │ └── views │ │ ├── About.vue │ │ ├── Dashboard.vue │ │ └── Home.vue └── vue.config.js └── server ├── .env.sample ├── .eslintrc.js ├── package-lock.json ├── package.json └── src ├── api └── channel.js ├── auth └── twitch.js ├── bot ├── command-formatter.js ├── commands │ ├── following.js │ ├── setgame.js │ └── settitle.js └── index.js ├── config.js ├── db ├── bot.js ├── channel.js ├── command.js ├── globalcommand.js ├── index.js ├── seeds │ ├── globalcommands.js │ └── index.js └── user.js ├── index.js ├── lib ├── jwt.js ├── twitch-api.js └── utils.js ├── middlewares └── index.js └── www └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # next.js build output 79 | .next 80 | 81 | # nuxt.js build output 82 | .nuxt 83 | 84 | # gatsby files 85 | .cache/ 86 | public 87 | 88 | # vuepress build output 89 | .vuepress/dist 90 | 91 | # Serverless directories 92 | .serverless/ 93 | 94 | # FuseBox cache 95 | .fusebox/ 96 | 97 | # DynamoDB Local files 98 | .dynamodb/ 99 | 100 | # TernJS port file 101 | .tern-port 102 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true 3 | } -------------------------------------------------------------------------------- /ERD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingGarden/twitch-bot/defadac2aaa14d515056abaf0ec2c4854bcb1e37/ERD.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Coding Garden with CJ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitch Bot 2 | 3 | A general purpose twitch bot that will work with multiple channels. 4 | 5 | ## TODO 6 | 7 | * [x] Model the data 8 | * [x] Setup Backend 9 | * [x] Install tmi.js 10 | * [x] Twitch Oauth 11 | * [x] Add the bot to a channel 12 | * [x] Bot can read and respond to messages in specified channels 13 | 14 | ## Environment Variable Names 15 | 16 | - PORT - HTTP port for the Express server. 17 | - TWITCH_CLIENT_ID - Client ID for the Twitch app 18 | - TWITCH_CLIENT_SECRET - Client OAuth Secret for the Twitch app 19 | - TWITCH_CLIENT_REDIR_HOST - Base host for the OAuth redirect URL. 20 | (`/auth/twitch/callback`) 21 | 22 | http://localhost:8888/auth/twitch?scope=moderation:read 23 | 24 | ## Data Model 25 | 26 | * Work in progress. 27 | * Green entities are completed. 28 | 29 | ![ERD](ERD.png) 30 | 31 | ## 10-20-2019 TODO 32 | 33 | * [x] List all commands for a channel 34 | * [x] Add command to a channel 35 | * [ ] `!so ` 36 | * [x] Architect variables and replyText replacement/format 37 | * [ ] Review TODOs 38 | 39 | 40 | 41 | ## TODO LATER 42 | 43 | * Schema validation with JOI 44 | * Profanity filter settings for a given channel 45 | * use twitch API 46 | 47 | ## Colors 48 | 49 | https://coolors.co/57bc59-d7263d-0072bb-f6ae2d-f26419 -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.19.0", 12 | "core-js": "^3.1.2", 13 | "vue": "^2.6.10", 14 | "vue-router": "^3.0.6", 15 | "vuex": "^3.0.1" 16 | }, 17 | "devDependencies": { 18 | "@vue/cli-plugin-babel": "^4.0.0", 19 | "@vue/cli-plugin-eslint": "^4.0.0", 20 | "@vue/cli-plugin-router": "^4.0.0", 21 | "@vue/cli-plugin-vuex": "^4.0.0", 22 | "@vue/cli-service": "^4.0.0", 23 | "@vue/eslint-config-airbnb": "^4.0.0", 24 | "babel-eslint": "^10.0.1", 25 | "eslint": "^5.16.0", 26 | "eslint-plugin-vue": "^5.0.0", 27 | "sass": "^1.19.0", 28 | "sass-loader": "^8.0.0", 29 | "vue-template-compiler": "^2.6.10" 30 | }, 31 | "eslintConfig": { 32 | "root": true, 33 | "env": { 34 | "node": true 35 | }, 36 | "extends": [ 37 | "plugin:vue/essential", 38 | "@vue/airbnb" 39 | ], 40 | "rules": {}, 41 | "parserOptions": { 42 | "parser": "babel-eslint" 43 | } 44 | }, 45 | "postcss": { 46 | "plugins": { 47 | "autoprefixer": {} 48 | } 49 | }, 50 | "browserslist": [ 51 | "> 1%", 52 | "last 2 versions" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /client/src/API_URL.js: -------------------------------------------------------------------------------- 1 | const API_URL = window.location.hostname === 'localhost' ? 'http://localhost:8888' : 'to-do'; 2 | 3 | export default API_URL; 4 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 17 | 18 | 58 | -------------------------------------------------------------------------------- /client/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingGarden/twitch-bot/defadac2aaa14d515056abaf0ec2c4854bcb1e37/client/src/assets/logo.png -------------------------------------------------------------------------------- /client/src/components/Button.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | 21 | 57 | -------------------------------------------------------------------------------- /client/src/components/CommandForm.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 60 | 61 | 128 | -------------------------------------------------------------------------------- /client/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 43 | 44 | 45 | 61 | -------------------------------------------------------------------------------- /client/src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /client/src/components/Toggle.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 38 | -------------------------------------------------------------------------------- /client/src/lib/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import API_URL from '../API_URL'; 3 | 4 | const apiBase = `${API_URL}/api`; 5 | 6 | // TODO: refresh JWT / re-login user 7 | // TODO: re-direct to login if JWT expired 8 | export function getUser() { 9 | if (!localStorage.token) { 10 | return null; 11 | } 12 | const token = localStorage.token 13 | .replace(/-/g, '+') 14 | .replace(/_/g, '/'); 15 | return JSON.parse(atob(token.split('.')[1])); 16 | } 17 | 18 | function getDefaultHeaders() { 19 | return { 20 | Authorization: `Bearer ${localStorage.token}`, 21 | }; 22 | } 23 | 24 | const api = axios.create({ 25 | baseURL: apiBase, 26 | }); 27 | 28 | export async function getChannel() { 29 | const { twitchId } = getUser(); 30 | const { data } = await api.get(`/channel/${twitchId}`, 31 | { 32 | headers: getDefaultHeaders(), 33 | }); 34 | return data; 35 | } 36 | 37 | export async function updateChannel({ enabled }) { 38 | const { twitchId } = getUser(); 39 | const { data } = await api.patch(`/channel/${twitchId}`, 40 | { 41 | enabled, 42 | }, 43 | { 44 | headers: getDefaultHeaders(), 45 | }); 46 | return data; 47 | } 48 | 49 | export async function listCommands() { 50 | const { twitchId } = getUser(); 51 | const { data } = await api.get(`/channel/${twitchId}/commands`, 52 | { 53 | headers: getDefaultHeaders(), 54 | }); 55 | return data; 56 | } 57 | 58 | export async function addCommand(newCommand) { 59 | const { twitchId } = getUser(); 60 | const { data } = await api.post(`/channel/${twitchId}/commands`, 61 | newCommand, 62 | { 63 | headers: getDefaultHeaders(), 64 | }); 65 | return data; 66 | } 67 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | import store from './store'; 5 | 6 | Vue.config.productionTip = false; 7 | 8 | new Vue({ 9 | router, 10 | store, 11 | render: h => h(App), 12 | }).$mount('#app'); 13 | -------------------------------------------------------------------------------- /client/src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import Home from '../views/Home.vue'; 4 | 5 | Vue.use(VueRouter); 6 | 7 | const routes = [ 8 | { 9 | path: '/', 10 | name: 'home', 11 | component: Home, 12 | }, 13 | { 14 | path: '/dashboard', 15 | name: 'dashboard', 16 | // route level code-splitting 17 | // this generates a separate chunk (about.[hash].js) for this route 18 | // which is lazy-loaded when the route is visited. 19 | component: () => import(/* webpackChunkName: "dashboard" */ '../views/Dashboard.vue'), 20 | }, 21 | ]; 22 | 23 | const router = new VueRouter({ 24 | routes, 25 | }); 26 | 27 | export default router; 28 | -------------------------------------------------------------------------------- /client/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | Vue.use(Vuex); 5 | 6 | export default new Vuex.Store({ 7 | state: { 8 | }, 9 | mutations: { 10 | }, 11 | actions: { 12 | }, 13 | modules: { 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /client/src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $codingGardenGreen: hsl(121, 43%, 54%); 2 | $red: hsl(352, 70%, 50%); 3 | $orange: hsl(21, 89%, 52%); 4 | $yellow: hsl(39, 92%, 57%); 5 | $blue: hsl(203, 100%, 37%); 6 | $black: hsl(0, 0%, 4%); -------------------------------------------------------------------------------- /client/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /client/src/views/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 125 | 126 | 224 | -------------------------------------------------------------------------------- /client/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 56 | 57 | 81 | -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | lintOnSave: false, 3 | }; 4 | -------------------------------------------------------------------------------- /server/.env.sample: -------------------------------------------------------------------------------- 1 | HOST=localhost 2 | PORT=8888 3 | TWITCH_CLIENT_ID= 4 | TWITCH_CLIENT_SECRET= 5 | TWITCH_CLIENT_REDIR_HOST= 6 | # BOT_REFRESH_TOKEN= 7 | MONGO_HOST=localhost 8 | MONGO_USER=prunebot 9 | MONGO_PASS= 10 | MONGO_DBNAME=prunebot 11 | JWT_SECRET= 12 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'commonjs': true, 4 | 'es6': true, 5 | 'node': true 6 | }, 7 | 'extends': 'eslint:recommended', 8 | 'globals': { 9 | 'Atomics': 'readonly', 10 | 'SharedArrayBuffer': 'readonly' 11 | }, 12 | 'parserOptions': { 13 | 'ecmaVersion': 2018 14 | }, 15 | 'rules': { 16 | 'indent': [ 17 | 'error', 18 | 'tab' 19 | ], 20 | 'linebreak-style': [ 21 | 'error', 22 | 'unix' 23 | ], 24 | 'quotes': [ 25 | 'error', 26 | 'single' 27 | ], 28 | 'semi': [ 29 | 'error', 30 | 'always' 31 | ], 32 | 'prefer-const': [ 33 | 'error', 34 | { 35 | 'destructuring': 'any', 36 | 'ignoreReadBeforeAssign': false 37 | } 38 | ] 39 | } 40 | }; -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node src/index.js", 8 | "dev": "nodemon src/index.js", 9 | "seeds": "node src/db/seeds/index.js" 10 | }, 11 | "keywords": [], 12 | "author": "CJ R. (https://w3cj.now.sh)", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@types/tmi.js": "^1.4.0", 16 | "axios": "^0.19.0", 17 | "cors": "^2.8.5", 18 | "countdown": "^2.6.0", 19 | "dotenv": "^8.1.0", 20 | "express": "^4.17.1", 21 | "helmet": "^3.21.1", 22 | "jsonwebtoken": "^8.5.1", 23 | "mongoose": "^5.7.4", 24 | "mustache": "^3.1.0", 25 | "saslprep": "^1.0.3", 26 | "tmi.js": "^1.5.0", 27 | "volleyball": "^1.5.1" 28 | }, 29 | "devDependencies": { 30 | "eslint": "^6.5.1", 31 | "nodemon": "^1.19.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/src/api/channel.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const channelModel = require('../db/channel'); 4 | const commandModel = require('../db/command'); 5 | const { joinChannels, partChannels } = require('../bot'); 6 | 7 | const router = express.Router(); 8 | 9 | const ensureUserAccess = (req, res, next) => { 10 | const { twitchId } = req.params; 11 | // TODO: check manager collection too, instead of just id 12 | if (twitchId !== req.user.twitchId) { 13 | const error = new Error('Not Allowed!'); 14 | return next(error); 15 | } 16 | next(); 17 | }; 18 | 19 | router.get('/:twitchId', ensureUserAccess, async (req, res, next) => { 20 | const { twitchId } = req.params; 21 | try { 22 | const channel = await channelModel.findOne({ twitchId }); 23 | if(!channel) { 24 | return next(); 25 | } 26 | res.json(channel); 27 | } catch (error) { 28 | next(error); 29 | } 30 | }); 31 | 32 | router.patch('/:twitchId', ensureUserAccess, async (req, res, next) => { 33 | const { twitchId } = req.params; 34 | const { enabled } = req.body; 35 | if(enabled === undefined || typeof enabled !== 'boolean') { 36 | return next(new Error('Enabled must be a boolean.')); 37 | } 38 | try { 39 | const channel = await channelModel.findOneAndUpdate( 40 | { twitchId }, 41 | { enabled }, 42 | { new: true } 43 | ); 44 | if (!channel) { 45 | return next(); 46 | } 47 | if (enabled) { 48 | await joinChannels([ twitchId ]); 49 | } else { 50 | await partChannels([ twitchId ]); 51 | } 52 | return res.json(channel); 53 | } catch (error) { 54 | return next(error); 55 | } 56 | }); 57 | 58 | router.get('/:twitchId/commands', ensureUserAccess, async (req, res, next) => { 59 | const { twitchId } = req.params; 60 | try { 61 | const commands = await commandModel.find({ channelId: twitchId }); 62 | res.json(commands); 63 | } catch (error) { 64 | next(error); 65 | } 66 | }); 67 | 68 | // create new command 69 | router.post('/:twitchId/commands', ensureUserAccess, async (req, res, next) => { 70 | const { twitchId } = req.params; 71 | const { name, aliases, replyText, requiredRole } = req.body; 72 | try { 73 | // TODO: sanitize command... 74 | // TODO: myString.replace(/[^\w\s!]/g,''); 75 | // TODO: check if mustaches, if so, validate all variables to be valid 76 | const existingCommand = await commandModel.findOne({ 77 | channelId: twitchId, name 78 | }); 79 | if(existingCommand) { 80 | throw new Error('Command already exists'); 81 | } 82 | const command = await commandModel.create({ 83 | channelId: twitchId, name, aliases, replyText, requiredRole 84 | }); 85 | res.json(command); 86 | } catch (error) { 87 | next(error); 88 | } 89 | }); 90 | 91 | router.patch('/:twitchId/commands/:commandId', ensureUserAccess, async (req, res, next) => { 92 | const { twitchId, commandId } = req.params; 93 | const { name, aliases, replyText, requiredRole } = req.body; 94 | try { 95 | // TODO: sanitize command... 96 | // TODO: myString.replace(/[^\w\s!]/g,''); 97 | // TODO: check if mustaches, if so, validate all variables to be valid 98 | const updated = await commandModel.findOneAndUpdate( 99 | { _id: commandId, channelId: twitchId }, 100 | Object.fromEntries( 101 | Object.entries({ name, aliases, replyText, requiredRole }) 102 | .filter(n => n[1] !== undefined) 103 | ), 104 | { new: true } 105 | ); 106 | if(!updated) { 107 | return next(); 108 | } 109 | res.json(updated); 110 | } catch (error) { 111 | next(error); 112 | } 113 | }); 114 | 115 | router.delete('/:twitchId/commands/:commandId', ensureUserAccess, async (req, res, next) => { 116 | const { twitchId, commandId } = req.params; 117 | try { 118 | const { deletedCount } = await commandModel.deleteOne({ 119 | _id: commandId, channelId: twitchId 120 | }); 121 | console.log({ deletedCount }); 122 | if(!deletedCount) { 123 | return next(); 124 | } 125 | res.sendStatus(204); 126 | } catch (error) { 127 | next(error); 128 | } 129 | }); 130 | 131 | module.exports = router; -------------------------------------------------------------------------------- /server/src/auth/twitch.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const config = require('../config'); 4 | const twitchAPI = require('../lib/twitch-api'); 5 | const userModel = require('../db/user'); 6 | const channelModel = require('../db/channel'); 7 | const jwt = require('../lib/jwt'); 8 | 9 | const redirect_uri = `${config.TWITCH_CLIENT_REDIR_HOST}/auth/twitch/callback`; 10 | 11 | const router = express.Router(); 12 | 13 | router.get('/', (req, res) => { 14 | const qs = new URLSearchParams({ 15 | client_id: config.TWITCH_CLIENT_ID, 16 | redirect_uri, 17 | response_type: 'code', 18 | scope: 'moderation:read' 19 | }); 20 | const redirectUrl = `${twitchAPI.authAPI.defaults.baseURL}/authorize?${qs}`; 21 | res.redirect(redirectUrl); 22 | }); 23 | 24 | router.get('/callback', async (req, res) => { 25 | const { code, /* state */ } = req.query; 26 | const qs = new URLSearchParams({ 27 | client_id: config.TWITCH_CLIENT_ID, 28 | client_secret: config.TWITCH_CLIENT_SECRET, 29 | code, 30 | grant_type: 'authorization_code', 31 | redirect_uri 32 | }); 33 | try { 34 | const { 35 | data: { access_token: token, refresh_token } 36 | } = await twitchAPI.authAPI.post(`/token?${qs}`); 37 | const { id: twitchId } = await twitchAPI.getUser({ token }); 38 | const query = { twitchId }; 39 | /** @type {import('mongoose').QueryFindOneAndUpdateOptions)} */ 40 | const options = { 41 | new: true, 42 | upsert: true 43 | }; 44 | const [ user, channel ] = await Promise.all([ 45 | userModel.findOneAndUpdate( 46 | query, 47 | { twitchId, refresh_token }, 48 | options 49 | ), 50 | channelModel.findOneAndUpdate( 51 | query, 52 | query, 53 | options 54 | ) 55 | ]); 56 | const loginToken = await jwt.sign({ twitchId }); 57 | /** @see http://expressjs.com/en/4x/api.html#res.cookie */ 58 | res.cookie('token', loginToken, { 59 | secure: process.env.NODE_ENV === 'production', 60 | domain: process.env.HOST, 61 | path: '/' 62 | }); 63 | res.redirect('/'); 64 | } catch (error) { 65 | res.json({ 66 | message: error.message, 67 | // body: error.response ? error.response.data : error, 68 | }); 69 | } 70 | }); 71 | 72 | module.exports = router; -------------------------------------------------------------------------------- /server/src/bot/command-formatter.js: -------------------------------------------------------------------------------- 1 | const Mustache = require('mustache'); 2 | const countdown = require('countdown'); 3 | const twitchAPI = require('../lib/twitch-api'); 4 | 5 | const cachedTemplates = {}; 6 | 7 | const resolvers = { 8 | twitch: { 9 | async uptime(context) { 10 | const { channelId } = context; 11 | const stream = await twitchAPI.getStream(channelId); 12 | if (stream) { 13 | return countdown(new Date(stream.started_at)); 14 | } else { 15 | return 'offline'; 16 | } 17 | }, 18 | async viewers(context) { 19 | const { channelId } = context; 20 | const stream = await twitchAPI.getStream(channelId); 21 | if (stream) { 22 | return stream.viewer_count; 23 | } else { 24 | return 'offline'; 25 | } 26 | } 27 | }, 28 | user: { 29 | name() { 30 | // TODO: Get display name 31 | }, 32 | status() { 33 | // TODO: Get stream status 34 | }, 35 | game() { 36 | // TODO: Get stream game/directory 37 | } 38 | } 39 | }; 40 | 41 | function getValue(name, context) { 42 | const [ group, prop ] = name.split('.'); 43 | if(group in resolvers && prop in resolvers[group]) { 44 | try { 45 | return resolvers[group][prop](context); 46 | } catch (error) { 47 | console.error('Error resolving', name, error); 48 | return 'ERROR'; 49 | } 50 | } 51 | } 52 | 53 | // const vm = require('vm'); 54 | // TODO: compile template in sandbox 55 | async function formatCommand(msg, context) { 56 | const variables = {}; 57 | const ast = cachedTemplates[msg] || Mustache.parse(msg); 58 | cachedTemplates[msg] = ast; 59 | await Promise.all( 60 | ast 61 | .map(async ([type, name]) => { 62 | if (type === 'name') { 63 | const [ group, prop ] = name.split('.'); 64 | const value = await getValue(name, context); 65 | if (value !== undefined) { 66 | variables[group] = variables[group] || {}; 67 | variables[group][prop] = value.toString(); 68 | } 69 | } 70 | }) 71 | ); 72 | context.args.forEach((n, i) => variables[i] = n); 73 | variables['*'] = context.args.join(' '); 74 | console.log({ msg, variables }); 75 | return Mustache.render(msg, variables); 76 | } 77 | 78 | module.exports = formatCommand; -------------------------------------------------------------------------------- /server/src/bot/commands/following.js: -------------------------------------------------------------------------------- 1 | module.exports = function(context) {}; 2 | -------------------------------------------------------------------------------- /server/src/bot/commands/setgame.js: -------------------------------------------------------------------------------- 1 | module.exports = function(context) {}; 2 | -------------------------------------------------------------------------------- /server/src/bot/commands/settitle.js: -------------------------------------------------------------------------------- 1 | module.exports = function(context) {}; -------------------------------------------------------------------------------- /server/src/bot/index.js: -------------------------------------------------------------------------------- 1 | const tmi = require('tmi.js'); 2 | 3 | const botModel = require('../db/bot'); 4 | const channelModel = require('../db/channel'); 5 | const globalCommandModel = require('../db/globalcommand'); 6 | const commandModel = require('../db/command'); 7 | const twitchAPI = require('../lib/twitch-api'); 8 | const { sleep } = require('../lib/utils'); 9 | const formatCommand = require('./command-formatter'); 10 | 11 | /** @type {import('tmi.js').Client} */ 12 | let client; 13 | 14 | async function getClient(token) { 15 | if(client) { 16 | return client; 17 | } 18 | 19 | try { 20 | const bot = await botModel.findOne({}); 21 | 22 | if (!token) { 23 | ({ access_token: token } = await twitchAPI.getAccessToken(bot.refresh_token)); 24 | } 25 | const botUser = await twitchAPI.getUser({ token }); 26 | 27 | // eslint-disable-next-line 28 | client = new tmi.Client({ 29 | connection: { 30 | secure: true, 31 | reconnect: true 32 | }, 33 | identity: { 34 | username: botUser.login, 35 | password: token 36 | }, 37 | options: { debug: true } 38 | }); 39 | 40 | client.on('message', messageHandler); 41 | 42 | await client.connect(); 43 | } catch(error) { 44 | console.error('Error connecting to twitch...', error); 45 | } 46 | 47 | return client; 48 | } 49 | 50 | function getToken() { 51 | return client.getOptions().identity.password; 52 | } 53 | 54 | async function init() { 55 | try { 56 | await getClient(); 57 | const dbChannels = await channelModel.find({ enabled: true }); 58 | const id = dbChannels.map(c => c.twitchId); 59 | await joinChannels(id); 60 | } catch (error) { 61 | console.error('Error connecting to twitch...', error); 62 | } 63 | } 64 | 65 | /** 66 | * @param {string[]} id 67 | */ 68 | async function joinChannels(id) { 69 | await getClient(); 70 | const channels = await twitchAPI.getUsers({ 71 | token: getToken(), 72 | id, 73 | }); 74 | for (const channel of channels) { 75 | await Promise.all([ 76 | client.join(channel.login), 77 | sleep(350) 78 | ]); 79 | } 80 | } 81 | 82 | /** 83 | * @param {string[]} id 84 | */ 85 | async function partChannels(id) { 86 | await getClient(); 87 | const channels = await twitchAPI.getUsers({ 88 | token: getToken(), 89 | id, 90 | }); 91 | for (const channel of channels) { 92 | await Promise.all([ 93 | client.part(channel.login), 94 | sleep(350) 95 | ]); 96 | } 97 | } 98 | 99 | /** 100 | * @param {string} channelId 101 | * @param {string} commandName 102 | * @param {string[]} args 103 | */ 104 | async function commandHandler(context) { 105 | const { reply, channelId, commandName } = context; 106 | // TODO: search through command aliases as well 107 | const [ channelCommand, globalCommand ] = await Promise.all([ 108 | commandModel.findOne({ channelId, name: commandName }), 109 | globalCommandModel.findOne({ name: commandName }) 110 | ]); 111 | const command = channelCommand || globalCommand; 112 | if (!command) { 113 | return; 114 | } 115 | if(command.replyText) { 116 | reply(command.replyText); 117 | } else { 118 | // TODO: check required command permission 119 | const commandFn = require(`./commands/${command.name}`); 120 | commandFn(context); 121 | } 122 | } 123 | 124 | /** 125 | * @param {string} channel 126 | * @param {import('tmi.js').ChatUserstate} tags 127 | * @param {string} message 128 | * @param {boolean} self 129 | */ 130 | async function messageHandler(channel, tags, message, self) { 131 | if (self || tags['message-type'] === 'whisper') { 132 | return; 133 | } 134 | // TODO: handle other prefixes based on channel settings 135 | if (message.startsWith('!')) { 136 | const args = message.slice(1).split(' '); 137 | const commandName = args.shift().toLowerCase(); 138 | const channelId = tags['room-id']; 139 | const context = { channel, channelId, commandName, args }; 140 | const reply = async msg => { 141 | try { 142 | msg = await formatCommand(msg, context); 143 | console.log({ msg }); 144 | if (msg.startsWith('/') || msg.startsWith('.')) { 145 | msg = 'Nice try! 🙃'; 146 | } 147 | await client.say(channel, msg); 148 | } catch (error) { 149 | console.error('Error compiling template', error); 150 | } 151 | }; 152 | commandHandler({ reply, ...context }); 153 | } 154 | } 155 | 156 | module.exports = { 157 | init, 158 | joinChannels, 159 | partChannels 160 | }; -------------------------------------------------------------------------------- /server/src/config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | /** 4 | * @typedef EnvironmentConfiguration 5 | * @prop {string} PORT The port to listen on. 6 | * @prop {string} TWITCH_CLIENT_ID Client ID for the Twitch app. 7 | * @prop {string} TWITCH_CLIENT_SECRET Client OAuth Secret for the Twitch app. 8 | * @prop {string} TWITCH_CLIENT_REDIR_HOST Client redirect. 9 | * @prop {string} BOT_REFRESH_TOKEN 10 | * @prop {string} MONGO_HOST Host URL of the Mongo instance. 11 | * @prop {string} MONGO_USER Mongo DB username. 12 | * @prop {string} MONGO_PASS Mongo db password 13 | * @prop {string} MONGO_DBNAME Name of mongo database 14 | * @prop {string} JWT_SECRET 15 | */ 16 | 17 | /** 18 | * @type {EnvironmentConfiguration} 19 | */ 20 | const config = { 21 | ...process.env, 22 | }; 23 | 24 | module.exports = config; -------------------------------------------------------------------------------- /server/src/db/bot.js: -------------------------------------------------------------------------------- 1 | const { model, Schema } = require('mongoose'); 2 | 3 | const BotSchema = new Schema({ 4 | name: { 5 | type: String, 6 | unique: true, 7 | }, 8 | refresh_token: { 9 | type: String, 10 | required: true, 11 | } 12 | }); 13 | 14 | /** 15 | * @typedef BotModel 16 | * @prop {string} name 17 | * @prop {string} refresh_token 18 | */ 19 | 20 | /** @type {BotModel | import('mongoose').Model} */ 21 | const botModel = model('bot', BotSchema); 22 | module.exports = botModel; 23 | -------------------------------------------------------------------------------- /server/src/db/channel.js: -------------------------------------------------------------------------------- 1 | const { model, Schema } = require('mongoose'); 2 | 3 | const ChannelSchema = new Schema({ 4 | twitchId: { 5 | type: String, 6 | unique: true, 7 | }, 8 | enabled: { 9 | type: Boolean, 10 | default: false 11 | } 12 | }, { 13 | versionKey: false 14 | }); 15 | 16 | /** 17 | * @typedef ChannelModel 18 | * @prop {string} twitchId 19 | * @prop {boolean} enabled 20 | */ 21 | 22 | /** @type {ChannelModel | import('mongoose').Model} */ 23 | const channelModel = model('channel', ChannelSchema); 24 | module.exports = channelModel; -------------------------------------------------------------------------------- /server/src/db/command.js: -------------------------------------------------------------------------------- 1 | const { model, Schema } = require('mongoose'); 2 | 3 | const CommandSchema = new Schema({ 4 | channelId: { type: String, required: true }, 5 | name: { type: String, required: true }, 6 | aliases: [{ type: String }], 7 | replyText: { type: String, required: true }, 8 | requiredRole: { type: String, default: 'viewer' }, 9 | enabled: { type: Boolean, default: true }, 10 | }, { 11 | versionKey: false 12 | }); 13 | 14 | const commandModel = model('command', CommandSchema); 15 | module.exports = commandModel; 16 | -------------------------------------------------------------------------------- /server/src/db/globalcommand.js: -------------------------------------------------------------------------------- 1 | const { model, Schema } = require('mongoose'); 2 | 3 | const GlobalCommandSchema = new Schema({ 4 | name: { type: String, required: true }, 5 | replyText: { type: String, default: null }, 6 | aliases: [{ type: String }], 7 | requiredRole: { type: String, default: 'viewer' } 8 | }, { 9 | versionKey: false 10 | }); 11 | 12 | const globalCommandModel = model('globalCommand', GlobalCommandSchema); 13 | module.exports = globalCommandModel; 14 | -------------------------------------------------------------------------------- /server/src/db/index.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const config = require('../config'); 4 | require('./bot'); 5 | 6 | mongoose.connect(`mongodb://${config.MONGO_HOST}/`, { 7 | user: config.MONGO_USER, 8 | pass: config.MONGO_PASS, 9 | dbName: config.MONGO_DBNAME, 10 | useNewUrlParser: true, 11 | useCreateIndex: true, 12 | useFindAndModify: false, 13 | useUnifiedTopology: true 14 | }); 15 | const { connection: db } = mongoose; 16 | 17 | db.on('connected', () => { 18 | console.log('Database connected'); 19 | }); 20 | 21 | db.on('disconnected', () => { 22 | console.log('Database disconnected'); 23 | }); 24 | 25 | db.on('error', err => { 26 | console.error(err); 27 | }); 28 | 29 | module.exports = db; -------------------------------------------------------------------------------- /server/src/db/seeds/globalcommands.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | name: 'uptime', 4 | aliases: [], 5 | replyText: 'Channel has been live for: {{twitch.uptime}}', 6 | requiredRole: 'viewer' 7 | }, { 8 | name: 'following', 9 | aliases: [], 10 | requiredRole: 'viewer' 11 | }, { 12 | name: 'settitle', 13 | aliases: [], 14 | requiredRole: 'manager' 15 | }, { 16 | name: 'setgame', 17 | aliases: [ 'setcategory', 'setdirectory', 'setplaying' ], 18 | requiredRole: 'manager' 19 | } 20 | ]; -------------------------------------------------------------------------------- /server/src/db/seeds/index.js: -------------------------------------------------------------------------------- 1 | const db = require('../'); 2 | 3 | const globalCommandModel = require('../globalcommand'); 4 | const globalCommandList = require('./globalcommands'); 5 | 6 | (async () => { 7 | console.log('Seeding global commands'); 8 | try { 9 | await globalCommandModel.collection.drop(); 10 | await globalCommandModel.create(...globalCommandList); 11 | console.log('Global commands seeded'); 12 | } catch (error) { 13 | console.error(error); 14 | } finally { 15 | db.close(); 16 | } 17 | })(); 18 | -------------------------------------------------------------------------------- /server/src/db/user.js: -------------------------------------------------------------------------------- 1 | const { model, Schema } = require('mongoose'); 2 | 3 | const UserSchema = new Schema({ 4 | twitchId: { 5 | type: String, 6 | unique: true, 7 | }, 8 | refresh_token: { 9 | type: String, 10 | required: true, 11 | } 12 | }, { 13 | versionKey: false 14 | }); 15 | 16 | /** 17 | * @typedef UserModel 18 | * @prop {string} twitchId 19 | * @prop {string} refresh_token 20 | */ 21 | 22 | /** @type {UserModel | import('mongoose').Model} */ 23 | const userModel = model('user', UserSchema); 24 | module.exports = userModel; 25 | -------------------------------------------------------------------------------- /server/src/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | const volleyball = require('volleyball'); 4 | const helmet = require('helmet'); 5 | const path = require('path'); 6 | 7 | require('dotenv').config(); 8 | 9 | const config = require('./config'); 10 | 11 | require('./db'); 12 | const middlewares = require('./middlewares'); 13 | 14 | const bot = require('./bot'); 15 | bot.init(); 16 | 17 | const app = express(); 18 | app.use(cors()); 19 | app.use(volleyball); 20 | app.use(helmet()); 21 | app.use(express.json()); 22 | 23 | app.use(middlewares.decodeAuthHeader); 24 | 25 | app.use(express.static(path.join(__dirname, 'www'), { extensions: [ 'html' ] })); 26 | 27 | app.use('/auth/twitch', require('./auth/twitch')); 28 | app.use('/api/channel', (req, res, next) => { 29 | if (!req.user) { 30 | next(new Error('Un-authorized')); 31 | } 32 | next(); 33 | }, require('./api/channel')); 34 | 35 | app.use((req, res, next) => { 36 | const error = new Error('Not Found - ' + req.originalUrl); 37 | res.status(404); 38 | next(error); 39 | }); 40 | 41 | // eslint-disable-next-line 42 | app.use((error, req, res, next) => { 43 | res.status(res.statusCode === 200 ? 500 : res.statusCode); 44 | res.json({ 45 | message: error.message, 46 | stack: config.NODE_ENV === 'production' ? undefined : error.stack, 47 | }); 48 | }); 49 | 50 | const port = process.env.PORT || 8888; 51 | const server = app.listen( 52 | port, 53 | () => console.log('http://localhost:' + server.address().port) 54 | ); 55 | -------------------------------------------------------------------------------- /server/src/lib/jwt.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | const config = require('../config'); 4 | 5 | const secret = Buffer.from(config.JWT_SECRET, 'base64'); 6 | 7 | function sign({ twitchId }) { 8 | return new Promise((resolve, reject) => { 9 | jwt.sign( 10 | { twitchId }, 11 | secret, 12 | { 13 | expiresIn: '1h' 14 | }, 15 | (err, token) => { 16 | if (err) return reject(err); 17 | return resolve(token); 18 | } 19 | ); 20 | }); 21 | } 22 | 23 | function verify(token) { 24 | return new Promise((resolve, reject) => { 25 | jwt.verify( 26 | token, 27 | secret, 28 | (err, decoded) => { 29 | if (err) return reject(err); 30 | return resolve(decoded); 31 | } 32 | ); 33 | }); 34 | } 35 | 36 | module.exports = { 37 | sign, 38 | verify 39 | }; -------------------------------------------------------------------------------- /server/src/lib/twitch-api.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const config = require('../config'); 4 | 5 | const authBaseUrl = 'https://id.twitch.tv/oauth2'; 6 | const authAPI = axios.create({ 7 | baseURL: authBaseUrl, 8 | }); 9 | 10 | const helixBaseUrl = 'https://api.twitch.tv/helix'; 11 | const helix = axios.create({ 12 | baseURL: helixBaseUrl, 13 | }); 14 | 15 | /** 16 | * @typedef TwitchAPIUser 17 | * @prop {string} id The Twitch API user ID. 18 | * @prop {string} login The Twitch API user login name. 19 | */ 20 | 21 | /** 22 | * @typedef TwitchStream 23 | * @prop {string} id The ID of the stream. 24 | * @prop {string} user_id The Twitch API user ID. 25 | * @prop {string} user_name The Twitch API display name. 26 | * @prop {string} game_id The ID of the game. 27 | * @prop {"live" | ""} type Stream type. 28 | * @prop {number} viewer_count 29 | * @prop {string} started_at Date string. 30 | * @prop {string} language 31 | * @prop {string} thumbnail_url 32 | * @see https://dev.twitch.tv/docs/api/reference#get-streams 33 | */ 34 | 35 | /** 36 | * @param {any} options 37 | * @param {string} options.token The OAuth token for the expected user. 38 | * @return {TwitchAPIUser} 39 | */ 40 | async function getUser({ token } = {}) { 41 | const { data: { data } } = await helix.get('/users', { 42 | headers: { 43 | Authorization: `Bearer ${token}` 44 | } 45 | }); 46 | return data[0] || null; 47 | } 48 | 49 | /** 50 | * @param {any} options 51 | * @param {string} id A list of IDs to look up. 52 | * @param {string} options.token The OAuth token for the bot user. 53 | * @return {TwitchAPIUser[]} 54 | */ 55 | async function getUsers({ id = [], token }) { 56 | if (!id.length) return []; 57 | const qs = new URLSearchParams(); 58 | // TODO: handle more than 100 ids 59 | for(const n of id) { 60 | qs.append('id', n); 61 | } 62 | const { data: { data } } = await helix.get(`/users?${qs}`, { 63 | headers: { 64 | Authorization: `Bearer ${token}` 65 | } 66 | }); 67 | return data; 68 | } 69 | 70 | async function getAccessToken(refresh_token) { 71 | const qs = new URLSearchParams({ 72 | grant_type: 'refresh_token', 73 | refresh_token, 74 | client_id: config.TWITCH_CLIENT_ID, 75 | client_secret: config.TWITCH_CLIENT_SECRET 76 | }); 77 | const { data } = await authAPI.post(`/token?${qs}`); 78 | return data; 79 | } 80 | 81 | // TODO: cache results for x amount of time, don't call same API twice simultaneously 82 | /** 83 | * @param {string} user_id 84 | * @return {TwitchStream} 85 | * @see https://dev.twitch.tv/docs/api/reference#get-streams 86 | */ 87 | async function getStream(user_id) { 88 | const qs = new URLSearchParams({ 89 | user_id 90 | }); 91 | const { data: { data } } = await helix.get(`/streams?${qs}`, { 92 | headers: { 93 | 'Client-ID': config.TWITCH_CLIENT_ID 94 | } 95 | }); 96 | return data[0] || null; 97 | } 98 | 99 | module.exports = { 100 | authAPI, 101 | getUser, 102 | getUsers, 103 | getAccessToken, 104 | getStream 105 | }; -------------------------------------------------------------------------------- /server/src/lib/utils.js: -------------------------------------------------------------------------------- 1 | const sleep = (timeout = 1000) => new Promise((resolve) => setTimeout(resolve, timeout)); 2 | 3 | module.exports = { 4 | sleep 5 | }; 6 | -------------------------------------------------------------------------------- /server/src/middlewares/index.js: -------------------------------------------------------------------------------- 1 | const jwt = require('../lib/jwt'); 2 | 3 | async function decodeAuthHeader(req, res, next) { 4 | const authHeader = req.get('Authorization'); 5 | req.user = null; 6 | if (authHeader) { 7 | const token = authHeader.split(' ')[1]; 8 | if (token) { 9 | try { 10 | const user = await jwt.verify(token); 11 | // eslint-disable-next-line 12 | req.user = user; 13 | } catch (error) { 14 | console.error('Error validating token.', token); 15 | } 16 | } 17 | } 18 | next(); 19 | } 20 | 21 | module.exports = { 22 | decodeAuthHeader 23 | }; -------------------------------------------------------------------------------- /server/src/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Logging In 8 | 9 | 14 | 15 | 16 |

Logging in...

17 | 26 | 27 | --------------------------------------------------------------------------------