├── 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 = $('', {
132 | src: url
133 | });
134 | $('body').append( tag );
135 |
136 | dependencies[name] = true;
137 | }
138 | }
139 | });
140 |
--------------------------------------------------------------------------------
/utils/Loader.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const Log = require('./Log');
6 | const Settings = require('./Settings');
7 | const commandTypes = ['message', 'presence', 'startup', 'websocket'];
8 |
9 | class Loader {
10 | /**
11 | * Load the enabled commands.
12 | * @param {Function} callback
13 | * @return {void}
14 | */
15 | static loadCoreCommands( callback ) {
16 | // Make sure each command type is an array
17 | let coreCommands = {};
18 | commandTypes.forEach( (commandType) => {
19 | coreCommands[ commandType ] = [];
20 | } );
21 |
22 | // Load all files in the commands directory into an array
23 | const commandsDir = path.join( __dirname, '..', 'commands' );
24 | fs.readdir( commandsDir, function( err, files ) {
25 | if ( err ) {
26 | Log.log( 'ERROR: ' + err );
27 | return;
28 | }
29 |
30 | files.forEach( function(fileName) {
31 | if ( fileName.indexOf( '.js' ) >= 0 ) {
32 | // Check settings to see if command is enabled
33 | // If command is enabled, load the command
34 | let commandName = fileName.replace('.js', '');
35 | let isCommandEnabled = Settings.getSetting('coreCommands', commandName);
36 |
37 | if ( isCommandEnabled === true ) {
38 | Log.log( `[Loader] Command loaded: ${commandName}` );
39 |
40 | // Loop through each command so we can separate out
41 | // each command type to its own array.
42 | let filePath = path.join( commandsDir, fileName );
43 | let commands = require( filePath );
44 | commands.forEach( (command) => {
45 | Loader.parseCommandIntoMessageTypes( command, coreCommands );
46 | } );
47 | }
48 | }
49 | });
50 |
51 | callback( coreCommands );
52 | });
53 | }
54 |
55 | /**
56 | * Load the enabled plugins.
57 | * @param {Function} callback
58 | * @return {void}
59 | */
60 | static loadPluginCommands( callback ) {
61 | // Make sure each command type is an array
62 | let pluginClientFiles = [];
63 | let pluginCommands = {};
64 | commandTypes.forEach( (commandType) => {
65 | pluginCommands[ commandType ] = [];
66 | } );
67 |
68 | // Load all files in the commands directory into an array
69 | const pluginsDir = path.join( __dirname, '..', 'plugins' );
70 | fs.readdir( pluginsDir , function( err, folders ) {
71 | if ( err ) {
72 | Log.log( 'WARNING: No /plugins directory exists.' );
73 | callback( pluginCommands );
74 | return;
75 | }
76 |
77 | folders.forEach( ( pluginName ) => {
78 | let pluginIndexFile = path.join( pluginsDir, pluginName, 'index.js' );
79 | let pluginClientFile = path.join( pluginsDir, pluginName, 'client.js' );
80 |
81 | // Check settings to see if command is enabled
82 | // If command is enabled, load the command
83 | let isPluginEnabled = Settings.getSetting('plugins', pluginName);
84 | if ( isPluginEnabled === true ) {
85 | Log.log( `[Loader] Plugin loaded: ${pluginName}` );
86 |
87 | // Loop through each command so we can separate out
88 | // each command type to its own array.
89 | let commands = require( pluginIndexFile );
90 | commands.forEach( (command) => {
91 | Loader.parseCommandIntoMessageTypes( command, pluginCommands );
92 | } );
93 |
94 | // Load the client.js file, if it exists
95 | try {
96 | let clientJsFile = require( pluginClientFile );
97 | clientJsFile.func = 'var ' + clientJsFile.name + ' = ' + clientJsFile.func + '; ' + clientJsFile.name + '( socket, username, pluginSettings );';
98 | pluginClientFiles.push( clientJsFile );
99 | } catch( e ) {
100 | Log.log( `[Loader] No client.js file for plugin ${pluginName}` );
101 | }
102 | }
103 | });
104 |
105 | callback( pluginCommands, pluginClientFiles );
106 | });
107 | }
108 |
109 | static parseCommandIntoMessageTypes( command, commandObject ) {
110 | // Loop through each command so we can separate out
111 | // each command type to its own array.
112 | commandTypes.forEach( ( commandType ) => {
113 | if ( command.types.indexOf( commandType ) >= 0 ) {
114 | commandObject[ commandType ].push( command );
115 | }
116 | } );
117 | }
118 | }
119 |
120 | module.exports = Loader;
121 |
--------------------------------------------------------------------------------
/utils/ChatBot.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Client = require( './Client' );
4 | const Websocket = require('./Websocket');
5 | const Log = require('./Log');
6 | const Loader = require('./Loader');
7 | let runtime = require('./Runtime');
8 |
9 | class ChatBot {
10 | static start() {
11 | // Load core commands
12 | Loader.loadCoreCommands( ( coreCommands ) => {
13 | // coreCommands is returned as an object with
14 | // each message type as an array
15 | runtime.coreCommands = coreCommands;
16 |
17 | // Load plugin commands
18 | Loader.loadPluginCommands( ( pluginCommands, pluginWebsocketFiles ) => {
19 | runtime.pluginCommands = pluginCommands;
20 | runtime.pluginWebsocketFiles = pluginWebsocketFiles;
21 |
22 | // Load the client (connects to server)
23 | let chat = new Client( runtime.credentials );
24 |
25 | // Run any start up commands
26 | ChatBot.runStartupCommands( chat );
27 |
28 | // Start the websocket server
29 | Websocket.start( chat );
30 |
31 | // Start listening for stanzas
32 | ChatBot.listenForStanzas( chat );
33 | } );
34 | } );
35 | }
36 |
37 | /**
38 | * Run any of the 'startup' type commands
39 | * for both core and plugin commands.
40 | * @return {void}
41 | */
42 | static runStartupCommands( chat ) {
43 | // Loop through each startup core commands, and run the action
44 | runtime.coreCommands.startup.forEach( function( command ) {
45 | command.action( chat );
46 | });
47 |
48 | // Loop through each startup plugin commands, and run the action
49 | runtime.pluginCommands.startup.forEach( function( command ) {
50 | command.action( chat );
51 | });
52 | }
53 |
54 | /**
55 | * Listen for incoming stanzas and run
56 | * matching commands.
57 | * @param {Client} chat
58 | * @return {void}
59 | */
60 | static listenForStanzas( chat ) {
61 | // Listen for incoming stanzas
62 | chat.listen( function( stanza ) {
63 | // Skip the initial messages when starting the bot
64 | if ( ChatBot.isStartingUp() ) {
65 | return;
66 | }
67 |
68 | runtime.brain.start( __dirname + '/../brain' );
69 |
70 | // Grab the incoming stanza, and parse it
71 | let parsedStanza = Client.parseStanza( stanza, runtime.credentials );
72 | if ( !parsedStanza ) {
73 | return;
74 | }
75 | parsedStanza.ranCommand = false;
76 |
77 | // Run the incoming stanza against
78 | // the core commands for the stanza's type.
79 | let coreCommandsForStanzaType = runtime.coreCommands[ parsedStanza.type ];
80 | if ( coreCommandsForStanzaType ) {
81 | coreCommandsForStanzaType.forEach( ( command ) => {
82 | if ( ChatBot.runCommand( command, parsedStanza, chat ) ) {
83 | parsedStanza.ranCommand = true;
84 | }
85 | } );
86 | }
87 |
88 | // Run the incoming stanza against
89 | // the plugin commands for the stanza's type.
90 | let pluginCommandsForStanzaType = runtime.pluginCommands[ parsedStanza.type ];
91 | if ( pluginCommandsForStanzaType ) {
92 | pluginCommandsForStanzaType.forEach( ( command ) => {
93 | if ( ChatBot.runCommand( command, parsedStanza, chat ) ) {
94 | parsedStanza.ranCommand = true;
95 | }
96 | } );
97 | }
98 |
99 | // Update the user's message log
100 | Client.updateMessageLog( parsedStanza );
101 |
102 | Log.log( JSON.stringify( parsedStanza, null, 4 ) );
103 | } );
104 | }
105 |
106 | /**
107 | * Runs a passed-in command, if the regex matches
108 | * and the rateLimiting criteria matches.
109 | * @param {obj} command
110 | * @param {obj} parsedStanza
111 | * @param {Client} chat
112 | * @return {void}
113 | */
114 | static runCommand( command, parsedStanza, chat ) {
115 |
116 | try {
117 | var regexMatched = command.regex && command.regex.test( parsedStanza.message.toLowerCase() );
118 | var ignoreRateLimiting = command.ignoreRateLimiting;
119 | var passesRateLimiting = !parsedStanza.rateLimited || ( parsedStanza.rateLimited && ignoreRateLimiting );
120 |
121 | if ( regexMatched && passesRateLimiting ) {
122 | command.action( chat, parsedStanza );
123 |
124 | // If we are ignoring rate limiting,
125 | // don't say we ran a command.
126 | if ( !ignoreRateLimiting ) {
127 | return true;
128 | }
129 | }
130 | } catch ( e ) {
131 | Log.log( 'Command error: ', command, e );
132 | }
133 | }
134 |
135 | /**
136 | * Returns a boolean based on the startup state of the bot.
137 | * @return {Boolean}
138 | */
139 | static isStartingUp() {
140 | const messageTime = new Date().getTime();
141 | if ( messageTime - runtime.startUpTime < 10000 ) { // 10 seconds
142 | Log.log('Starting up, skipping message');
143 | return true;
144 | }
145 |
146 | return false;
147 | }
148 | }
149 |
150 | module.exports = ChatBot;
151 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # livecodingtv-bot
2 |
3 | # NO LONGER SUPPORTED
4 |
5 | ## BEFORE YOU BEGIN!
6 | ### This node app uses ES6 features.
7 | ### Please ensure you are running node v4 or greater!
8 |
9 | ## Changelog
10 |
11 | [https://github.com/owenconti/livecodingtv-bot/blob/master/changelog.md](https://github.com/owenconti/livecodingtv-bot/blob/master/changelog.md)
12 |
13 | ## Offical Plugins
14 | The list of official plugins for the bot can be found on this repo: [https://github.com/owenconti/livecodingtv-bot-plugins](https://github.com/owenconti/livecodingtv-bot-plugins)
15 |
16 | Instructions for writing your own plugins can also be found on the repo above.
17 |
18 | ## Setup
19 |
20 | 1) Clone the repo
21 |
22 | 2) Create a `setup/custom/credentials.js` file and `setup/custom/settings.json` file.
23 |
24 | 3) Find your XMPP password on LCTV page.
25 | 1. Open your live stream page ( https://www.livecoding.tv/USERNAME )
26 | 2. Open Dev Tools and switch to the Elements tab
27 | 3. Search the HTML content for "password".
28 | 4. The XMPP password will be a long string in an object containing 'jid' and 'password'.
29 |
30 | 4) Fill in the `setup/custom/credentials.js` file with the following format:
31 |
32 | ```
33 | var username = 'LCTV_BOT_USERNAME';
34 | var password = 'XMPP_BOT_PASSWORD'; // Found in step 3
35 | var room = 'LCTV_USER_WHERE_BOT_WILL_BE';
36 |
37 | module.exports = {
38 | room: username,
39 | username: username,
40 | jid: username + '@livecoding.tv',
41 | password: password,
42 | roomJid: room + '@chat.livecoding.tv'
43 | };
44 | ```
45 |
46 | 5) Fill `setup/custom/settings.json` with the following JSON data:
47 |
48 | ```
49 | {
50 | "plugins": {
51 | "noifications": true,
52 | "api-triggers": true
53 | }
54 | }
55 | ```
56 |
57 | 6) Run `npm install`
58 |
59 | 7) Run `node index.js`
60 |
61 | 8) Some commands require extra API keys or specific variables. You can use the `credentials.js` file to store these variables.
62 |
63 | ```
64 | module.exports = {
65 | // ...
66 | password: password,
67 | roomJid: room + '@chat.livecoding.tv',
68 | githubRepo: 'owenconti/livecodingtv-bot',
69 | youtubeApiKey: 'adfadsfsadfasdf'
70 | };
71 | ```
72 |
73 | 9) The bot should be running and waiting for your commands!
74 |
75 | ## Custom command credentials
76 |
77 | #### Github repo command
78 | - Github repo must be publicly available
79 | - Attribute in credentials.js: `githubRepo: 'owenconti/livecodingtv-bot'`
80 |
81 |
82 | ### Generating Help documentation
83 | Help documentation is generated via Gist. Please ensure you have the following variables setup in your credentials file before starting the bot:
84 |
85 | ```
86 | {
87 | "gistUsername" : "GIST_USERNAME",
88 | "gistAccessToken" : "GIST_ACCESS_TOKEN"
89 | }
90 | ```
91 |
92 | ## Custom settings
93 | To enable plugins once you've downloaded them, edit the `setup/custom/settings.json` file:
94 |
95 | ```
96 | {
97 | "plugins": {
98 | "noifications": true,
99 | "api-triggers": true
100 | }
101 | }
102 | ```
103 |
104 | ## Custom assets
105 | The core includes one asset, the `doge.png` image file. If you want to include more assets, place the files in the `setup/custom/assets` directory. Using the `Assets.load( filename.ext )` function, your custom asset will be loaded as base64 encoded string.
106 |
107 | ## Plugin settings
108 | Plugins can have their own settings. If a plugin chooses to have its own settings, the plugin folder will contain a `settings.json` file. You can edit any of the available settings inside that `settings.json` file.
109 |
110 | Where can I find plugins?
111 | Take a look at https://github.com/owenconti/livecodingtv-bot-plugins
112 |
113 | What you need for getting Plugins to work:
114 |
115 | 1) Download the plugin to the folder "plugins"
116 | 2) Add it to your settings.json in "setup/custom/" like that:
117 | ```
118 | "plugins" : {
119 | "PLUGIN-NAME": true
120 | }
121 | ```
122 | 3) Restart the bot
123 |
124 | ## Writing plugins
125 | Plugins can be composed of multiple commands. Commands can have four attributes:
126 |
127 | ```
128 | {
129 | types: ['message'],
130 | regex: /^test$/,
131 | help: 'Returns a confirmation if the `test` message was received.'
132 | action: function( chat, stanza ) {
133 | chat.sendMessage( 'Test received!' );
134 | }
135 | }
136 | ```
137 | * types
138 | * Types must be an array, and can contain multiple types.
139 | * Valid types are: `message` `presence` `startup` `websocket`
140 | * `message` types are parsed when an incoming chat message is received
141 | * `presence` types are parsed when an incoming presence (user joined/left) is received
142 | * `startup` types are parsed and ran during start up
143 | * `websocket` types are parsed when an incoming websocket message is received
144 | * regex
145 | * The `regex` attribute is used to determine the incoming stanza matches the command.
146 | * If a matched is determined, the action attribute of the command will be run.
147 | * `regex` must be a valid RegExp object (https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions)
148 | * help
149 | * The `help` attribute is used generate documentation for the `!help` command.
150 | * If no `help` attribute is set, no documentation will be generated for the command.
151 | * action
152 | * The `action` attribute is a function that is called if the `regex` for the command matches.
153 | * The logic for the command should reside inside the `action` attribute.
154 | * `action` is passed 2 parameters:
155 | 1. `chat` - an instance of the Client object
156 | 2. `stanza` - the parsed stanza containing:
157 | ```
158 | {
159 | user: User object,
160 | message: message string,
161 | type: type of stanza (message or presence)
162 | rateLimited: boolean to determine if the user is rateLimited
163 | }
164 | ```
165 | * The `stanza` parameter is not passed to `startup` commands.
166 |
167 |
168 | See the examples directory for an example of creating a plugin.
169 |
--------------------------------------------------------------------------------
/commands/ban-management.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const xmpp = require('node-xmpp-client');
4 | const Assets = require('../utils/Assets');
5 | const Websocket = require('../utils/Websocket');
6 | const Say = require('../utils/Say');
7 | const Log = require('../utils/Log');
8 | const Client = require('../utils/Client');
9 | const Settings = require('../utils/Settings');
10 | const Templater = require('../utils/Templater');
11 |
12 | const unbanRegex = new RegExp( /^(!|\/)unban\s(\w+)$/ );
13 | const banRegex = new RegExp( /^(!|\/)ban\s(\w+)$/ );
14 |
15 | class BanManagement {
16 | /**
17 | * Returns the commands this class exports.
18 | * @return {array}
19 | */
20 | static getCommands() {
21 | return [{
22 | // Auto ban users flood messages to the chat
23 | types: ['message'],
24 | regex: /./,
25 | ignoreRateLimiting: true,
26 | action: function( chat, stanza ) {
27 | const numberOfMessagesAllowed = 5;
28 | const timeframeAllowed = 10; // seconds
29 |
30 | // Never auto ban the streamer or the bot
31 | if ( stanza.user.isStreamer() || stanza.user.isBot() ) {
32 | return;
33 | }
34 |
35 | let userMessageLog = stanza.user.getMessages();
36 | if ( userMessageLog ) {
37 | let messages = userMessageLog.messages;
38 | // If the user has sent at least the number of messages allowed
39 | if ( messages.length > numberOfMessagesAllowed ) {
40 | const now = new Date().getTime();
41 |
42 | let startOfLimitMessage = messages[ messages.length - numberOfMessagesAllowed ];
43 |
44 | // If the number of allowed messages sent by the user (ie: 5th)
45 | // was sent within the last X seconds (timeframeAllowed),
46 | // ban the user.
47 | if ( now - startOfLimitMessage.time < ( timeframeAllowed * 1000 ) ) {
48 | BanManagement.banUser( stanza.user.username, chat );
49 | }
50 | }
51 | }
52 |
53 | }
54 | }, {
55 | name: '!unban',
56 | help: 'Unbans the specified user.',
57 | types: ['message'],
58 | regex: unbanRegex,
59 | action: function( chat, stanza ) {
60 | if ( stanza.user.isModerator() ) {
61 | let match = unbanRegex.exec( stanza.message );
62 | let userToUnban = match[2];
63 |
64 | let affiliationStanza = BanManagement.getUserAffiliationStanza( chat.credentials, userToUnban, 'none' );
65 | chat.client.send( affiliationStanza );
66 |
67 | chat.sendMessage( '@' + userToUnban + ' has been unbanned!' );
68 | }
69 | }
70 | }, {
71 | name: '!ban',
72 | help: 'Bans the specified user.',
73 | types: ['message'],
74 | regex: banRegex,
75 | action: function( chat, stanza ) {
76 | if ( stanza.user.isModerator() ) {
77 | let match = banRegex.exec( stanza.message );
78 | let userToBan = match[2];
79 |
80 | BanManagement.banUser( userToBan, chat );
81 | }
82 | }
83 | }];
84 | }
85 |
86 | /**
87 | * Sends a ban stanza
88 | * @param {string} username
89 | * @param {object} chat
90 | * @return {void}
91 | */
92 | static banUser( username, chat ) {
93 | // Don't try to ban the streamer or the bot
94 | let user = Client.getUser( username );
95 | if ( user.isStreamer() || user.isBot() ) {
96 | Log.log('Attempt to ban streamer or bot.' );
97 | return false;
98 | }
99 |
100 | let affiliationStanza = BanManagement.getUserAffiliationStanza( chat.credentials, username, 'outcast' );
101 | chat.client.send( affiliationStanza );
102 |
103 | // Display the ban police flyout
104 | Assets.load( 'ban-police.png', function(base64Image) {
105 | let htmlContent = `
106 |
${ username }
120 |

121 |
`;
122 |
123 | Websocket.sendMessage( chat.credentials.room, {
124 | message: 'flyout',
125 | type: 'div',
126 | content: htmlContent,
127 | duration: 6000,
128 | animation: 'flyLeft'
129 | });
130 |
131 | let banSayMessage = Settings.getSetting('ban-management', 'banSayMessage');
132 | let message = Templater.run( banSayMessage, {
133 | username: username
134 | } );
135 | Say.say( message, 'Daniel' );
136 | });
137 | }
138 |
139 | /**
140 | * Returns a stanza to send to the server.
141 | * @param {object} credentials
142 | * @param {string} nickname
143 | * @param {string} affiliation
144 | * @return {Stanza}
145 | */
146 | static getUserAffiliationStanza( credentials, nickname, affiliation ) {
147 | let stanza = new xmpp.Stanza('iq', {
148 | from: credentials.jid,
149 | id: '12:sendIQ',
150 | to: credentials.roomJid,
151 | type: 'set',
152 | xmlns: 'jabber:client'
153 | })
154 | .c('query', { xmlns: 'http://jabber.org/protocol/muc#admin' })
155 | .c('item', { affiliation: affiliation, jid: nickname + '@livecoding.tv' });
156 |
157 | return stanza;
158 | }
159 | }
160 |
161 | module.exports = BanManagement.getCommands();
162 |
--------------------------------------------------------------------------------
/changelog.md:
--------------------------------------------------------------------------------
1 | Starting to keep track of changes as of October 28, 2015. I will try my best to keep this file updated.
2 |
3 | You can also browse the commit history to track my commits: [https://github.com/owenconti/livecodingtv-bot/commits/master](https://github.com/owenconti/livecodingtv-bot/commits/master)
4 |
5 | ## Jan 12 2016
6 | * Possibly fixed issue with bot disconnecting after a certain period of time without sending a message to the server.
7 | * Updated default enabled commands to disable `unavailable` and `greeting` command.
8 | * If you want to use either of these commands, please make sure you updated your `setup/custom/settings.json` file.
9 | * No longer greet the bot or the streamer in the `greeting` command.
10 | * Replaced Pastebin with Gist for help documentation and youtube playlist track output.
11 | * **Required update**
12 | * Delete your `help` brain file before starting up.
13 | * Update the `youtube` plugin to latest from repo, if you use that command.
14 | * Setup your Gist/Github access token in `credentials.js`
15 | ```
16 | {
17 | "gistUsername" : "owenconti",
18 | "gistAccessToken" : "ajskfnasdifhj98y3129uncawdf"
19 | }
20 | ```
21 |
22 | ## Nov 7 2015
23 | * Added a setting for the `unavailable` command to enable/disable the command.
24 | * Added a `setsubject` command, to set the room's subject via XMPP.
25 | * Added the ability to pass a settings object to the `client.js` code of plugins.
26 | * Updated `help` command to output a link to Pastebin when called.
27 | * When the bot starts, it will generate and store the documentation for help in a Paste on pastebin.com.
28 | * Greetings are now run through a templater with `username` and `status` variables being passed-in.
29 |
30 | ## Nov 5 2015
31 | * Say command messages limit increased to 80 characters.
32 | * Added a Say message to the ban command.
33 | * Decreased ban flyout duration to 6 seconds from 10 seconds.
34 | * Case-insensitive command matching (assuming all commands register their regex in lowercase).
35 | * Added setting to customize the on-ban say message.
36 | * Added setting to enable/disable the Say utility.
37 | * Added the loadUrl function to the Assets utility class.
38 |
39 | ## Nov 4 2015
40 | * Updated ban management to display the ban police image when a user is banned.
41 |
42 | ## Nov 2 2015
43 | * Replaced node-xmpp with node-xmpp-client.
44 | * **Required update**
45 | * You must run `npm install` after pulling this change.
46 | * Hopeful fix for the brain to stop it from erasing files when an error occurs.
47 | * Removed public.html and replaced with the `/client` folder.
48 | * To run the new client page:
49 | * Make sure the bot is running
50 | * Open a new terminal window and navigate to `/client`
51 | * Run `python -m SimpleHTTPServer {PORT}`
52 | * Navgiate to `localhost:{PORT}/#{USERNAME}`
53 | * The websocket connection should happen, any plugin code should be loaded, and then the client page should be ready to go.
54 | * Setup plugins to register themselves for the client page.
55 | * Moved playlist.js command to a `youtube` plugin.
56 | * **Required update**
57 | * If you used the playlist.js command, you will need to install the plugin to use it.
58 | * Ensure you have a `settings.json` file in the `youtube` folder. The structure should be:
59 | ```
60 | {
61 | "youtubeApiKey" : "aisjdoi12masdasd",
62 | "requiredVotesToSkip" : 3
63 | }
64 | ```
65 | * Removed the doge flyout from the `say` command. Will come back soon as part of the flyouts plugin.
66 |
67 | ## Oct 31 2015
68 | * Fixed error with Client.js getUser() where it would crash when no `users` brain file existed.
69 |
70 | ## Oct 29 2015
71 | * Updated pattern for `Templater` class to use '{{VAR}}' instead of '${VAR}'.
72 | * **Required update**
73 | * If you are using the `Templater` class, make sure your template strings use the '{{VAR}}' pattern.
74 | * Removed the `upcoming` playlist command
75 | * Updated `skip` playlist command to be random
76 | * Fixed auto ban command
77 | * Changed how user messages are stored.
78 | * **Required update**
79 | * If you depended on userMessages, you should clear your userMessages brain file because the structure has changed.
80 | * Assets now load custom assets, and then core assets.
81 | * Rearranged setup folder so it makes more sense.
82 | * Updated the Loader class to read settings files to determine which commands and plugins it should load.
83 | * **Required update**
84 | * By default, all core commands are enabled.
85 | * By default, all plugins are disabled.
86 | * You must add a `coreCommands` and `plugins` setting to your `setup/custom/settings.json` file if you want to enable or disable commands and plugins.
87 | * Fixed bug with `greeting` command where an existing user with a status that did not have a matching greeting, was not greeted.
88 | * **Required update**
89 | * If you had any custom greetings, you will need to update to the new greeting structure. See `setup/core/settings.json` for an example.
90 |
91 | ### New Format
92 |
93 | * Updated Loader to allow bot to start without a `/plugins` directory.
94 | * Verifying credentials.js includes the required attributes to start up when starting the bot.
95 | * Moved a couple classes into the `utils` directory.
96 | * If you required any of the following files, you will need to update your references:
97 | * `ChatBot.js`
98 | * `Client.js`
99 | * `Loader.js`
100 | * `websocket.js`
101 | * Created a `Settings.js` utility file, to easily load settings.
102 | * Created a `setup` directory, and started a `defaultSettings.json` file to keep default settings.
103 | * Moved `credentials.js` into the `setup` directory.
104 | * If you required `credentials.js`, you will need to update your references.
105 | * Added available statuses to the default settings
106 | * Added `hasStatus` to User model to easily check for a specific status
107 | * Updated various commands to pull from default settings file
108 | * Added `Templater` class, to build strings from a template
109 | * Added `Assets` class, to load assets into the bot
110 | * Updated say command to display a popout doge when !say is used
111 |
--------------------------------------------------------------------------------
/utils/Client.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const xmpp = require('node-xmpp-client');
4 | const crypto = require('crypto');
5 | const Log = require('./Log');
6 | const runtime = require('./Runtime');
7 | const User = require('../model/User');
8 |
9 | class Client {
10 | /**
11 | * Connect to a room
12 | * @param {object} credentials
13 | * @return {void}
14 | */
15 | constructor( credentials ) {
16 | this.credentials = credentials;
17 |
18 | // Connect to the server
19 | this.client = new xmpp.Client({
20 | jid: this.credentials.jid,
21 | password: this.credentials.password
22 | });
23 |
24 | this.client.on('error', function(err) {
25 | Log.log('CLIENT ERROR: ', err);
26 | });
27 |
28 | // Once online, send presence to the room
29 | this.client.on('online', function( resp ) {
30 | Log.log( 'Connected to server' );
31 |
32 | this.sendPresence();
33 | setInterval(this.sendPresence.bind(this), 1000 * 60);
34 | }.bind( this ) );
35 | }
36 |
37 | /**
38 | * Sends the bot's presence to the room specified.
39 | * @return {void}
40 | */
41 | sendPresence() {
42 | Log.log('Sending presence to server');
43 | this.client.send(
44 | new xmpp.Stanza('presence', {
45 | to: this.credentials.roomJid + '/' + this.credentials.username
46 | })
47 | );
48 | }
49 |
50 | /**
51 | * Sends a message to the specified room.
52 | * @param {string} msg
53 | * @param {string} room
54 | * @return {void}
55 | */
56 | sendMessage( msg ) {
57 | if ( runtime.debug ) {
58 | Log.log('DEBUGGING: ' + msg);
59 | return false;
60 | }
61 |
62 | // Get the previously sent messages
63 | let messages = runtime.brain.get('messages') || {};
64 |
65 | // Hash the message and use it as our key.
66 | // Grab the previous message that uses the same hash.
67 | // (ie: the message text is the same).
68 | // Build the new message object.
69 | let hash = crypto.createHash('md5').update( msg ).digest('hex');
70 | let previousMessage = messages[ hash ];
71 | let messageObj = {
72 | message: msg,
73 | time: new Date().getTime()
74 | };
75 |
76 | // Compare the previous message time vs the current message time
77 | // Only send the message to the server, if the difference is > 5 seconds
78 | if ( !previousMessage || messageObj.time - previousMessage.time > 5000 ) { // 5 seconds
79 | this.client.send(
80 | new xmpp.Stanza('message', {
81 | to: this.credentials.roomJid,
82 | type: 'groupchat'
83 | })
84 | .c('body')
85 | .t( msg )
86 | );
87 | } else {
88 | Log.log( 'Skipping sendMessage - previous message sent within 5 seconds' );
89 | }
90 |
91 | // Save the message to the messages store
92 | messages[ hash ] = messageObj;
93 | runtime.brain.set( 'messages', messages );
94 | }
95 |
96 | /**
97 | * Replies to the specified user.
98 | * @param {string} username
99 | * @param {string} msg
100 | * @return {void}
101 | */
102 | replyTo( username, msg ) {
103 | this.sendMessage( '@' + username + ': ' + msg );
104 | }
105 |
106 | /**
107 | * Listens for messages and calls the passed-in callback.
108 | * Called from ChatBot.js
109 | * @param {function} action
110 | * @return {void}
111 | */
112 | listen( action ) {
113 | this.client.on('stanza', function( stanza ) {
114 | action( stanza );
115 | });
116 | }
117 |
118 | /**
119 | * Returns the user based on the specified username.
120 | * @param {string} username
121 | * @return {object}
122 | */
123 | static getUser( username ) {
124 | const users = runtime.brain.get( 'users' ) || {};
125 | let userObj = users[ username ];
126 |
127 | if ( !userObj ) {
128 | // If the user joined the channel for the first time,
129 | // while the bot was not connected, the user will not
130 | // have an entry in the 'users' brain.
131 | // Create the entry for the user here
132 | userObj = {
133 | username: username,
134 | count: 1,
135 | time: new Date().getTime(),
136 | role: 'participant',
137 | status: 'Viewer'
138 | };
139 | users[ username ] = userObj;
140 | runtime.brain.set( 'users', users );
141 | }
142 |
143 | return new User( userObj );
144 | }
145 |
146 | /**
147 | * Parses a stanza from the server
148 | * @param {Stanza} stanza
149 | * @param {obj} credentials
150 | * @return {obj}
151 | */
152 | static parseStanza( stanza, credentials ) {
153 | let type = stanza.name;
154 |
155 | switch( type ) {
156 | case 'message':
157 | return Client.parseMessage( stanza, credentials );
158 | case 'presence':
159 | return Client.parsePresence( stanza, credentials );
160 | }
161 | }
162 |
163 | /**
164 | * Parses the passed-in 'message' stanza.
165 | * @param {Stanza} stanza
166 | * @param {obj} credentials
167 | * @return {obj}
168 | */
169 | static parseMessage( stanza, credentials ) {
170 | let type = 'message';
171 | let rateLimited = false;
172 | let jid = stanza.attrs.from;
173 | let username = jid.substr( jid.indexOf( '/' ) + 1 );
174 | let body = Client.findChild( 'body', stanza.children );
175 |
176 | if ( !body ) {
177 | return false;
178 | }
179 |
180 | let message = body.children.join('').replace('\\', '');
181 |
182 | // Rate limiting
183 | const now = new Date().getTime();
184 | let messages = runtime.brain.get( 'userMessages' ) || {};
185 | let userMessageLog = messages[ username ];
186 |
187 | // Don't rate limit the bot
188 | if ( username !== credentials.username && userMessageLog ) {
189 | let lastCommandTimeExists = userMessageLog.lastCommandTime > 0;
190 |
191 | if ( lastCommandTimeExists && now - userMessageLog.lastCommandTime < 3000 ) { // 3 seconds
192 | rateLimited = true;
193 | }
194 | }
195 |
196 | let user = Client.getUser( username );
197 |
198 | // Return the parsed message
199 | return { type, user, message, rateLimited };
200 | }
201 |
202 | /**
203 | * Parses the passed-in 'presence' stanza.
204 | * @param {Stanza} stanza
205 | * @param {obj} credentials
206 | * @return {obj}
207 | */
208 | static parsePresence( stanza, credentials) {
209 | let type = 'presence';
210 | let jid = stanza.attrs.from;
211 | let username = jid.substr( jid.indexOf( '/' ) + 1 );
212 | let message = stanza.attrs.type || 'available';
213 |
214 | // Find role
215 | let xObj = Client.findChild( 'x', stanza.children );
216 | let itemObj = Client.findChild( 'item', xObj.children );
217 | let role = itemObj.attrs.role;
218 |
219 | // Store new users in the 'users' brain object
220 | let user = Client.getUser( username );
221 |
222 | // Update the user's view count and presence time
223 | // only if their count hasn't been updated in
224 | // the last 5 minutes.
225 | const now = new Date().getTime();
226 | const minutes = 5;
227 | if ( now - user.lastVisitTime > 1000 * 60 * minutes ) {
228 | user.viewCount++;
229 | user.lastVisitTime = now;
230 | }
231 |
232 | // If presence is unavailable,
233 | // return without storing user object
234 | if ( message === 'unavailable' ) {
235 | return { type, user, message, role };
236 | }
237 |
238 | user.saveToBrain();
239 |
240 | return { type, user, message, role };
241 | }
242 |
243 | /**
244 | * Records the user's message in the message log
245 | * @param {Stanza} stanza
246 | * @return {void}
247 | */
248 | static updateMessageLog( parsedStanza ) {
249 | const now = new Date().getTime();
250 | let messages = runtime.brain.get( 'userMessages' ) || {};
251 | let userMessageLog = messages[ parsedStanza.user.username ] || {
252 | messages: [],
253 | lastCommandTime: 0
254 | };
255 |
256 | userMessageLog.messages.push( {
257 | message: parsedStanza.message,
258 | time: now
259 | } );
260 |
261 | // If the user ran a command, track the time
262 | if ( parsedStanza.ranCommand ) {
263 | userMessageLog.lastCommandTime = now;
264 | }
265 |
266 | messages[ parsedStanza.user.username ] = userMessageLog;
267 |
268 | runtime.brain.set( 'userMessages', messages );
269 | }
270 |
271 | /**
272 | * Child a child based on the 'name' property
273 | * @param {[type]} name [description]
274 | * @param {[type]} children [description]
275 | * @return {[type]} [description]
276 | */
277 | static findChild( name, children ) {
278 | let result = null;
279 | for ( let index in children ) {
280 | let child = children[ index ];
281 | if ( child.name === name ) {
282 | result = child;
283 | break;
284 | }
285 | }
286 | return result;
287 | }
288 | }
289 |
290 | module.exports = Client;
291 |
--------------------------------------------------------------------------------