├── setup └── core │ ├── assets │ ├── cat.png │ ├── doge.png │ └── ban-police.png │ └── settings.json ├── utils ├── Runtime.js ├── Brain.js ├── Templater.js ├── Log.js ├── Say.js ├── Assets.js ├── Settings.js ├── Gist.js ├── Websocket.js ├── Loader.js ├── ChatBot.js └── Client.js ├── client ├── index.html └── client.js ├── test ├── example.test.js ├── core │ └── Say.test.js └── commands │ └── say.test.js ├── commands ├── voices.js ├── unavailable.js ├── subject.js ├── say.js ├── leaderboard.js ├── custom-commands.js ├── greeting.js ├── user-status.js ├── todo.js ├── help.js └── ban-management.js ├── package.json ├── .gitignore ├── examples └── plugins │ └── test │ └── index.js ├── index.js ├── model └── User.js ├── README.md └── changelog.md /setup/core/assets/cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenconti/livecodingtv-bot/HEAD/setup/core/assets/cat.png -------------------------------------------------------------------------------- /setup/core/assets/doge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenconti/livecodingtv-bot/HEAD/setup/core/assets/doge.png -------------------------------------------------------------------------------- /setup/core/assets/ban-police.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenconti/livecodingtv-bot/HEAD/setup/core/assets/ban-police.png -------------------------------------------------------------------------------- /utils/Runtime.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | debug: false, 5 | commands: null, 6 | plugins: null, 7 | websocketCommands: null, 8 | startupTime: 0, 9 | credentials: null, 10 | brain: null 11 | }; 12 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | LCTV Bot - Client 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/example.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('assert'); 4 | describe('Array', function() { 5 | describe('#indexOf()', function () { 6 | it('should return -1 when the value is not present', function () { 7 | assert.equal(-1, [1,2,3].indexOf(5)); 8 | assert.equal(-1, [1,2,3].indexOf(0)); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /utils/Brain.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const brain = require('node-persist'); 4 | 5 | class Brain { 6 | static start( directory ) { 7 | brain.initSync({ 8 | dir: directory 9 | }); 10 | } 11 | 12 | static get( key ) { 13 | return brain.getItemSync( key ) || null; 14 | } 15 | 16 | static set( key, value ) { 17 | brain.setItemSync( key, value ); 18 | } 19 | }; 20 | 21 | module.exports = Brain; 22 | -------------------------------------------------------------------------------- /utils/Templater.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class Templater { 4 | /** 5 | * Runs a template string through a key/value map. 6 | * @param {String} templateString 7 | * @param {Object} model 8 | * @return {String} 9 | */ 10 | static run( templateString, model ) { 11 | let keys = Object.keys( model ); 12 | keys.forEach( ( key ) => { 13 | let pattern = new RegExp( "{{" + key + "}}", "g" ); 14 | templateString = templateString.replace( pattern, model[key] ); 15 | } ); 16 | 17 | return templateString; 18 | } 19 | } 20 | 21 | module.exports = Templater; 22 | -------------------------------------------------------------------------------- /commands/voices.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = [{ 4 | name: '!voices', 5 | help: 'Lists the available voices to be used with the !say command.', 6 | types: ['message'], 7 | regex: /^(!|\/)voices/i, 8 | action: function( chat, stanza ) { 9 | let voices = `Agnes, Kathy, Princess, Vicki, Victoria, Albert, Alex, Bruce, Fred, Junior, Ralph, Bad News, Bahh, Bells, Boing, Bubbles, Cellos, Deranged, Good News, Hysterical, Pipe Organ, Trinoids, Whisper, Zarvox`; 10 | chat.replyTo( stanza.user.username, `Available !say voices:\n\n${voices}` ); 11 | } 12 | }]; 13 | -------------------------------------------------------------------------------- /commands/unavailable.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Settings = require('../utils/Settings'); 4 | const fileSettings = Settings.getSettingFile( __filename ); 5 | const Templater = require('../utils/Templater'); 6 | const Say = require('../utils/Say'); 7 | 8 | module.exports = [{ 9 | types: ['presence'], 10 | regex: /^unavailable$/, 11 | action: function( chat, stanza ) { 12 | if ( fileSettings.enabled ) { 13 | let msg = Templater.run( fileSettings.disconnectMessage, { 14 | username: stanza.user.username 15 | } ); 16 | Say.say( stanza.user.username + ' disconnected.' ); 17 | } 18 | } 19 | }]; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lctv-bot", 3 | "version": "1.0.0", 4 | "description": "Live Coding TV chat bot", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha test/**/*.test.js" 8 | }, 9 | "author": "Owen Conti ", 10 | "license": "ISC", 11 | "dependencies": { 12 | "express": "^4.13.3", 13 | "moment": "^2.10.6", 14 | "node-base64-image": "^0.1.0", 15 | "node-persist": "0.0.6", 16 | "node-xmpp-client": "^2.0.2", 17 | "request": "^2.65.0", 18 | "say": "^0.6.0", 19 | "websocket": "^1.0.22", 20 | "winsay": "0.0.5", 21 | "youtube-node": "^1.3.0" 22 | }, 23 | "devDependencies": { 24 | "mocha": "^2.3.3", 25 | "simple-mock": "^0.4.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /utils/Log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const moment = require('moment'); 4 | const brain = require('node-persist'); 5 | let logStorage = brain.create({ 6 | dir: __dirname + '/../logs' 7 | }); 8 | logStorage.initSync(); 9 | 10 | class Log { 11 | /** 12 | * Logs parameters to console.log, 13 | * stores all parameters in the brain. 14 | * @return {void} 15 | */ 16 | static log() { 17 | let args = Array.prototype.slice.call(arguments); 18 | let date = moment().format('YYYY-MM-DD'); 19 | let logs = logStorage.getItem('logs-' + date) || []; 20 | 21 | args.forEach( function(arg) { 22 | console.log( arg ); 23 | logs.push( arg ); 24 | } ); 25 | 26 | logStorage.setItem( 'logs-' + date, logs ); 27 | } 28 | 29 | } 30 | 31 | module.exports = Log; 32 | -------------------------------------------------------------------------------- /.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 | # Don't show our credentials to anyone 30 | /setup/credentials.js 31 | 32 | # Don't store the contents of the brain in the repo 33 | brain 34 | 35 | # Don't commit custom plugins 36 | /plugins 37 | 38 | # Don't commit settings.json file 39 | /setup/custom 40 | -------------------------------------------------------------------------------- /commands/subject.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const xmpp = require('node-xmpp-client'); 4 | const runtime = require('../utils/Runtime'); 5 | const setSubjectRegex = /^(!|\/)setsubject\s(.+)$/; 6 | 7 | module.exports = [{ 8 | name: '!setsubject {subject}', 9 | help: 'Sets the room\'s subject to {subject}.', 10 | types: ['message'], 11 | regex: setSubjectRegex, 12 | action: function( chat, stanza ) { 13 | if ( stanza.user.isModerator() ) { 14 | let subject = setSubjectRegex.exec( stanza.message )[2]; 15 | 16 | // Build the stanza 17 | let subjectStanza = new xmpp.Stanza('message', { 18 | from: runtime.credentials.jid, 19 | id: 'lh2bs617', 20 | to: runtime.credentials.roomJid, 21 | type: 'groupchat' 22 | }) 23 | .c('subject') 24 | .t( subject ); 25 | 26 | chat.client.send( subjectStanza ); 27 | } 28 | } 29 | }] 30 | -------------------------------------------------------------------------------- /examples/plugins/test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Test plugin example 5 | * 6 | * There must be an 'index.js' file for each plugin! 7 | * Plugins can then require additional files, if needed. 8 | * 9 | * Command 1: 10 | * 11 | * The first command will respond to any message with 12 | * with the contents 'test'. The bot will reply with 13 | * a string: 'I heard test!'. 14 | * 15 | * Command 2: 16 | * 17 | * The second command is a startup command. It will be called 18 | * during start up, after connecting to the server. 19 | * It logs out the string, "Starting the test plugin". 20 | * You can use startup commands to initialize storage mechanisms, 21 | * or to connect to APIs, etc, etc. 22 | */ 23 | 24 | module.exports = [{ 25 | types: ['message'], 26 | regex: /^test$/, 27 | action: function(chat, stanza) { 28 | chat.sendMessage('I heard test!'); 29 | } 30 | }, { 31 | types: ['startup'], 32 | action: function(chat, stanza) { 33 | console.log( 'Starting the test plugin' ); 34 | } 35 | }]; 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * LCTV Bot :) 5 | */ 6 | 7 | const credentials = require('./setup/custom/credentials'); 8 | const Brain = require('./utils/Brain'); 9 | const ChatBot = require('./utils/ChatBot'); 10 | 11 | // Build the initial runtime object 12 | let runtime = require('./utils/Runtime'); 13 | runtime.debug = process.argv[2] === 'debug' || false; 14 | runtime.coreCommands = null; 15 | runtime.pluginCommands = null; 16 | runtime.websocketCommands = null; 17 | runtime.startUpTime = new Date().getTime(); 18 | runtime.credentials = credentials; 19 | runtime.brain = Brain; 20 | 21 | // Verify credentials exist 22 | if ( !runtime.credentials.username || !runtime.credentials.room || !runtime.credentials.password || !runtime.credentials.jid || !runtime.credentials.roomJid ) { 23 | console.error('ERROR: Credentials file is missing required attributes. Please check your credentials.js'); 24 | console.log('[bot] Quitting startup process.'); 25 | return; 26 | } 27 | 28 | runtime.brain.start( __dirname + "/brain" ); 29 | ChatBot.start(); 30 | -------------------------------------------------------------------------------- /commands/say.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Settings = require('../utils/Settings'); 4 | const defaultVoice = Settings.getSetting( __filename, 'defaultVoice' ); 5 | const Say = require('../utils/Say'); 6 | const Assets = require('../utils/Assets'); 7 | const Websocket = require('../utils/Websocket'); 8 | const regex = new RegExp( /^(!|\/)say\s(.+)$/ ); 9 | 10 | module.exports = [{ 11 | name: '!say {[-voice VoiceName]} {message}', 12 | help: 'Verbally speaks a message.', 13 | types: ['message'], 14 | regex: regex, 15 | action: function( chat, stanza ) { 16 | // Parse the message from the command, 17 | // limit !say message to 80 chars 18 | let message = regex.exec( stanza.message )[2]; 19 | message = message.substr( 0, 80 ); 20 | 21 | // Allow users to override the voice 22 | let voice = defaultVoice; 23 | let match = /^\-voice\s(\w+)\s(.+)/.exec( message ); 24 | if ( match ) { 25 | voice = match[1]; 26 | message = match[2]; 27 | } 28 | 29 | Say.say( message, voice ); 30 | } 31 | }]; 32 | -------------------------------------------------------------------------------- /utils/Say.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const isWin = /^win/.test( process.platform ); 4 | const say = isWin ? require('winsay') : require('say'); 5 | const Log = require('./Log'); 6 | const Settings = require('./Settings'); 7 | const runtime = require('./Runtime'); 8 | 9 | let queue = []; 10 | let speaking = false; 11 | 12 | /** 13 | * Says the next item in the say queue. 14 | * @return void 15 | */ 16 | function sayNextItem() { 17 | if ( queue.length > 0 && !speaking ) { 18 | let item = queue[ 0 ]; 19 | speaking = true; 20 | 21 | say.speak( item.voice, item.message, function() { 22 | queue.shift(); 23 | speaking = false; 24 | sayNextItem(); 25 | } ); 26 | } 27 | } 28 | 29 | module.exports = { 30 | say( message, voice ) { 31 | // Check settings to make sure Say is enabled 32 | let sayEnabled = Settings.getSetting( 'SayUtil', 'enabled' ); 33 | if ( !sayEnabled ) { 34 | return; 35 | } 36 | 37 | if ( !voice ) { 38 | voice = 'Victoria'; 39 | } 40 | if ( !message || typeof message !== 'string' ) { 41 | throw new Error('Nothing to say!'); 42 | } 43 | if ( runtime.debug ) { 44 | Log.log('DEBUGGING (say): ' + message); 45 | return; 46 | } 47 | 48 | let sayObj = { 49 | voice: voice, 50 | message: message 51 | }; 52 | queue.push( sayObj ); 53 | sayNextItem(); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /utils/Assets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const base64 = require('node-base64-image'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | class Assets { 8 | static loadUrl( url, callback ) { 9 | // base64 encode the loaded image 10 | base64.base64encoder( url, { 11 | string: true 12 | }, function( err, image ) { 13 | if ( err ) { 14 | console.log(err); 15 | return; 16 | } 17 | 18 | if ( callback ) { 19 | callback( image ); 20 | } 21 | } ); 22 | } 23 | 24 | static load( fileName, callback ) { 25 | let filePath = path.join( __dirname, '../setup/custom/assets/', fileName ); 26 | 27 | try { 28 | // Check for a custom asset 29 | fs.statSync( filePath ); 30 | } catch( e ) { 31 | try { 32 | // No custom assets exists, look in the core assets directory 33 | filePath = path.join( __dirname, '../setup/core/assets/', fileName ); 34 | fs.statSync( filePath ); 35 | } catch( e2 ) { 36 | console.warn(`[bot] Asset: ${filePath} not found!`); 37 | return; 38 | } 39 | } 40 | 41 | // base64 encode the loaded image 42 | base64.base64encoder( filePath, { 43 | localFile: true, 44 | string: true 45 | }, function( err, image ) { 46 | if ( err ) { 47 | console.log(err); 48 | return; 49 | } 50 | 51 | if ( callback ) { 52 | callback( image ); 53 | } 54 | } ); 55 | } 56 | } 57 | 58 | module.exports = Assets; 59 | -------------------------------------------------------------------------------- /commands/leaderboard.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Leaderboard - tracks the number of times a viewer enters the stream. 5 | * Limits a viewer to update the leaderboard once every 10 minutes. 6 | * 7 | * Commands: 8 | * 9 | * !top X - Displays the top X viewers 10 | */ 11 | 12 | const runtime = require('../utils/Runtime'); 13 | const Log = require('../utils/Log'); 14 | const topRegex = /^(\!|\/)top\s(\d{1,2})$/; 15 | 16 | module.exports = [{ 17 | name: '!top {X}', 18 | help: 'Displays the top X viewers.', 19 | types: ['message'], 20 | regex: topRegex, 21 | action: function( chat, stanza ) { 22 | const x = parseInt( topRegex.exec( stanza.message )[2], 10 ); 23 | 24 | // Grab users from the leaderboard brain object 25 | // Map the leaderboard into an array 26 | let users = runtime.brain.get( 'users' ) || {}; 27 | let userScores = []; 28 | for ( let username in users ) { 29 | userScores.push( users[ username ] ); 30 | } 31 | 32 | // Sort the entire leaderboard 33 | userScores.sort( function(a, b) { 34 | return a.count < b.count ? -1 : a.count > b.count ? 1 : 0; 35 | }).reverse(); 36 | 37 | // Build the output message 38 | let msg = `Top ${x} Viewers:\n`; 39 | for ( let i = 0; i < x; i++ ) { 40 | const user = userScores[i]; 41 | if ( user ) { 42 | let viewsText = user.count === 1 ? 'view' : 'views'; 43 | msg += `${i+1}. ${user.username} with ${user.count} ${viewsText}!\n`; 44 | } 45 | } 46 | chat.sendMessage( msg ); 47 | } 48 | }] 49 | -------------------------------------------------------------------------------- /utils/Settings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | let defaultSettings = require('../setup/core/settings.json'); 5 | let customSettings = require('../setup/custom/settings.json'); 6 | 7 | class Settings { 8 | /** 9 | * Returns a setting with the passed-in key, 10 | * from the specified file. 11 | * @param {String} fileName 12 | * @param {String} key 13 | * @return {any} 14 | */ 15 | static getSetting( fileName, key ) { 16 | fileName = path.basename( fileName, '.js' ); 17 | 18 | // Check for the setting in custom settings first 19 | if ( customSettings[ fileName ] && customSettings[ fileName ][ key ] !== undefined ) { 20 | return customSettings[ fileName ][ key ]; 21 | } 22 | 23 | // If a setting was not found in the custom settings 24 | // then search in the core settings 25 | if ( defaultSettings[ fileName ] && defaultSettings[ fileName ][ key ] !== undefined ) { 26 | return defaultSettings[ fileName ][ key ]; 27 | } 28 | 29 | return null; 30 | } 31 | 32 | static getSettingFile( fileName ) { 33 | fileName = path.basename( fileName, '.js' ); 34 | 35 | // Check for the setting in settings.json first 36 | if ( customSettings[ fileName ] ) { 37 | return customSettings[ fileName ]; 38 | } 39 | 40 | // If a setting was not found in the settings.json, 41 | // then search in the defaultSettings 42 | if ( defaultSettings[ fileName ] ) { 43 | return defaultSettings[ fileName ]; 44 | } 45 | 46 | return null; 47 | } 48 | } 49 | 50 | module.exports = Settings; 51 | -------------------------------------------------------------------------------- /test/core/Say.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const simpleMock = require('simple-mock'); 5 | const isWin = /^win/.test( process.platform ); 6 | const say = isWin ? require('winsay') : require('say'); 7 | const SayUtil = require('../../utils/Say'); 8 | 9 | let sayCommands = require('../../commands/say'); 10 | 11 | describe('Say Utility', function() { 12 | beforeEach(function() { 13 | simpleMock.mock( say, 'speak' ).callFn(function( voice, message, callback ) { 14 | callback(); 15 | }); 16 | }); 17 | afterEach(function() { 18 | simpleMock.restore(); 19 | }); 20 | describe('Say.say', function () { 21 | it('calling say() with no message throws an error', function () { 22 | assert.throws( function() { 23 | SayUtil.say(); 24 | } ); 25 | assert.throws( function() { 26 | SayUtil.say( null ); 27 | } ); 28 | assert.throws( function() { 29 | SayUtil.say( undefined ); 30 | } ); 31 | assert.throws( function() { 32 | SayUtil.say( '' ); 33 | } ); 34 | assert.throws( function() { 35 | SayUtil.say( 10 ); 36 | } ); 37 | }); 38 | it('calling say() with no voice defaults to Victoria', function () { 39 | SayUtil.say('Testing'); 40 | assert.equal( say.speak.lastCall.args[0], 'Victoria' ); 41 | }); 42 | it('passes the correct message to say.speak', function () { 43 | SayUtil.say('Testing'); 44 | assert.equal( say.speak.lastCall.args[1], 'Testing' ); 45 | }); 46 | it('passes the correct message to say.speak', function () { 47 | SayUtil.say('Testing', 'Frank'); 48 | assert.equal( say.speak.lastCall.args[0], 'Frank' ); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /setup/core/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "coreCommands" : { 3 | "ban-management" : true, 4 | "custom-commands" : true, 5 | "greeting" : false, 6 | "help" : true, 7 | "leaderboard" : true, 8 | "say" : true, 9 | "subject" : true, 10 | "todo" : true, 11 | "unavailable" : false, 12 | "user-status" : true, 13 | "voices" : true 14 | }, 15 | "ban-management" : { 16 | "banSayMessage" : "{{username}} You are banned for being a douche" 17 | }, 18 | "greeting": { 19 | "default" : "Welcome to the stream!", 20 | "existing" : { 21 | "Viewer" : [ 22 | "Back again! How's life treating you today?", 23 | "Hey friend! What are you working on today?" 24 | ], 25 | "Royalty" : [ 26 | "The king has arrived! We appreciate your support!" 27 | ] 28 | }, 29 | "new" : [ 30 | "Welcome to the stream! Thanks for stopping by!" 31 | ] 32 | }, 33 | "say" : { 34 | "defaultVoice" : "Victoria" 35 | }, 36 | "unavailable" : { 37 | "disconnectMessage" : "{{username}} disconnected.", 38 | "enabled" : true 39 | }, 40 | "user-status" : { 41 | "statuses" : { 42 | "viewer" : { 43 | "title" : "Viewer", 44 | "weight" : 0 45 | }, 46 | "follower" : { 47 | "title" : "Follower", 48 | "weight" : 50 49 | }, 50 | "vip" : { 51 | "title" : "VIP", 52 | "weight" : 70 53 | }, 54 | "donator" : { 55 | "title" : "Donator", 56 | "weight" : 80 57 | }, 58 | "moderator" : { 59 | "title" : "Moderator", 60 | "weight" : 90 61 | }, 62 | "owner" : { 63 | "title" : "Owner", 64 | "weight" : 100 65 | } 66 | } 67 | }, 68 | "SayUtil" : { 69 | "enabled" : true 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /model/User.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const runtime = require('../utils/Runtime'); 4 | const Settings = require('../utils/Settings'); 5 | const availableStatuses = Settings.getSetting( 'user-status', 'statuses' ); 6 | 7 | class User { 8 | constructor( attrs ) { 9 | this.username = attrs.username; 10 | this.role = attrs.role; 11 | this.status = attrs.status; 12 | this.viewCount = attrs.count; 13 | this.lastVisitTime = attrs.time; 14 | } 15 | 16 | /** 17 | * Save this user into the brain 18 | * @return {void} 19 | */ 20 | saveToBrain() { 21 | let users = runtime.brain.get( 'users' ) || {}; 22 | users[ this.username ] = { 23 | username: this.username, 24 | count: this.viewCount, 25 | time: this.lastVisitTime, 26 | role: this.role, 27 | status: this.status 28 | }; 29 | runtime.brain.set( 'users', users ); 30 | } 31 | 32 | getMessages() { 33 | let messages = runtime.brain.get( 'userMessages' ) || {}; 34 | let userMessageLog = messages[ this.username ]; 35 | 36 | return userMessageLog; 37 | } 38 | 39 | /** 40 | * Returns a boolean if the user has equal-to or 41 | * greater than the passed-in permission. 42 | * @param {String} statusID 43 | * @return {Boolean 44 | */ 45 | hasStatus( statusID ) { 46 | let statusObj = availableStatuses[ statusID.toLowerCase() ]; 47 | let userStatusObj = availableStatuses[ this.status.toLowerCase() ]; 48 | return userStatusObj.weight >= statusObj.weight; 49 | } 50 | 51 | isModerator() { 52 | return this.hasStatus( 'moderator' ); 53 | } 54 | 55 | isStreamer() { 56 | return this.username === runtime.credentials.room; 57 | } 58 | 59 | isBot() { 60 | return this.username === runtime.credentials.username; 61 | } 62 | } 63 | 64 | module.exports = User; 65 | -------------------------------------------------------------------------------- /test/commands/say.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const simpleMock = require('simple-mock'); 5 | const Say = require('../../utils/Say'); 6 | const Assets = require('../../utils/Assets'); 7 | 8 | let sayCommands = require('../../commands/say'); 9 | 10 | describe('!say command', function() { 11 | let chat; 12 | 13 | beforeEach(function() { 14 | chat = {}; 15 | simpleMock.mock( Assets, 'load' ).callFn(function() {}); 16 | simpleMock.mock( Say, 'say' ).callFn(function() {}); 17 | }); 18 | afterEach(function() { 19 | chat = {}; 20 | simpleMock.restore(); 21 | }); 22 | describe('!say {message}', function () { 23 | it('should call the Say.say command with default voice and a message', function () { 24 | sayCommands[0].action( chat, { 25 | type: 'message', 26 | username: 'ohseemedia', 27 | message: '!say testing', 28 | rateLimited: false 29 | } ); 30 | assert.deepEqual( Say.say.lastCall.args, ['testing', 'Victoria'] ); 31 | }); 32 | it('should call the Say.say command with passed-in voice and a message', function () { 33 | sayCommands[0].action( chat, { 34 | type: 'message', 35 | username: 'ohseemedia', 36 | message: '!say -voice Frank testing', 37 | rateLimited: false 38 | } ); 39 | assert.deepEqual( Say.say.lastCall.args, ['testing', 'Frank'] ); 40 | }); 41 | it('should strip the message to max 50 chars', function () { 42 | sayCommands[0].action( chat, { 43 | type: 'message', 44 | username: 'ohseemedia', 45 | message: '!say This message is longer than 50 characters. It should be stripped.', 46 | rateLimited: false 47 | } ); 48 | assert.deepEqual( Say.say.lastCall.args, ['This message is longer than 50 characters. It shou', 'Victoria'] ); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /commands/custom-commands.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * This command allows moderators to manage 5 | * commands via chat instead of via code. 6 | * 7 | * Usage: 8 | * 9 | * !addcommand COMMAND_NAME OUTPUT 10 | * !removecommand COMMAND_NAME 11 | */ 12 | const runtime = require('../utils/Runtime'); 13 | 14 | let addCommandRegex = new RegExp( /^(!|\/)addcommand\s(\w+)\s((.|\n)+)$/ ); 15 | let removeCommandRegex = new RegExp( /^(!|\/)removecommand\s(\w+)$/ ); 16 | let runCommandRegex = new RegExp( /^(!|\/)(\w+)$/ ); 17 | 18 | module.exports = [{ 19 | // Run custom command 20 | types: ['message'], 21 | regex: runCommandRegex, 22 | action: function( chat, stanza ) { 23 | let match = runCommandRegex.exec( stanza.message ); 24 | let command = match[2]; 25 | let customCommands = runtime.brain.get('customCommands') || {}; 26 | let commandValue = customCommands[ command ]; 27 | 28 | if ( commandValue ) { 29 | chat.sendMessage( commandValue ); 30 | } 31 | } 32 | }, { 33 | // Add custom command 34 | name: '!addcommand {command} {output}', 35 | help: 'Adds a new command to the bot (Mod only).', 36 | types: ['message'], 37 | regex: addCommandRegex, 38 | action: function( chat, stanza ) { 39 | if ( stanza.user.isModerator() ) { 40 | let match = addCommandRegex.exec( stanza.message ); 41 | let command = match[2]; 42 | let commandValue = match[3]; 43 | let customCommands = runtime.brain.get('customCommands') || {}; 44 | 45 | customCommands[ command ] = commandValue; 46 | runtime.brain.set( 'customCommands', customCommands ); 47 | 48 | chat.replyTo( stanza.user.username, `!${command} added!` ); 49 | } 50 | } 51 | }, { 52 | // Remove custom command 53 | name: '!removecomamnd {command}', 54 | help: 'Removes a command from the bot (Mod only).', 55 | types: ['message'], 56 | regex: removeCommandRegex, 57 | action: function( chat, stanza ) { 58 | if ( stanza.user.isModerator() ) { 59 | let match = removeCommandRegex.exec( stanza.message ); 60 | let command = match[2]; 61 | let customCommands = runtime.brain.get('customCommands') || {}; 62 | 63 | // Remove the command from the customCommands object 64 | delete customCommands[ command ]; 65 | 66 | runtime.brain.set( 'customCommands', customCommands ); 67 | chat.replyTo( stanza.user.username, `!${command} removed!` ); 68 | } 69 | } 70 | }] 71 | -------------------------------------------------------------------------------- /commands/greeting.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Greets a viewer when they join the stream. 5 | * There is a different message displayed for new viewers vs. previous viewers. 6 | */ 7 | 8 | const runtime = require('../utils/Runtime'); 9 | const Settings = require('../utils/Settings'); 10 | const Templater = require('../utils/Templater'); 11 | 12 | /** 13 | * Checks the settings files for greetings, for the passed-in viewerType 14 | * @param {String} viewerType 15 | * @param {String} status 16 | * @return {array} 17 | */ 18 | function findAvailableGreetings( viewerType, status ) { 19 | let greetingsForViewerType = Settings.getSetting( __filename, viewerType ); 20 | 21 | // If the user is new, return the 'new' greetings 22 | if ( viewerType === 'new' ) { 23 | return greetingsForViewerType; 24 | } else { 25 | // User is existing 26 | if ( greetingsForViewerType && greetingsForViewerType[status] !== undefined ) { 27 | // Greeting for the user's status exists 28 | return greetingsForViewerType[status]; 29 | } else { 30 | // Greeting for the user's status does not exist, 31 | // return greetings for the first status 32 | let firstExistingStatus = Object.keys( greetingsForViewerType )[0]; 33 | return greetingsForViewerType[ firstExistingStatus ]; 34 | } 35 | } 36 | } 37 | 38 | /** 39 | * Returns a random greeting from the 40 | * available greetings passed-in. 41 | * @param {array} availableGreetings 42 | * @return {string} 43 | */ 44 | function getRandomGreeting( availableGreetings ) { 45 | if ( !availableGreetings ) { 46 | return Settings.getSetting( __filename, 'defaultGreeting' ); 47 | } 48 | 49 | let index = Math.floor(Math.random() * availableGreetings.length); 50 | return availableGreetings[ index ]; 51 | }; 52 | 53 | module.exports = [{ 54 | types: ['presence'], 55 | regex: /^available$/, 56 | action: function( chat, stanza ) { 57 | if ( stanza.user.isStreamer() || stanza.user.isBot() ) { 58 | // Don't greet the streamer or the bot 59 | return; 60 | } 61 | 62 | let viewerType = stanza.user.viewCount > 1 ? 'existing' : 'new'; 63 | let availableGreetings = findAvailableGreetings( viewerType, stanza.user.status ); 64 | let greeting = getRandomGreeting( availableGreetings ); 65 | greeting = Templater.run( greeting, { 66 | username: stanza.user.username, 67 | status: stanza.user.status 68 | } ); 69 | 70 | chat.replyTo( stanza.user.username, greeting ); 71 | } 72 | }]; 73 | -------------------------------------------------------------------------------- /commands/user-status.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const runtime = require('../utils/Runtime'); 4 | const Settings = require('../utils/Settings'); 5 | const Client = require('../utils/Client'); 6 | const availableStatuses = Settings.getSetting( __filename, 'statuses' ); 7 | const setStatusRegex = new RegExp( /^(!|\/)setstatus\s(.+)\s(\w+)/ ); 8 | const getStatusRegex = new RegExp( /^(!|\/)getstatus\s(.+)/ ); 9 | 10 | module.exports = [{ 11 | // !status - Returns the fromUser's status 12 | name: '!status', 13 | help: 'Returns the status of the user.', 14 | types: ['message'], 15 | regex: /^(!|\/)status/, 16 | action: function( chat, stanza ) { 17 | let statusObj = availableStatuses[ stanza.user.status.toLowerCase() ]; 18 | chat.sendMessage(`${stanza.user.username} is set to: ${ statusObj.title }`); 19 | } 20 | }, { 21 | name: '!getstatus {username}', 22 | help: 'Returns the status of the specified user.', 23 | types: ['message'], 24 | regex: getStatusRegex, 25 | action: function( chat, stanza ) { 26 | let match = getStatusRegex.exec( stanza.message ); 27 | let username = match[2]; 28 | if ( username.indexOf('@') === 0 ) { 29 | username = username.substr(1); 30 | } 31 | 32 | // Look up the user 33 | let users = runtime.brain.get( 'users' ) || {}; 34 | let user = users[ username ]; 35 | 36 | if ( !user ) { 37 | chat.sendMessage( `User '${username}' cannot be found.` ); 38 | return; 39 | } 40 | 41 | let statusObj = availableStatuses[ user.status.toLowerCase() ]; 42 | chat.sendMessage(`${username} is set to: ${ statusObj.title }`); 43 | } 44 | }, { 45 | name: '!setstatus {username} {status}', 46 | help: 'Sets the status of the specified user to the specified status.', 47 | types: ['message'], 48 | regex: setStatusRegex, 49 | action: function( chat, stanza ) { 50 | if ( stanza.user.isModerator() ) { 51 | let match = setStatusRegex.exec( stanza.message ); 52 | let statusToSet = match[3].toLowerCase(); 53 | let username = match[2]; 54 | if ( username.indexOf('@') === 0 ) { 55 | username = username.substr(1); 56 | } 57 | 58 | // Check if the status to set is an available status 59 | if ( !isAvailableStatus( statusToSet ) ) { 60 | chat.replyTo( username, `${ statusToSet } is not a valid status.` ); 61 | return; 62 | } 63 | 64 | // Set the status 65 | let user = Client.getUser( username ); 66 | user.status = statusToSet; 67 | user.saveToBrain(); 68 | 69 | let statusObj = availableStatuses[ statusToSet.toLowerCase() ]; 70 | chat.replyTo(username, `is now a ${statusObj.title}!` ); 71 | } 72 | } 73 | }]; 74 | 75 | /** 76 | * Returns a boolean if the passed-in status is 77 | * an available status. 78 | * @param {String} status 79 | * @return {Boolean} 80 | */ 81 | function isAvailableStatus( statusID ) { 82 | statusID = statusID.toLowerCase(); 83 | let statuses = Object.keys( availableStatuses ).map( ( statusKey ) => { 84 | return statusKey.toLowerCase(); 85 | } ); 86 | return statuses.indexOf( statusID ) >= 0; 87 | } 88 | -------------------------------------------------------------------------------- /utils/Gist.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let runtime = require('./Runtime'); 4 | const Log = require('./Log'); 5 | const request = require('request'); 6 | 7 | class Gist { 8 | static getGistLink( gistID ) { 9 | return `https://gist.github.com/${runtime.credentials.gistUsername}/${gistID}`; 10 | } 11 | 12 | static hasGistCredentials() { 13 | return runtime.credentials.gistUsername && runtime.credentials.gistAccessToken; 14 | } 15 | 16 | /** 17 | * Builds the request options object. 18 | * @return {object} 19 | */ 20 | static getRequestOptions() { 21 | return { 22 | url: 'https://api.github.com/gists', 23 | headers: { 24 | 'User-Agent' : 'Livecoding.tv Chat Bot' 25 | }, 26 | auth: { 27 | 'user' : runtime.credentials.gistUsername, 28 | 'pass' : runtime.credentials.gistAccessToken 29 | }, 30 | json: true, 31 | body: { 32 | description: 'LCTV Bot Gist', 33 | public: true, 34 | files: {} 35 | } 36 | }; 37 | } 38 | 39 | /** 40 | * Creates a new Gist 41 | * @param {String} title 42 | * @param {String} content 43 | * @param {Function} callback 44 | * @return {void} 45 | */ 46 | static createGist( title, content, callback ) { 47 | if ( !Gist.hasGistCredentials() ) { 48 | Log.log('[Gist] No Gist credentials, not creating a Gist!'); 49 | return; 50 | } 51 | 52 | let requestOpts = Gist.getRequestOptions(); 53 | requestOpts.body.files = { 54 | "lctv-bot-help.md": { 55 | content: content 56 | } 57 | }; 58 | 59 | request.post( requestOpts, ( err, response, body ) => { 60 | Gist.handleResponse( err, response, body, callback ); 61 | }); 62 | } 63 | 64 | /** 65 | * Updates an existing Gist 66 | * @param {String} gistID 67 | * @param {String} content 68 | * @param {Function} callback 69 | * @return {void} 70 | */ 71 | static updateGist( gistID, content, callback ) { 72 | if ( !Gist.hasGistCredentials() ) { 73 | Log.log('[Gist] No Gist credentials, not updating Gist!'); 74 | return; 75 | } 76 | 77 | let requestOpts = Gist.getRequestOptions(); 78 | requestOpts.url += '/' + gistID; 79 | requestOpts.body.files = { 80 | "lctv-bot-help.md": { 81 | content: content 82 | } 83 | }; 84 | 85 | request.patch( requestOpts, ( err, response, body ) => { 86 | Gist.handleResponse( err, response, body, callback ); 87 | }); 88 | } 89 | 90 | /** 91 | * Handles response from Gist server. 92 | * @param {?} err 93 | * @param {?} response 94 | * @param {object} body 95 | * @param {Function} callback 96 | * @return {void} 97 | */ 98 | static handleResponse( err, response, body, callback ) { 99 | if ( err ) { 100 | Log.log( 'Error creating Gist: ' + err ); 101 | } 102 | 103 | Log.log('[Gist] Gist post created', body.id); 104 | 105 | if ( callback ) { 106 | callback( body.id ); 107 | } 108 | } 109 | } 110 | 111 | module.exports = Gist; 112 | -------------------------------------------------------------------------------- /commands/todo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const runtime = require('../utils/Runtime'); 4 | const addRegex = new RegExp( /^(!|\/)todo\s(\-a)\s(.+)$/ ); 5 | const listRegex = new RegExp( /^(!|\/)todo$/ ); 6 | const removeRegex = new RegExp( /^(!|\/)todo\s(\-r)\s(\d{1})$/ ); 7 | const completeRegex = new RegExp( /^(!|\/)todo\s(\-c)\s(\d{1})$/ ); 8 | 9 | module.exports = [{ 10 | name: '!todo', 11 | help: 'List the current TODOs.', 12 | types: ['message'], 13 | regex: listRegex, 14 | action: function( chat, stanza ) { 15 | // LIST ITEMS 16 | let todos = runtime.brain.get('todos') || []; 17 | let completedTodos = runtime.brain.get('completedTodos') || []; 18 | let msg = ''; 19 | 20 | if ( todos.length === 0 ) { 21 | msg = 'No todos.\n\n' 22 | } else { 23 | msg = 'Todos:\n'; 24 | todos.forEach( function( todo, i ) { 25 | msg += (i + 1) + '. ' + todo + '\n'; 26 | } ); 27 | } 28 | 29 | if ( completedTodos.length > 0 ) { 30 | msg += 'Completed Todos:\n'; 31 | completedTodos.forEach( function( todo, i ) { 32 | msg += (i + 1) + '. ' + todo + '\n'; 33 | } ); 34 | } 35 | 36 | chat.sendMessage( msg ); 37 | } 38 | }, { 39 | name: '!todo -a {TODO item}', 40 | help: 'Adds specified item to the TODO list (Mod only).', 41 | types: ['message'], 42 | regex: addRegex, 43 | action: function( chat, stanza ) { 44 | // ADD ITEMS 45 | if ( stanza.user.isModerator() ) { 46 | let todos = runtime.brain.get('todos') || []; 47 | let item = addRegex.exec( stanza.message )[3]; 48 | 49 | todos.push( item ); 50 | runtime.brain.set('todos', todos); 51 | 52 | chat.sendMessage( 'Todo item added!' ); 53 | } 54 | } 55 | }, { 56 | name: '!todo -r {X}', 57 | help: 'Removes TODO item at index X from the TODO list (Mod only).', 58 | types: ['message'], 59 | regex: removeRegex, 60 | action: function( chat, stanza ) { 61 | // REMOVE ITEMS 62 | if ( stanza.user.isModerator() ) { 63 | let todos = runtime.brain.get('todos') || []; 64 | let todoIndex = parseInt( removeRegex.exec( stanza.message )[3], 10 ); 65 | let itemToRemove = todos[ todoIndex - 1 ]; 66 | 67 | if ( !itemToRemove ) { 68 | chat.sendMessage( 'Todo #' + todoIndex + ' not found!' ); 69 | return; 70 | } 71 | 72 | todos.splice( todoIndex - 1, 1 ); 73 | runtime.brain.set('todos', todos); 74 | 75 | chat.sendMessage( 'Removed Todo #' + todoIndex + '.' ); 76 | } 77 | } 78 | }, { 79 | name: '!todo -c {X}', 80 | help: 'Completes the TODO item at the specified index (Mod only).', 81 | types: ['message'], 82 | regex: completeRegex, 83 | action: function( chat, stanza ) { 84 | // COMPLETE ITEMS 85 | if ( stanza.user.isModerator() ) { 86 | let todos = runtime.brain.get('todos') || []; 87 | let completedTodos = runtime.brain.get('completedTodos') || []; 88 | let todoIndex = parseInt( completeRegex.exec( stanza.message )[3], 10 ); 89 | let itemToComplete = todos[ todoIndex - 1 ]; 90 | 91 | if ( !itemToComplete ) { 92 | chat.sendMessage( 'Todo #' + todoIndex + ' not found!' ); 93 | return; 94 | } 95 | 96 | // Save the completed item to the completed todos array 97 | completedTodos.push( itemToComplete ); 98 | runtime.brain.set('completedTodos', completedTodos); 99 | 100 | // Remove the completed item from the todos array 101 | todos.splice( todoIndex - 1, 1 ); 102 | runtime.brain.set('todos', todos); 103 | 104 | chat.sendMessage( 'Moved Todo #' + todoIndex + ' to the completed list.' ); 105 | } 106 | } 107 | }]; 108 | -------------------------------------------------------------------------------- /commands/help.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const runtime = require('../utils/Runtime'); 4 | const Log = require('../utils/Log'); 5 | const Gist = require('../utils/Gist'); 6 | 7 | module.exports = [{ 8 | types: ['startup'], 9 | action: function( chat ) { 10 | let helpObject = runtime.brain.get('help') || { 11 | id: '', 12 | documentation: '' 13 | }; 14 | 15 | // Generate help documentation 16 | let newDocumentation = generateHelpDocumentation(); 17 | 18 | // Compare new documentation agianst the brain's stored documentation 19 | if ( newDocumentation === helpObject.documentation ) { 20 | Log.log('[help] Help documentation unchanged, not creating a Gist.'); 21 | return; 22 | } 23 | 24 | if ( helpObject.id === '' ) { 25 | Log.log('[help] Creating new help documentation'); 26 | 27 | Gist.createGist( `${runtime.credentials.username} - LCTV Bot Help Documentation`, newDocumentation, (gistID) => { 28 | helpObject.id = gistID; 29 | helpObject.documentation = newDocumentation; 30 | runtime.brain.set( 'help', helpObject ); 31 | } ); 32 | } else { 33 | Log.log('[help] Updating existing help documentation'); 34 | 35 | Gist.updateGist( helpObject.id, newDocumentation, (gistID) => { 36 | helpObject.id = gistID; 37 | helpObject.documentation = newDocumentation; 38 | runtime.brain.set( 'help', helpObject ); 39 | } ); 40 | } 41 | } 42 | }, { 43 | name: '!help', 44 | help: 'Shows the documentation for core, plugins, and custom commands.', 45 | types: ['message'], 46 | regex: /^(!|\/)(help|commands)$/, 47 | action: function( chat, stanza ) { 48 | let helpObj = runtime.brain.get('help'); 49 | let output = 'No help documentation generated.'; 50 | 51 | if ( helpObj && helpObj.id ) { 52 | output = `Help documentation can be found at this Gist: ${ Gist.getGistLink(helpObj.id) }`; 53 | } 54 | chat.sendMessage( output ); 55 | } 56 | }]; 57 | 58 | /** 59 | * Generates the entire help documentation into a giant string. 60 | * @return {String} 61 | */ 62 | function generateHelpDocumentation() { 63 | Log.log('[help] Generating help documentation'); 64 | 65 | // Core commands 66 | let output = '## Core Commands\n'; 67 | 68 | runtime.coreCommands.message.forEach( ( command ) => { 69 | if ( command.name && command.help ) { 70 | output += `### ${command.name}\n`; 71 | output += command.help + '\n\n'; 72 | } 73 | } ); 74 | 75 | // Plugin commands 76 | output += '## Plugin Commands\n'; 77 | 78 | // Loop through the core message customCommands 79 | let pluginCommands = runtime.pluginCommands.message; 80 | if ( pluginCommands.length > 0 ) { 81 | pluginCommands.forEach( ( command ) => { 82 | if ( command.name && command.help ) { 83 | output += `### ${command.name}\n`; 84 | output += command.help + '\n\n'; 85 | } 86 | } ); 87 | } else { 88 | output += '\nNo plugin commands available.\n'; 89 | } 90 | 91 | // Custom commands 92 | output += '## Custom Commands\n'; 93 | 94 | // Get our custom commands 95 | let customCommands = runtime.brain.get('customCommands') || {}; 96 | let customCommandKeys = Object.keys( customCommands ); 97 | if ( customCommandKeys.length > 0 ) { 98 | customCommandKeys.forEach( ( command ) => { 99 | output += '\n\n!' + command; 100 | } ); 101 | } else { 102 | output += 'No custom commands available.'; 103 | } 104 | 105 | Log.log('[help] Help documentation generated'); 106 | 107 | return output; 108 | } 109 | -------------------------------------------------------------------------------- /utils/Websocket.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const WebSocketServer = require('websocket').server; 5 | const runtime = require('./Runtime'); 6 | const Log = require('./Log'); 7 | 8 | class Websocket { 9 | start( client ) { 10 | this.httpServer = null; 11 | this.wsServer = null; 12 | this.chat = client; 13 | this.connections = {}; 14 | 15 | this.startHttpServer( () => { 16 | this.startWebsocketServer(); 17 | }); 18 | } 19 | 20 | startHttpServer( callback ) { 21 | this.httpServer = http.createServer( (request, response) => { 22 | response.writeHead(404); 23 | response.end(); 24 | }); 25 | this.httpServer.listen(8881, () => { 26 | console.log((new Date()) + ' Server is listening on port 8881'); 27 | 28 | callback(); 29 | }); 30 | } 31 | 32 | startWebsocketServer() { 33 | this.wsServer = new WebSocketServer({ 34 | httpServer: this.httpServer 35 | }); 36 | 37 | this.wsServer.on('request', (request) => { 38 | let connection = request.accept('lctv-bot', request.origin); 39 | 40 | console.log(request.origin + ' connected'); 41 | 42 | connection.on('message', (message) => { 43 | this.onConnectionMessage( connection, message ); 44 | }); 45 | 46 | // Handle websocket connection closed 47 | connection.on('close', (e) => { 48 | this.onConnectionClose( connection ); 49 | }); 50 | }); 51 | } 52 | 53 | onConnectionMessage( connection, message ) { 54 | let messageObj = JSON.parse( message.utf8Data ); 55 | let username = messageObj.data; 56 | 57 | // Store the connection in the connections object 58 | if ( messageObj.message === 'subscribe' ) { 59 | console.log(username, 'subscribed to websocket connection'); 60 | 61 | this.connections[ username ] = connection; 62 | 63 | this.sendMessage( username, { 64 | message: 'clientFiles', 65 | files: runtime.pluginWebsocketFiles 66 | } ); 67 | } else { 68 | // Run any core websocket commands 69 | runtime.coreCommands.websocket.forEach( ( command ) => { 70 | this.runWebsocketCommand( command, messageObj ); 71 | } ); 72 | 73 | // Run any plugin websocket plugins 74 | runtime.pluginCommands.websocket.forEach( ( command ) => { 75 | this.runWebsocketCommand( command, messageObj ); 76 | } ); 77 | } 78 | } 79 | 80 | /** 81 | * Handles the closing of a connection 82 | * @param {obj} connection 83 | * @return {void} 84 | */ 85 | onConnectionClose( connection ) { 86 | console.log( "Connection disconnected"); 87 | connection = null; 88 | } 89 | 90 | /** 91 | * Sends a message on the socket for the specified username 92 | * @param {string} username 93 | * @param {obj} messageObj 94 | * @return void 95 | */ 96 | sendMessage( username, messageObj ) { 97 | // Find the right connection 98 | let connection = this.connections[ username ]; 99 | 100 | if ( connection ) { 101 | connection.sendUTF( JSON.stringify(messageObj) ); 102 | 103 | // Stop logging out giant base64 encoded images 104 | if ( messageObj.message === 'flyout' ) { 105 | messageObj = 'base64 encoded flyout image'; 106 | } 107 | 108 | if ( messageObj.message === 'clientFiles' ) { 109 | messageObj = 'plugin client files'; 110 | } 111 | 112 | console.log('WS message sent', username, messageObj); 113 | } 114 | } 115 | 116 | /** 117 | * Runs the passed-in command if the messageObj 118 | * passes the regex test. 119 | * @param {object} command 120 | * @param {object} messageObj 121 | * @return {void} 122 | */ 123 | runWebsocketCommand( command, messageObj ) { 124 | try { 125 | var regexMatched = command.regex && command.regex.test( messageObj.message ); 126 | if ( regexMatched ) { 127 | command.action( this.chat, messageObj ); 128 | } 129 | } catch ( e ) { 130 | Log.log('[Websocket] Run command error', e); 131 | } 132 | } 133 | } 134 | 135 | module.exports = new Websocket(); 136 | -------------------------------------------------------------------------------- /client/client.js: -------------------------------------------------------------------------------- 1 | jQuery(document).ready(function($) { 2 | // Declare variables 3 | var username = location.hash.substring( 1 ); 4 | var socket; 5 | var isPlaying = false; 6 | var handlers = {}; 7 | var dependencies = { 8 | jquery: true 9 | }; 10 | 11 | // Load default dependencies 12 | 13 | function connectToWebsocketServer() { 14 | console.log('Connecting to WS server'); 15 | 16 | socket = new WebSocket("ws://localhost:8881", 'lctv-bot'); 17 | 18 | /** 19 | * Opens the websocket connection. 20 | * Connects to the bot server and 21 | * sends a 'subscribe' message, and 22 | * a 'isPlaying' message for the YouTube player. 23 | * @return {void} 24 | */ 25 | socket.onopen = function() { 26 | console.log('Connected to WebSocket server. Sending subscription for ' + username); 27 | 28 | // subscribe to username's messages 29 | socket.send( JSON.stringify({ 30 | message: 'subscribe', 31 | data: username, 32 | }) ); 33 | socket.send( JSON.stringify({ 34 | message: 'isPlaying', 35 | data: isPlaying 36 | }) ); 37 | }; 38 | 39 | /** 40 | * Handles incoming websocket messages. 41 | * Runs the 'message' value through the registered 42 | * websocket handlers. If there is a match, 43 | * it calls the handler's function. 44 | * @param {string} message 45 | * @return {void} 46 | */ 47 | socket.onmessage = function(message) { 48 | var messageObj = JSON.parse( message.data ); 49 | 50 | // Call registered handlers, if exists 51 | var messageHandlers = handlers[messageObj.message]; 52 | if ( messageHandlers ) { 53 | messageHandlers.forEach( function(handler) { 54 | handler( messageObj ); 55 | }); 56 | } 57 | 58 | switch( messageObj.message ) { 59 | case 'clientFiles': 60 | var clientFiles = messageObj.files; 61 | clientFiles.forEach( function(clientFile) { 62 | var obj = eval( clientFile ); 63 | 64 | // Declare pluginSettings so they are passed-in 65 | // to eval(obj.func) 66 | var pluginSettings = obj.pluginSettings; 67 | 68 | // Load dependencies 69 | obj.dependencies.forEach( function( dependency ) { 70 | loadDependency( dependency.name, dependency.url ); 71 | } ); 72 | 73 | // Append the plugin's HTML to the DOM 74 | if ( $('#' + obj.name).length === 0 ) { 75 | var $html = $("
", { 76 | id: obj.name, 77 | class: 'plugin-module' 78 | }).html( obj.html ); 79 | $('body').append( $html ); 80 | 81 | // Run the plugin's function, 82 | // after a 3 second delay 83 | setTimeout(function() { 84 | eval( obj.func ); 85 | }, 3000); 86 | } 87 | 88 | }); 89 | break; 90 | } 91 | }; 92 | 93 | /** 94 | * Handles closing of the websocket connection. 95 | * Will try to reconnect every 5 seconds. 96 | * @param {object} message 97 | * @return {void} 98 | */ 99 | socket.onclose = function(message) { 100 | console.log('closed connection!'); 101 | 102 | // try to reconnect 103 | setTimeout( connectToWebsocketServer, 5000 ); 104 | }; 105 | 106 | /** 107 | * Handles a websocket error 108 | * @param {obj} err 109 | * @return {void} 110 | */ 111 | socket.onerror = function(err) { 112 | console.log('Websocket error', err); 113 | }; 114 | } 115 | connectToWebsocketServer(); 116 | 117 | /** 118 | * Registers the passed-in function as a message handler. 119 | * @param {String} messageName 120 | * @param {Function} func 121 | * @return {void} 122 | */ 123 | function registerSocketMessage( messageName, func ) { 124 | var messageHandlers = handlers[ messageName ] || []; 125 | messageHandlers.push( func ); 126 | handlers[ messageName ] = messageHandlers; 127 | } 128 | 129 | function loadDependency( name, url ) { 130 | if ( !dependencies[name] ) { 131 | var tag = $('