├── LICENSE ├── README.md ├── fileTriggers.txt ├── index.html ├── js ├── Documentation.md ├── action │ └── actionHandler.js ├── api │ └── apiHandler.js ├── chat │ └── chatHandler.js ├── controller.js ├── cooldown │ └── cooldownHandler.js ├── debug │ └── debugHandler.js ├── discord │ └── discordHandler.js ├── handler.js ├── index.js ├── list │ └── listHandler.js ├── message │ └── messageHandler.js ├── mqtt │ ├── mqtt-websocket.js │ └── mqttHandler.js ├── obs │ ├── obs-websocket.js │ ├── obs-websocket.js.map │ ├── obs-wrapper.js │ └── obsHandler.js ├── param │ └── paramHandler.js ├── parser.js ├── random │ └── randomHandler.js ├── slobs │ ├── slobs-websocket.js │ └── slobsHandler.js ├── streamElementsAlert │ ├── streamElements-socket.js │ └── streamElementsAlertHandler.js ├── streamlabs │ ├── streamlabs-socket.js │ └── streamlabsHandler.js ├── timer │ └── timerHandler.js ├── tts │ └── ttsHandler.js ├── twitch │ ├── twitch-api.js │ ├── twitch-eventsub-handler.js │ ├── twitch-pub-socket.js │ └── twitchHandler.js ├── utils │ ├── async.js │ ├── idb-service.js │ ├── shlex.js │ ├── storage-emitter.js │ └── utils.js ├── variable │ └── variableHandler.js └── voicemod │ ├── voicemod-socket.js │ └── voicemodHandler.js ├── logo.png ├── settings ├── Settings.md ├── chat │ ├── channel.txt │ └── code.txt ├── mqtt │ ├── password.txt │ ├── username.txt │ └── websocket.txt ├── obs │ ├── address.txt │ └── password.txt ├── slobs │ └── token.txt ├── streamelements │ └── jwtToken.txt ├── streamlabs │ └── socketAPIToken.txt ├── twitch │ ├── clientId.txt │ ├── clientSecret.txt │ ├── code.txt │ └── user.txt ├── variable │ └── autoload.txt └── voicemod │ ├── address.txt │ └── apiKey.txt ├── sounds └── MashiahMusic__Kygo-Style-Melody.wav ├── triggers.txt ├── triggers └── sample.txt └── version.txt /LICENSE: -------------------------------------------------------------------------------- 1 | This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivs 4.0 Generic License. To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-nd/4.0/ or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kruiz Control 2 | 3 |

4 | 5 |

6 | 7 |

8 | Kruiz Control enables a pseudo code approach to manage and automatically handle Twitch chat and events, OBS or SLOBS, and StreamElements or Streamlabs alerts. 9 |

10 |

11 | Tutorial | 12 | Download | 13 | Documentation | 14 | Settings 15 |

16 | 17 |

18 | @Kruiser8 | 19 | Trello (Roadmap) | 20 | Patreon | 21 | Support Discord 22 |

23 | 24 | ## Table of Contents 25 | 26 | - [Setup Guide](#setup-guide) 27 | - [Compatibility](#compatibility) 28 | - [Installation](#installation) 29 | + [Settings](#settings) 30 | + [Add as Browser Source](#add-as-browser-source) 31 | + [OBS Websocket](#obs-websocket) 32 | - [Usage](#usage) 33 | + [Pseudo Code Format](#pseudo-code-format) 34 | + [triggers.txt](#triggerstxt) 35 | + [fileTriggers.txt and the triggers folder](#filetriggerstxt-and-the-triggers-folder) 36 | + [sounds folder](#sounds-folder) 37 | - [FAQ](#faq) 38 | + [XSplit Support](#xsplit-support) 39 | + [Support for Youtube Alerts](#support-for-youtube-alerts) 40 | + [Support for Youtube Chat](#support-for-youtube-chat) 41 | + [Will you support X](#will-you-support-x) 42 | - [Support the Project](#support-the-project) 43 | - [Associated Projects](#associated-projects) 44 | - [Credits](#credits) 45 | 46 | *** 47 | 48 | ## Setup Guide 49 | 50 | - Add the **index.html** to OBS or SLOBS as a browser source. 51 | - Fill out [the settings files](settings/Settings.md) in the settings folder. 52 | - Type `!example` in your twitch chat. If your user responds with `Success! It worked!`, you're good to go! 53 | - Customize the _triggers.txt_ with your own triggers from the [the documentation](js/Documentation.md). 54 | 55 | _Note: If you're on OBS v27 or lower, you'll also have to install the [OBS Websocket Plugin](https://github.com/Palakis/obs-websocket/releases/latest). Reopen OBS after installing._ 56 | 57 | *** 58 | 59 | ## Compatibility 60 | 61 | Kruiz Control supports 62 | - Twitch Channel Management 63 | - Twitch Chat 64 | - Twitch Events (alerts, channel points, hype trains) 65 | - Streamlabs Alerts 66 | - StreamElements Alerts 67 | - OBS scene, source, and filter changes 68 | - SLOBS scene and source changes 69 | - Voicemod Voice Changer control 70 | - Playing music (mp3, wav, ogg) 71 | - Timers (triggering on an interval) 72 | - Sending API calls 73 | 74 | and more in [the documentation](js/Documentation.md)! 75 | 76 | The script should run on any broadcast software that supports browser sources, however only OBS and SLOBS support changing scenes and sources. OBS.Live is supported. 77 | 78 | *** 79 | 80 | ## Installation 81 | 82 | ### Add as Browser Source 83 | Add the **index.html** file as a browser source within your broadcast software. It is *recommended* to add this source to one scene that is included in all other scenes (like your alert scene) rather than recreate this source in every scene. 84 | 85 | ### Settings 86 | Before the script will work, you'll need to fill out all of the settings files. Please see the [settings description](settings/Settings.md) for more information. 87 | 88 | #### Steps for adding to OBS/SLOBS 89 | - In OBS, under **Sources** click the + icon to add a new **Browser** source. 90 | - Name it and select OK. 91 | - Check the `Local file` checkbox. 92 | - Click **Browse** and open the **index.html** file within the Kruiz Control script directory. 93 | - Recommended to set the width/height to 100 or less to reduce the size of the source. 94 | 95 | ### OBS Websocket 96 | In OBS, click **Tools** > **WebSockets Server Settings** and enable the websocket server. 97 | 98 | It is **highly recommended** to use a password! 99 | 100 | *** 101 | 102 | ## Usage 103 | 104 | ### Pseudo Code Format 105 | For information on the pseudo code format, please see [the documentation](js/Documentation.md). 106 | 107 | ### triggers.txt 108 | Setup your triggers inside of this file if you do not need actions to be run one after another. 109 | 110 | As an example, if the below is in the _triggers.txt_ file, then both sounds can be played at the same time. 111 | 112 | #### triggers.txt 113 | ``` 114 | OnTWChannelPoint SHIKAKA 115 | Play 30 wait Shikaka.mp3 116 | 117 | OnCommand sbvm 0 !intervention 118 | Play 45 nowait MashiahMusic__Kygo-Style-Melody.wav 119 | ``` 120 | 121 | ### fileTriggers.txt and the triggers folder 122 | When you need actions to be run one-after-another, create a file in the _triggers_ folder and add the name of the folder to _fileTriggers.txt_. 123 | 124 | As an example, here's a setup to make sure multiple scene changes don't happen simultaneously. 125 | 126 | #### _fileTriggers.txt_ 127 | ``` 128 | obs.txt 129 | ``` 130 | #### _triggers/obs.txt_ 131 | ```m 132 | OnSLDonation 133 | OBS Scene DonationCelebration 134 | Delay 4 135 | 136 | OnCommand mb 0 !brb 137 | OBS Scene BRB 138 | Delay 5 139 | ``` 140 | 141 | ### sounds folder 142 | In order to use a sound with [`Play`](js/Documentation.md#play), add the sound file to the *sounds* folder. The supported audio formats are mp3, wav, and ogg. 143 | 144 | *** 145 | 146 | ## FAQ 147 | 148 | ### XSplit Support 149 | The script should work with XSplit _BUT_ the OBS-like functionality will not work. XSplit does not provide a direct websocket interface to do such actions. It may be possible to implement a plugin that provides a websocket interface to connect with xsplit. If you know a way to achieve this, [please reach out](mailto:kruiser.twitch@gmail.com). 150 | 151 | ### Support for Youtube Alerts 152 | I can definitely add support for Youtube alerts from Streamlabs. I just haven't had the time to implement it yet. 153 | 154 | More investigation time is needed to implement Youtube with StreamElements. 155 | 156 | ### Support for Youtube Chat 157 | Potentially if I can find the libraries to implement it. Again, if you know a way to achieve this, please reach out via [twitter](https://twitter.com/kruiser8) or [discord](https://discord.gg/wU3ZK3Q). 158 | 159 | ### Will you support X 160 | Please reach out if you have any ideas or other questions that were not covered in the documentation. 161 | 162 | Discord: [Kruiz Control Support Discord](https://discord.gg/wU3ZK3Q) 163 | 164 | Twitter: [@Kruiser8](https://twitter.com/kruiser8) 165 | 166 | *** 167 | 168 | ## Support the Project 169 | There are a number of ways to support this project. 170 | 171 | - Support Kruiser through Patreon. 172 | - Translate the documentation. 173 | - Help others in the Support Discord. 174 | - Contribute ideas for the roadmap. 175 | - Spread the word! 176 | 177 | I do take commissions to implement custom functionality when necessary. Please reach out if you have a specific request. 178 | 179 | *** 180 | 181 | ## Associated Projects 182 | 183 | - Kruiz Control Documentation. 184 | - Kruiz Control Widget Template. 185 | - Kruiz Control Configurator by CrashKoeck. 186 | 187 | *** 188 | 189 | ## Credits 190 | - [async](https://github.com/caolan/async) by Caolan McMahon (caolan) 191 | - [comfyjs](https://github.com/instafluff/ComfyJS) by Instafluff (instafluff) 192 | - [node-shlex](https://github.com/rgov/node-shlex) by Ryan Govostes (rgov) 193 | - [obs-websocket-js](https://github.com/haganbmj/obs-websocket-js) by Brendan Hagan (haganbmj) 194 | - [tesjs](https://github.com/mitchwadair/tesjs) by Mitchell Adair @mitchwadair 195 | 196 | ## License 197 | Creative Commons License
This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivs 4.0 Generic License. 198 | -------------------------------------------------------------------------------- /fileTriggers.txt: -------------------------------------------------------------------------------- 1 | sample.txt 2 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /js/action/actionHandler.js: -------------------------------------------------------------------------------- 1 | class ActionHandler extends Handler { 2 | /** 3 | * Create a new Actions handler. 4 | */ 5 | constructor() { 6 | super('Action', ['OnAction']); 7 | this.actions = []; 8 | this.actionsTriggers = {}; 9 | this.success(); 10 | } 11 | 12 | /** 13 | * Register trigger from user input. 14 | * @param {string} trigger name to use for the handler 15 | * @param {array} triggerLine contents of trigger line 16 | * @param {number} id of the new trigger 17 | */ 18 | addTriggerData = (trigger, triggerLine, triggerId) => { 19 | var { actions } = Parser.getInputs(triggerLine, ['actions'], true); 20 | // Allow action aliases 21 | actions.forEach(action => { 22 | action = action.toLowerCase(); 23 | if (this.actions.indexOf(action) === -1) { 24 | this.actions.push(action); 25 | this.actionsTriggers[action] = []; 26 | } 27 | this.actionsTriggers[action].push(triggerId); 28 | }); 29 | } 30 | 31 | /** 32 | * Handle the input data (take an action). 33 | * @param {array} triggerData contents of trigger line 34 | */ 35 | handleData = async (triggerData) => { 36 | var action = Parser.getAction(triggerData, 'Action', -1); 37 | 38 | if (action === "action") { 39 | if (triggerData.length > 2) { 40 | triggerData.shift(); 41 | return { actions: [triggerData] }; 42 | } else if (triggerData.length === 2) { 43 | return { actions: [triggerData[1]] }; 44 | } else { 45 | console.error('No action provided to the Action handler: ' + JSON.stringify(triggerData)); 46 | } 47 | } else if (this.actions.indexOf(action) != -1) { 48 | var inputs = {}; 49 | for (var i = 1; i < triggerData.length; i++) { 50 | inputs[`in${i}`] = triggerData[i]; 51 | } 52 | return { "_trigId": this.actionsTriggers[action], action: action, in_count: Object.keys(inputs).length, ...inputs }; 53 | } else { 54 | console.error('Unable to find parser for input: ' + action); 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * Create a handler 61 | */ 62 | function actionHandlerExport() { 63 | var actionHandler = new ActionHandler(); 64 | } 65 | actionHandlerExport(); 66 | -------------------------------------------------------------------------------- /js/api/apiHandler.js: -------------------------------------------------------------------------------- 1 | class ApiHandler extends Handler { 2 | /** 3 | * Create a new API handler. 4 | */ 5 | constructor() { 6 | super('API', []); 7 | this.apiCall = {}; 8 | this.success(); 9 | } 10 | 11 | /** 12 | * Handle the input data (take an action). 13 | * @param {array} triggerData contents of trigger line 14 | */ 15 | handleData = async (triggerData) => { 16 | var action = Parser.getAction(triggerData, 'API'); 17 | if (action === 'get') { 18 | var { url } = Parser.getInputs(triggerData, ['action', 'url']); 19 | var response = await this.callAPI('GET', url); 20 | return { api_data: response }; 21 | } else { 22 | switch (action) { 23 | case 'method': 24 | var { name, method } = Parser.getInputs(triggerData, ['action', 'name', 'method']); 25 | this.initialize(name); 26 | this.apiCall[name].method = method.toUpperCase(); 27 | break; 28 | case 'header': 29 | var { name, headerKey, headerValue } = Parser.getInputs(triggerData, ['action', 'name', 'headerKey', 'headerValue']); 30 | this.initialize(name); 31 | this.apiCall[name].headers[headerKey] = headerValue; 32 | break; 33 | case 'data': 34 | var { name, dataKey, dataValue } = Parser.getInputs(triggerData, ['action', 'name', 'dataKey', 'dataValue']); 35 | this.initialize(name); 36 | this.apiCall[name].data[dataKey] = dataValue; 37 | break; 38 | case 'rawdata': 39 | var { name, rawData } = Parser.getInputs(triggerData, ['action', 'name', 'rawData']); 40 | this.initialize(name); 41 | this.apiCall[name].data = rawData; 42 | break; 43 | case 'url': 44 | var { name, apiURL } = Parser.getInputs(triggerData, ['action', 'name', 'apiURL']); 45 | this.initialize(name); 46 | this.apiCall[name].url = apiURL; 47 | break; 48 | case 'send': 49 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 50 | this.initialize(name); 51 | var response = await this.callAPI(this.apiCall[name].method, this.apiCall[name].url, this.apiCall[name].data, this.apiCall[name].headers); 52 | return { api_data: response }; 53 | break; 54 | case 'clear': 55 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 56 | delete this.apiCall[name]; 57 | break; 58 | default: 59 | console.error(`Unexpected API (${action}). Check your event code.`); 60 | break; 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Initialize the api data if it does not exist. 67 | * @param {string} name webhook name 68 | */ 69 | initialize = (name) => { 70 | if (this.apiCall[name] === undefined) { 71 | this.apiCall[name] = { 72 | method: "GET", 73 | headers: {}, 74 | data: {}, 75 | url: "" 76 | }; 77 | } 78 | } 79 | 80 | /** 81 | * Call the input url and return the response. 82 | * @param {string} method type of API call 83 | * @param {string} url url to call 84 | * @param {object} data parameters to send with the call 85 | * @param {object} headers headers to send with the call 86 | */ 87 | callAPI = async (method, url, data, headers) => { 88 | data = data || {}; 89 | headers = headers || {}; 90 | var response = null; 91 | try { 92 | await $.ajax({ 93 | url: url, 94 | type: method, 95 | data: data, 96 | headers: headers, 97 | success: function(data) { 98 | response = data; 99 | if (data === undefined) { 100 | response = 'success'; 101 | } 102 | }, 103 | error: function(err) { 104 | console.error(`Error calling the ${url} API: ${JSON.stringify(err)}`); 105 | response = 'error'; 106 | } 107 | }); 108 | } catch (err) { 109 | console.error(`Unhandled error calling the ${url} API: ${JSON.stringify(err)}`); 110 | response = 'error'; 111 | } 112 | 113 | return response; 114 | } 115 | } 116 | 117 | /** 118 | * Create a handler 119 | */ 120 | function apiHandlerExport() { 121 | var api = new ApiHandler(); 122 | } 123 | apiHandlerExport(); 124 | -------------------------------------------------------------------------------- /js/chat/chatHandler.js: -------------------------------------------------------------------------------- 1 | class ChatHandler extends Handler { 2 | /** 3 | * Create a new Chat handler. 4 | */ 5 | constructor() { 6 | super('Chat', ['OnCommand','OnKeyword','OnEveryChatMessage','OnHypeChat', 'OnSpeak']); 7 | 8 | /* OnCommand */ 9 | this.commands = []; 10 | this.commandsOther = []; 11 | this.commandsInfo = {}; 12 | 13 | /* OnKeyword */ 14 | this.keywords = []; 15 | this.keywordsRegex = null; 16 | this.keywordsInfo = {}; 17 | 18 | /* OnSpeak */ 19 | this.speaks = []; 20 | this.speaksInfo = {}; 21 | this.speakers = {}; 22 | 23 | /* OnEveryChatMessage */ 24 | this.chatTriggers = []; 25 | 26 | /* OnHypeChat */ 27 | this.onHypeChats = []; 28 | this.onHypeChatsInfo = {}; 29 | } 30 | 31 | /** 32 | * Initialize the chat connection with the input user. 33 | * @param {string} channel twitch channel to connect 34 | */ 35 | init = (channel) => { 36 | this.channel = channel.toLowerCase(); 37 | ComfyJS.onConnected = ( address, port, isFirstConnect ) => { 38 | console.error("Chat connected successfully"); 39 | if (isFirstConnect) { 40 | this.success(); 41 | } 42 | } 43 | 44 | Storage.onChange("ChatOAuth", (_, value) => { 45 | if (Debug.All || Debug.Chat) { 46 | console.error("New chat oauth value received") 47 | } 48 | if (ComfyJS.GetClient() !== null) { 49 | if (Debug.All || Debug.Chat) { 50 | console.error("Disconnecting ComfyJS") 51 | } 52 | ComfyJS.Disconnect(); 53 | } 54 | if (Debug.All || Debug.Chat) { 55 | console.error("Initializing ComfyJS") 56 | } 57 | ComfyJS.Init(channel, `oauth:${value}`); 58 | }, true); 59 | } 60 | 61 | /** 62 | * Register trigger from user input. 63 | * @param {string} trigger name to use for the handler 64 | * @param {array} triggerLine contents of trigger line 65 | * @param {number} id of the new trigger 66 | */ 67 | addTriggerData = (trigger, triggerLine, triggerId) => { 68 | trigger = trigger.toLowerCase(); 69 | switch (trigger) { 70 | case 'oncommand': 71 | var permission = ''; 72 | var inputs = ['permission', 'cooldown', 'commands']; 73 | if (triggerLine.length > 1) { 74 | permission = triggerLine[1].toLowerCase(); 75 | if (permission.includes('u')) { 76 | inputs.splice(1, 0, 'info'); 77 | } 78 | } 79 | var { permission, info, cooldown, commands } = Parser.getInputs(triggerLine, inputs, true); 80 | permission = permission.toLowerCase(); 81 | info = info ? info.toLowerCase() : ""; 82 | info = info.split(',') 83 | 84 | cooldown = parseInt(cooldown); 85 | if (isNaN(cooldown)) { 86 | cooldown = 0; 87 | } 88 | 89 | commands.forEach(command => { 90 | command = command.toLowerCase(); 91 | if (command.charAt(0) === "!") { 92 | command = command.substring(1); 93 | if (this.commands.indexOf(command) === -1) { 94 | this.commands.push(command); 95 | this.commandsInfo[command] = []; 96 | } 97 | } else { 98 | if (this.commandsOther.indexOf(command) === -1) { 99 | this.commandsOther.push(command); 100 | this.commandsInfo[command] = []; 101 | } 102 | } 103 | this.commandsInfo[command].push({ 104 | permission: permission, 105 | info: info, 106 | trigger: triggerId, 107 | cooldown: cooldown * 1000, 108 | lastUse: 0 - (cooldown * 1000) 109 | }); 110 | }); 111 | break; 112 | case 'onkeyword': 113 | var permission = ''; 114 | var inputs = ['permission', 'cooldown', 'keywords']; 115 | if (triggerLine.length > 1) { 116 | permission = triggerLine[1].toLowerCase(); 117 | if (permission.includes('u')) { 118 | inputs.splice(1, 0, 'info'); 119 | } 120 | } 121 | var { permission, info, cooldown, keywords } = Parser.getInputs(triggerLine, inputs, true); 122 | permission = permission.toLowerCase(); 123 | info = info ? info.toLowerCase() : ""; 124 | info = info.split(',') 125 | 126 | cooldown = parseInt(cooldown); 127 | if (isNaN(cooldown)) { 128 | cooldown = 0; 129 | } 130 | 131 | keywords.forEach(keyword => { 132 | keyword = keyword.toLowerCase(); 133 | if (this.keywords.indexOf(keyword) === -1) { 134 | this.keywords.push(keyword); 135 | this.keywordsInfo[keyword] = []; 136 | } 137 | this.keywordsInfo[keyword].push({ 138 | permission: permission, 139 | info: info, 140 | trigger: triggerId, 141 | cooldown: cooldown * 1000, 142 | lastUse: 0 - (cooldown * 1000) 143 | }); 144 | }); 145 | break; 146 | case 'onspeak': 147 | var { users } = Parser.getInputs(triggerLine, ['users'], true); 148 | users.forEach(user => { 149 | user = user.toLowerCase(); 150 | if (this.speaks.indexOf(user) === -1) { 151 | this.speaks.push(user); 152 | this.speaksInfo[user] = []; 153 | } 154 | this.speaksInfo[user].push(triggerId); 155 | }); 156 | break; 157 | case 'oneverychatmessage': 158 | this.chatTriggers.push(triggerId); 159 | break; 160 | case 'onhypechat': 161 | var { users } = Parser.getInputs(triggerLine, ['users'], true); 162 | users.forEach(user => { 163 | user = user.toLowerCase(); 164 | if (this.onHypeChats.indexOf(user) === -1) { 165 | this.onHypeChats.push(user); 166 | this.onHypeChatsInfo[user] = []; 167 | } 168 | this.onHypeChatsInfo[user].push(triggerId); 169 | }); 170 | break; 171 | default: 172 | // do nothing 173 | } 174 | return; 175 | } 176 | 177 | /** 178 | * Handle the input data (take an action). 179 | * @param {array} triggerData contents of trigger line 180 | */ 181 | handleData = async (triggerData) => { 182 | var action = Parser.getAction(triggerData, 'Chat'); 183 | if (action === 'send') { 184 | var { message } = Parser.getInputs(triggerData, ['action', 'message']); 185 | ComfyJS.Say(message); 186 | } else if (action === 'whisper') { 187 | var { user, message } = Parser.getInputs(triggerData, ['action', 'user', 'message']); 188 | ComfyJS.Whisper(message, user); 189 | } 190 | return; 191 | } 192 | 193 | /** 194 | * Register trigger from user input. 195 | * @param {string} user twitch display name that sent the message 196 | * @param {object} flags permission flags for the user 197 | * @param {string} permissions usability of the command or keyword 198 | * @param {string} username twitch username that sent the message 199 | * @param {string} info extra information for the usability 200 | */ 201 | checkPermissions = (user, flags, permissions, username, info) => { 202 | user = user.toLowerCase(); 203 | if (permissions.includes('e')) { 204 | return [true, 'e']; 205 | } else if (permissions.includes('s') && flags.subscriber) { 206 | return [true, 's']; 207 | } else if (permissions.includes('v') && flags.vip) { 208 | return [true, 'v']; 209 | } else if (permissions.includes('o') && flags.founder) { 210 | return [true, 'o']; 211 | } else if (permissions.includes('m') && flags.mod) { 212 | return [true, 'm']; 213 | } else if (permissions.includes('b') && this.channel === username) { 214 | return [true, 'b']; 215 | } else if (permissions.includes('u') && info && info.indexOf(user) !== -1) { 216 | return [true, 'u']; 217 | } else if (permissions.includes('n') && !flags.founder && !flags.subscriber && !flags.vip && !flags.mod && this.channel !== user) { 218 | return [true, 'n'] 219 | } 220 | 221 | return [false, '']; 222 | } 223 | 224 | /** 225 | * Check if a trigger is on cooldown. 226 | * @param {object} info command info object 227 | */ 228 | updateCooldown = (info) => { 229 | if (info.cooldown === 0) { 230 | return true; 231 | } 232 | 233 | var curTime = new Date().getTime(); 234 | if (curTime >= info.lastUse + info.cooldown) { 235 | info.lastUse = curTime; 236 | return true; 237 | } else { 238 | return false; 239 | } 240 | } 241 | 242 | /** 243 | * Called after parsing all user input. 244 | */ 245 | postParse = () => { 246 | // Create Keyword Regex 247 | if (this.keywords.length > 0) { 248 | this.keywordsRegex = new RegExp('(?:\\b|^|\\s)' + this.keywords.map(x => escapeRegExp(x)).join('(?:\\b|$|\\s)|(?:\\b|^|\\s)') + '(?:\\b|$|\\s)', 'gi'); 249 | } 250 | 251 | ComfyJS.onCommand = ( user, command, message, flags, extra ) => { 252 | if (Debug.All || Debug.Chat) { 253 | console.error(`Command Received: ${JSON.stringify({user, command, message, flags, extra})}`); 254 | } 255 | var combined = '!' + command + ' ' + message; 256 | 257 | this.onAllChat(user, { 258 | user: user, 259 | message: combined, 260 | message_id: extra.id || '', 261 | data: { 262 | user: user, 263 | command: command, 264 | message: combined, 265 | after: message, 266 | flags: flags, 267 | extra: extra 268 | } 269 | }); 270 | 271 | // Check for matching command and user permission 272 | if (this.commands.indexOf(command) !== -1) { 273 | var args = Parser.splitLine(message); 274 | 275 | var chatArgs = {}; 276 | for (var i = 0; i < args.length; i++) { 277 | chatArgs[`arg${i+1}`] = args[i]; 278 | } 279 | this.commandsInfo[command].forEach(info => { 280 | var [canUseCommand, grantingPermission] = this.checkPermissions(user, flags, info.permission, extra.username, info.info); 281 | if ((canUseCommand || info.permission.includes('f')) && this.updateCooldown(info)) { 282 | controller.handleData( 283 | info.trigger, 284 | { 285 | command: command, 286 | user: user, 287 | message: combined, 288 | message_id: extra.id || '', 289 | after: message, 290 | data: { 291 | user: user, 292 | command: command, 293 | message: combined, 294 | after: message, 295 | flags: flags, 296 | extra: extra 297 | }, 298 | arg_count: args.length, 299 | ...chatArgs 300 | }, 301 | canUseCommand && grantingPermission !== 'n' ? [] : this.addFollowerActions(info.permission, user) 302 | ); 303 | } 304 | }); 305 | } 306 | // Otherwise, check for keyword match 307 | else { 308 | var result = message.match(this.keywordsRegex); 309 | if (result) { 310 | var match = result[0].trim().toLowerCase(); 311 | var args = Parser.splitLine(message); 312 | var chatArgs = {}; 313 | for (var i = 0; i < args.length; i++) { 314 | chatArgs[`arg${i+1}`] = args[i]; 315 | } 316 | this.keywordsInfo[match].forEach(info => { 317 | // Check if user has permission to trigger keyword 318 | var [canUseCommand, grantingPermission] = this.checkPermissions(user, flags, info.permission, extra.username, info.info); 319 | if ((canUseCommand || info.permission.includes('f')) && this.updateCooldown(info)) { 320 | controller.handleData(info.trigger, 321 | { 322 | user: user, 323 | keyword: match, 324 | message: message, 325 | message_id: extra.id || '', 326 | data: { 327 | user: user, 328 | keyword: match, 329 | message: message, 330 | flags: flags, 331 | extra: extra 332 | }, 333 | arg_count: args.length, 334 | ...chatArgs 335 | }, 336 | canUseCommand && grantingPermission !== 'n' ? [] : this.addFollowerActions(info.permission, user) 337 | ); 338 | } 339 | }); 340 | } 341 | } 342 | } 343 | 344 | ComfyJS.onChat = ( user, message, flags, self, extra ) => { 345 | if (Debug.All || Debug.Chat) { 346 | console.error(`Chat Received: ${JSON.stringify({user, command, message, flags, extra})}`); 347 | } 348 | this.onAllChat(user, { 349 | user: user, 350 | message: message, 351 | message_id: extra.id || '', 352 | data: { 353 | user: user, 354 | message: message, 355 | flags: flags, 356 | extra: extra 357 | } 358 | }); 359 | 360 | // Check for matching command and user permission 361 | var command = message.split(' ')[0].toLowerCase(); 362 | if(this.commandsOther.indexOf(command) != -1) { 363 | var args = Parser.splitLine(message).slice(1); 364 | var chatArgs = {}; 365 | for (var i = 0; i < args.length; i++) { 366 | chatArgs[`arg${i+1}`] = args[i]; 367 | } 368 | this.commandsInfo[command].forEach(info => { 369 | var [canUseCommand, grantingPermission] = this.checkPermissions(user, flags, info.permission, extra.username, info.info); 370 | if ((canUseCommand || info.permission.includes('f')) && this.updateCooldown(info)) { 371 | var after = args.join(' '); 372 | controller.handleData(info.trigger, 373 | { 374 | command: command, 375 | user: user, 376 | message: message, 377 | message_id: extra.id || '', 378 | after: after, 379 | data: { 380 | user: user, 381 | command: command, 382 | message: message, 383 | after: after, 384 | flags: flags, 385 | extra: extra 386 | }, 387 | arg_count: args.length, 388 | ...chatArgs 389 | }, 390 | canUseCommand && grantingPermission !== 'n' ? [] : this.addFollowerActions(info.permission, user) 391 | ); 392 | } 393 | }); 394 | } 395 | // Otherwise, check for keyword match 396 | else { 397 | var result = message.match(this.keywordsRegex); 398 | if (result) { 399 | var args = Parser.splitLine(message); 400 | var chatArgs = {}; 401 | for (var i = 0; i < args.length; i++) { 402 | chatArgs[`arg${i+1}`] = args[i]; 403 | } 404 | var match = result[0].trim().toLowerCase(); 405 | this.keywordsInfo[match].forEach(info => { 406 | // Check if user has permission to trigger keyword 407 | var [canUseCommand, grantingPermission] = this.checkPermissions(user, flags, info.permission, extra.username, info.info); 408 | if ((canUseCommand || info.permission.includes('f')) && this.updateCooldown(info)) { 409 | controller.handleData(info.trigger, 410 | { 411 | user: user, 412 | keyword: match, 413 | message: message, 414 | message_id: extra.id || '', 415 | data: { 416 | user: user, 417 | keyword: match, 418 | message: message, 419 | flags: flags, 420 | extra: extra 421 | }, 422 | arg_count: args.length, 423 | ...chatArgs 424 | }, 425 | canUseCommand && grantingPermission !== 'n' ? [] : this.addFollowerActions(info.permission, user) 426 | ); 427 | } 428 | }); 429 | } 430 | } 431 | } 432 | return; 433 | } 434 | 435 | /** 436 | * Return actions to check for twitch follower status if requested by the event. 437 | * @param {string} permissions usability of the command or keyword 438 | * @param {string} user twitch display name that sent the message 439 | */ 440 | addFollowerActions = (permissions, user) => { 441 | if (permissions.includes('n')) { 442 | return [ 443 | ['Ignore', 'Twitch', 'IsFollower', user], 444 | ['Ignore', 'If', '{is_follower}', '=', 'false'] 445 | ]; 446 | } else if (permissions.includes('f')) { 447 | return [ 448 | ['Ignore', 'Twitch', 'IsFollower', user], 449 | ['Ignore', 'If', '{is_follower}', '=', 'true'] 450 | ]; 451 | } else { 452 | return []; 453 | } 454 | } 455 | 456 | /** 457 | * Check if a trigger is on cooldown. 458 | * @param {string} user that sent the message 459 | * @param {data} data to send with the OnEveryChatMessage 460 | */ 461 | onAllChat = (user, data) => { 462 | // OnEveryChatMessage 463 | this.chatTriggers.forEach(triggerId => { 464 | controller.handleData(triggerId, data); 465 | }); 466 | 467 | // Check for OnHypeChat 468 | if ("pinned-chat-paid-amount" in data.data.extra.userState) { 469 | var userLower = user.toLowerCase(); 470 | var onHypeChatTriggers = []; 471 | if (this.onHypeChats.indexOf(userLower) !== -1) { 472 | onHypeChatTriggers.push(...this.onHypeChatsInfo[userLower]); 473 | } 474 | if (this.onHypeChats.indexOf('*') !== -1) { 475 | onHypeChatTriggers.push(...this.onHypeChatsInfo['*']); 476 | } 477 | if (onHypeChatTriggers.length > 0) { 478 | var userState = data.data.extra.userState; 479 | var hypeChatData = { 480 | amount: userState["pinned-chat-paid-amount"], 481 | formatted_amount: (userState["pinned-chat-paid-amount"] / Math.pow(10, userState["pinned-chat-paid-exponent"])).toFixed(userState["pinned-chat-paid-exponent"]), 482 | currency: userState["pinned-chat-paid-currency"], 483 | exponent: userState["pinned-chat-paid-exponent"], 484 | level: userState["pinned-chat-paid-level"], 485 | is_system_message: userState["pinned-chat-paid-is-system-message"], 486 | ...data 487 | }; 488 | 489 | onHypeChatTriggers.sort((a, b) => a-b); 490 | onHypeChatTriggers.forEach(triggerId => { 491 | controller.handleData(triggerId, hypeChatData); 492 | }); 493 | } 494 | } 495 | 496 | // Check for OnSpeak Event 497 | var userLower = user.toLowerCase(); 498 | var onSpeakTriggers = []; 499 | if (this.speakers[userLower] === undefined && this.speaks.indexOf(userLower) !== -1) { 500 | onSpeakTriggers.push(...this.speaksInfo[userLower]); 501 | } 502 | if (this.speakers[userLower] === undefined && this.speaks.indexOf('*') !== -1) { 503 | onSpeakTriggers.push(...this.speaksInfo['*']); 504 | } 505 | if (onSpeakTriggers.length > 0) { 506 | this.speakers[userLower] = true; 507 | onSpeakTriggers.sort((a, b) => a-b); 508 | onSpeakTriggers.forEach(triggerId => { 509 | controller.handleData(triggerId, data); 510 | }); 511 | } else if (this.speakers[userLower] === undefined) { 512 | this.speakers[userLower] = true; 513 | } 514 | } 515 | } 516 | 517 | /** 518 | * Create a handler and read user settings 519 | */ 520 | async function chatHandlerExport() { 521 | var chat = new ChatHandler(); 522 | var channel = await readFile('settings/chat/channel.txt'); 523 | chat.init(channel.trim()); 524 | } 525 | chatHandlerExport(); 526 | -------------------------------------------------------------------------------- /js/cooldown/cooldownHandler.js: -------------------------------------------------------------------------------- 1 | class CooldownHandler extends Handler { 2 | GLOBAL_COOLDOWN_KEY = '__kc_cooldown_global_cooldowns'; 3 | GLOBAL_COOLDOWN_KEY_PREFIX = '__kc_cooldown_global_cooldown_'; 4 | 5 | /** 6 | * Create a new API handler. 7 | */ 8 | constructor() { 9 | super('Cooldown', []); 10 | this.success(); 11 | 12 | this.cooldowns = {}; 13 | } 14 | 15 | /** 16 | * Called after parsing all user input. 17 | */ 18 | postParse = async () => { 19 | this.global_cooldowns = await IDBService.get(this.GLOBAL_COOLDOWN_KEY) || []; 20 | for (const cooldown_id of this.global_cooldowns) { 21 | this.cooldowns[cooldown_id] = await IDBService.get(`${this.GLOBAL_COOLDOWN_KEY_PREFIX}${cooldown_id}`) || []; 22 | }; 23 | } 24 | 25 | /** 26 | * Handle the input data (take an action). 27 | * @param {array} triggerData contents of trigger line 28 | */ 29 | handleData = async (triggerData) => { 30 | var action = Parser.getAction(triggerData, 'Cooldown'); 31 | if (action === 'global') { 32 | action = Parser.getAction(triggerData, 'Cooldown', 1); 33 | var { name, duration } = Parser.getInputs(triggerData, ['global', 'action', 'name', 'duration'], false, 1); 34 | 35 | switch (action) { 36 | case 'apply': 37 | var response = this.handleCooldown(name, parseFloat(duration)); 38 | if (this.global_cooldowns.indexOf(name) === -1) { 39 | this.global_cooldowns.push(name); 40 | IDBService.set(this.GLOBAL_COOLDOWN_KEY, this.global_cooldowns); 41 | } 42 | IDBService.set(`${this.GLOBAL_COOLDOWN_KEY_PREFIX}${name}`, this.cooldowns[name]); 43 | return response; 44 | break; 45 | case 'check': 46 | return this.checkCooldown(name); 47 | break; 48 | case 'clear': 49 | delete this.cooldowns[name]; 50 | var nameIndex = this.global_cooldowns.indexOf(name); 51 | if (nameIndex !== -1) { 52 | this.global_cooldowns.splice(nameIndex, 1); 53 | IDBService.set(this.GLOBAL_COOLDOWN_KEY, this.global_cooldowns); 54 | IDBService.delete(`${this.GLOBAL_COOLDOWN_KEY_PREFIX}${name}`); 55 | } 56 | break; 57 | default: 58 | console.error(`Unexpected Cooldown (${action}). Check your event code.`); 59 | break; 60 | } 61 | } else { 62 | var { name, duration } = Parser.getInputs(triggerData, ['action', 'name', 'duration'], false, 1); 63 | switch (action) { 64 | case 'apply': 65 | return this.handleCooldown(name, parseFloat(duration)); 66 | break; 67 | case 'check': 68 | return this.checkCooldown(name); 69 | break; 70 | case 'clear': 71 | delete this.cooldowns[name]; 72 | break; 73 | default: 74 | console.error(`Unexpected Cooldown (${action}). Check your event code.`); 75 | break; 76 | } 77 | } 78 | } 79 | 80 | /** 81 | * Check the named cooldown. 82 | * 83 | * @param {string} name name of the cooldown 84 | * @return {Object} whether or not to continue the trigger. 85 | */ 86 | checkCooldown = (name) => { 87 | var response = {}; 88 | response[name] = false; 89 | var curTime = new Date().getTime(); 90 | if ( typeof(this.cooldowns[name]) !== 'undefined' && curTime < this.cooldowns[name] ) { 91 | response[name] = true; 92 | response['cooldown_real'] = (this.cooldowns[name] - curTime) / 1000; 93 | response['cooldown'] = Math.ceil(response['cooldown_real']); 94 | } 95 | return response; 96 | } 97 | 98 | /** 99 | * Handle the named cooldown. 100 | * 101 | * @param {string} name name of the cooldown 102 | * @param {numeric} duration duration of the cooldown 103 | * @return {Object} whether or not to continue the trigger. 104 | */ 105 | handleCooldown = (name, duration) => { 106 | var response = {"continue": false}; 107 | duration = duration * 1000; // convert to milliseconds 108 | var curTime = new Date().getTime(); 109 | if ( typeof(this.cooldowns[name]) === 'undefined' || curTime >= this.cooldowns[name] ) { 110 | this.cooldowns[name] = curTime + duration; 111 | response["continue"] = true; 112 | } 113 | return response; 114 | } 115 | } 116 | 117 | /** 118 | * Create a handler 119 | */ 120 | function cooldownHandlerExport() { 121 | var cooldown = new CooldownHandler(); 122 | } 123 | cooldownHandlerExport(); 124 | -------------------------------------------------------------------------------- /js/debug/debugHandler.js: -------------------------------------------------------------------------------- 1 | class DebugHandler extends Handler { 2 | /** 3 | * Create a new Debug handler. 4 | */ 5 | constructor() { 6 | super('Debug', []); 7 | this.success(); 8 | this.All = false; 9 | this.Chat = false; 10 | this.MQTT = false; 11 | this.OBS = false; 12 | this.Parser = false; 13 | this.SLOBS = false; 14 | this.Storage = false; 15 | this.StreamElements = false; 16 | this.Streamlabs = false; 17 | this.Twitch = false; 18 | this.Voicemod = false; 19 | } 20 | 21 | /** 22 | * Handle the input data (take an action). 23 | * @param {array} triggerData contents of trigger line 24 | */ 25 | handleData = async (triggerData) => { 26 | var { handler } = Parser.getInputs(triggerData, ['handler'], false, 1); 27 | if (handler) { 28 | handler = handler.toLowerCase(); 29 | switch (handler) { 30 | case 'chat': 31 | this.Chat = true; 32 | break; 33 | case 'mqtt': 34 | this.MQTT = true; 35 | break; 36 | case 'obs': 37 | this.OBS = true; 38 | break; 39 | case 'parser': 40 | this.Parser = true; 41 | break; 42 | case 'slobs': 43 | this.SLOBS = true; 44 | break; 45 | case 'storage': 46 | this.Storage = true; 47 | break; 48 | case 'sl': 49 | case 'streamlabs': 50 | this.Streamlabs = true; 51 | break; 52 | case 'se': 53 | case 'streamelements': 54 | this.StreamElements = true; 55 | break; 56 | case 'twitch': 57 | this.Twitch = true; 58 | break; 59 | case 'voicemod': 60 | this.Voicemod = true; 61 | break; 62 | default: 63 | break; 64 | } 65 | } else { 66 | this.All = true; 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Create a handler 73 | */ 74 | let Debug; 75 | function debugHandlerExport() { 76 | Debug = new DebugHandler(); 77 | } 78 | debugHandlerExport(); 79 | -------------------------------------------------------------------------------- /js/discord/discordHandler.js: -------------------------------------------------------------------------------- 1 | class DiscordHandler extends Handler { 2 | /** 3 | * Create a new Discord handler. 4 | */ 5 | constructor() { 6 | super('Discord', []); 7 | this.webhookUrls = {}; 8 | this.webhookMessageIds = {}; 9 | this.webhooks = {}; 10 | this.success(); 11 | } 12 | 13 | /** 14 | * Handle the input data (take an action). 15 | * @param {array} triggerData contents of trigger line 16 | */ 17 | handleData = async (triggerData) => { 18 | var action = Parser.getAction(triggerData, 'Discord'); 19 | 20 | switch (action) { 21 | case 'clear': 22 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 23 | delete this.webhooks[name]; 24 | break; 25 | case 'color': 26 | var { name, color } = Parser.getInputs(triggerData, ['action', 'name', 'color']); 27 | this.initialize(name); 28 | var color = triggerData.slice(3).join(' '); 29 | if (color.charAt(0) === '#') { 30 | color = color.substring(1); 31 | } 32 | var colorNum = parseInt(color, 16); 33 | if (!isNaN(colorNum)) { 34 | this.webhooks[name].embed.color = colorNum; 35 | } 36 | break; 37 | case 'create': 38 | var { name, url } = Parser.getInputs(triggerData, ['action', 'name', 'url']); 39 | this.webhookUrls[name] = url; 40 | break; 41 | case 'delete': 42 | var { name, messageId } = Parser.getInputs(triggerData, ['action', 'name', 'messageId'], false, 1); 43 | messageId = messageId || this.webhookMessageIds[name]; 44 | var data = await callAPI('DELETE', `${this.webhooks[name].url}/messages/${messageId}`); 45 | break; 46 | case 'description': 47 | var { name, description } = Parser.getInputs(triggerData, ['action', 'name', 'description']); 48 | this.initialize(name); 49 | this.webhooks[name].embed.description = description; 50 | break; 51 | case 'field': 52 | var { name, header, value, inline } = Parser.getInputs(triggerData, ['action', 'name', 'header', 'value', 'inline'], false, 1); 53 | this.initialize(name); 54 | var field = { 55 | name: header, 56 | value: value 57 | }; 58 | if (inline && inline.toLowerCase() === 'true') { 59 | field['inline'] = true; 60 | } 61 | if (!this.webhooks[name].embed.fields) { 62 | this.webhooks[name].embed.fields = []; 63 | } 64 | this.webhooks[name].embed.fields.push(field); 65 | break; 66 | case 'file': 67 | var { name, file } = Parser.getInputs(triggerData, ['action', 'name', 'file']); 68 | this.initialize(name); 69 | this.webhooks[name].file = file; 70 | break; 71 | case 'footericon': 72 | var { name, icon } = Parser.getInputs(triggerData, ['action', 'name', 'icon']); 73 | this.initialize(name); 74 | if (!this.webhooks[name].embed.footer) { 75 | this.webhooks[name].embed.footer = {}; 76 | } 77 | this.webhooks[name].embed.footer.icon_url = icon; 78 | break; 79 | case 'footertext': 80 | var { name, text } = Parser.getInputs(triggerData, ['action', 'name', 'text']); 81 | this.initialize(name); 82 | if (!this.webhooks[name].embed.footer) { 83 | this.webhooks[name].embed.footer = {}; 84 | } 85 | this.webhooks[name].embed.footer.text = text; 86 | break; 87 | case 'image': 88 | var { name, image } = Parser.getInputs(triggerData, ['action', 'name', 'image']); 89 | this.initialize(name); 90 | this.webhooks[name].embed.image = { url: image }; 91 | break; 92 | case 'message': 93 | var { name, message } = Parser.getInputs(triggerData, ['action', 'name', 'message']); 94 | this.initialize(name); 95 | this.webhooks[name].content = message; 96 | break; 97 | case 'send': 98 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 99 | this.initialize(name); 100 | var json = {}; 101 | if (this.webhooks[name].content) { 102 | json.content = this.webhooks[name].content; 103 | } 104 | 105 | if (this.webhooks[name].embed && Object.keys(this.webhooks[name].embed).length > 0) { 106 | json.embeds = [this.webhooks[name].embed]; 107 | } 108 | 109 | var data; 110 | 111 | // Try to get file information and send as form data 112 | if (this.webhooks[name].file) { 113 | var fd = new FormData(); 114 | var fileObj = await convertUrlToFileObj(this.webhooks[name].file); 115 | 116 | if (fileObj !== null) { 117 | fd.append( 'file', fileObj ); 118 | fd.append( 'payload_json', JSON.stringify(json) ); 119 | data = await callAPI('POST', `${this.webhooks[name].url}?wait=true`, fd, {}, { contentType: false, processData: false }); 120 | } 121 | } 122 | 123 | // Otherwise, send message data as JSON 124 | if (data == undefined) { 125 | data = await callAPI('POST', `${this.webhooks[name].url}?wait=true`, JSON.stringify(json), { "Content-Type": "application/json" }); 126 | } 127 | 128 | this.webhookMessageIds[name] = data.id; 129 | return { "discord_msg_id": data.id }; 130 | break; 131 | case 'thumbnail': 132 | var { name, thumbnail } = Parser.getInputs(triggerData, ['action', 'name', 'thumbnail']); 133 | this.initialize(name); 134 | this.webhooks[name].embed.thumbnail = { url: thumbnail }; 135 | break; 136 | case 'title': 137 | var { name, title } = Parser.getInputs(triggerData, ['action', 'name', 'title']); 138 | this.initialize(name); 139 | this.webhooks[name].embed.title = title; 140 | break; 141 | case 'update': 142 | var { name, messageId } = Parser.getInputs(triggerData, ['action', 'name', 'messageId'], false, 1); 143 | this.initialize(name); 144 | messageId = messageId || this.webhookMessageIds[name]; 145 | 146 | var json = {}; 147 | if (this.webhooks[name].content) { 148 | json.content = this.webhooks[name].content; 149 | } 150 | 151 | if (this.webhooks[name].embed) { 152 | json.embeds = [this.webhooks[name].embed]; 153 | } 154 | var data = await callAPI('PATCH', `${this.webhooks[name].url}/messages/${messageId}`, JSON.stringify(json), { "Content-Type": "application/json" }); 155 | break; 156 | case 'url': 157 | var { name, url } = Parser.getInputs(triggerData, ['action', 'name', 'url']); 158 | this.initialize(name); 159 | this.webhooks[name].embed.url = url; 160 | break; 161 | } 162 | } 163 | 164 | /** 165 | * Initialize the webhook data if it does not exist. 166 | * @param {string} name webhook name 167 | */ 168 | initialize = (name) => { 169 | if (!this.webhooks[name]) { 170 | this.webhooks[name] = { content: "", url: this.webhookUrls[name], embed: {}, file: "" }; 171 | } 172 | } 173 | } 174 | 175 | /** 176 | * Create a handler 177 | */ 178 | function discordHandlerExport() { 179 | var discord = new DiscordHandler(); 180 | } 181 | discordHandlerExport(); 182 | -------------------------------------------------------------------------------- /js/handler.js: -------------------------------------------------------------------------------- 1 | class Handler { 2 | /** 3 | * Create a new handler. 4 | * @param {string} parserName name to use for the handler 5 | * @param {array} triggers list of triggers this handler is responsible for 6 | */ 7 | constructor(parserName, triggers) { 8 | this.parserName = parserName; 9 | triggers = triggers || []; 10 | 11 | // Configure handler with controller 12 | controller.addParser(parserName, this); 13 | triggers.forEach(trigger => { 14 | controller.addTrigger(trigger, parserName); 15 | }); 16 | } 17 | 18 | /** 19 | * Register trigger as successfully initialized. 20 | */ 21 | success = () => { 22 | controller.addSuccess(this.parserName); 23 | } 24 | 25 | /** 26 | * Register trigger from user input. 27 | * @param {string} trigger name to use for the handler 28 | * @param {array} triggerLine contents of trigger line 29 | * @param {number} id of the new trigger 30 | */ 31 | addTriggerData = (trigger, triggerLine, triggerId) => { 32 | return; 33 | } 34 | 35 | /** 36 | * Handle the input data (take an action). 37 | * @param {array} triggerData contents of trigger line 38 | * @param {array} triggerParams current trigger parameters 39 | */ 40 | handleData = async (triggerData, triggerParams) => { 41 | return; 42 | } 43 | 44 | /** 45 | * Called before parsing user input. 46 | */ 47 | preParse = () => { 48 | return; 49 | } 50 | 51 | /** 52 | * Called after parsing all user input. 53 | */ 54 | postParse = () => { 55 | return; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /js/index.js: -------------------------------------------------------------------------------- 1 | // Do stuff if the document is fully loaded 2 | $(document).ready(async function() { 3 | var version = await readFile("version.txt"); 4 | console.error(`Kruiz Control ${version.trim()} Initialized`); 5 | var data = await readFile("triggers.txt"); 6 | await readTriggerFile(data); 7 | }); 8 | 9 | /** 10 | * Read all the file triggers 11 | * @param {string} data list of files to parse 12 | */ 13 | async function readTriggerFile(data) { 14 | controller.parseInput(data, false); 15 | var files = await readFile("fileTriggers.txt"); 16 | await readFileTriggers(files); 17 | } 18 | 19 | /** 20 | * Read all the file triggers 21 | * @param {string} data list of files to parse 22 | */ 23 | async function readFileTriggers(data) { 24 | data = data.trim(); 25 | var lines = data.split(/\r\n|\n/); 26 | 27 | for (var i = 0; i < lines.length; i++) { 28 | var line = lines[i]; 29 | if (!line.startsWith('#') && line.trim().length > 0) { 30 | try { 31 | var input = await readFile('triggers/' + line); 32 | controller.parseInput(input, true); 33 | } catch (error) { 34 | console.error(`Check that the ${line} file exists in the triggers folder`); 35 | } 36 | } 37 | } 38 | 39 | controller.doneParsing(); 40 | setTimeout(function() { 41 | controller.runInit(); 42 | }, 2000); 43 | } 44 | -------------------------------------------------------------------------------- /js/list/listHandler.js: -------------------------------------------------------------------------------- 1 | class ListHandler extends Handler { 2 | GLOBAL_LIST_KEY = '__kc_list_global_lists'; 3 | GLOBAL_LIST_KEY_PREFIX = '__kc_list_global_list_'; 4 | 5 | /** 6 | * Create a new List handler. 7 | */ 8 | constructor() { 9 | super('List', []); 10 | this.success(); 11 | this.lists = {}; 12 | } 13 | 14 | /** 15 | * Called after parsing all user input. 16 | */ 17 | postParse = async () => { 18 | this.global_lists = await IDBService.get(this.GLOBAL_LIST_KEY) || []; 19 | for (const list_id of this.global_lists) { 20 | this.lists[list_id] = await IDBService.get(`${this.GLOBAL_LIST_KEY_PREFIX}${list_id}`) || []; 21 | }; 22 | } 23 | 24 | /** 25 | * Handle the input data (take an action). 26 | * @param {array} triggerData contents of trigger line 27 | */ 28 | handleData = async (triggerData) => { 29 | var action = Parser.getAction(triggerData, 'List'); 30 | 31 | switch (action) { 32 | case 'add': 33 | var { name, value, index } = Parser.getInputs(triggerData, ['action', 'name', 'value', 'index'], false, 1); 34 | this.initialize(name); 35 | return this.add(name, value, parseInt(index)); 36 | break; 37 | case 'contains': 38 | var { name, value } = Parser.getInputs(triggerData, ['action', 'name', 'value']); 39 | this.initialize(name); 40 | return { contains: this.lists[name].indexOf(value) !== -1 }; 41 | break; 42 | case 'count': 43 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 44 | this.initialize(name); 45 | return { count: this.lists[name].length }; 46 | break; 47 | case 'empty': 48 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 49 | this.initialize(name); 50 | this.lists[name] = []; 51 | if (this.global_lists.indexOf(name) !== -1) { 52 | IDBService.set(`${this.GLOBAL_LIST_KEY_PREFIX}${name}`, this.lists[name]) 53 | } 54 | break; 55 | case 'export': 56 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 57 | this.initialize(name); 58 | return { [name]: JSON.stringify(this.lists[name]) }; 59 | break; 60 | case 'get': 61 | var { name, index } = Parser.getInputs(triggerData, ['action', 'name', 'index'], false, 1); 62 | this.initialize(name); 63 | return this.get(name, index); 64 | break; 65 | case 'global': 66 | var { name, status } = Parser.getInputs(triggerData, ['action', 'name', 'status']); 67 | status = status.toLowerCase(); 68 | this.initialize(name); 69 | 70 | var nameIndex = this.global_lists.indexOf(name); 71 | if (status === 'on' && nameIndex === -1) { 72 | this.global_lists.push(name); 73 | IDBService.set(this.GLOBAL_LIST_KEY, this.global_lists); 74 | IDBService.set(`${this.GLOBAL_LIST_KEY_PREFIX}${name}`, this.lists[name]); 75 | } else if (status === 'off' && nameIndex !== -1) { 76 | this.global_lists.splice(nameIndex, 1); 77 | IDBService.set(this.GLOBAL_LIST_KEY, this.global_lists); 78 | IDBService.delete(`${this.GLOBAL_LIST_KEY_PREFIX}${name}`); 79 | } 80 | break; 81 | case 'import': 82 | var { name, values } = Parser.getInputs(triggerData, ['action', 'name', 'values']); 83 | this.initialize(name); 84 | this.lists[name] = JSON.parse(values); 85 | if (this.global_lists.indexOf(name) !== -1) { 86 | IDBService.set(`${this.GLOBAL_LIST_KEY_PREFIX}${name}`, this.lists[name]) 87 | } 88 | break; 89 | case 'index': 90 | var { name, value } = Parser.getInputs(triggerData, ['action', 'name', 'value']); 91 | this.initialize(name); 92 | var index = this.lists[name].indexOf(value); 93 | var position = index + 1; 94 | if (position === 0) { 95 | position = -1; 96 | } 97 | return { index: index, position: position }; 98 | break; 99 | case 'join': 100 | var { name, delimiter } = Parser.getInputs(triggerData, ['action', 'name', 'delimiter']); 101 | this.initialize(name); 102 | return { joined: this.lists[name].join(delimiter) }; 103 | break; 104 | case 'remove': 105 | var { name, index } = Parser.getInputs(triggerData, ['action', 'name', 'index'], false, 1); 106 | this.initialize(name); 107 | return this.remove(name, index); 108 | break; 109 | case 'set': 110 | var { name, index, value } = Parser.getInputs(triggerData, ['action', 'name', 'index', 'value']); 111 | this.initialize(name); 112 | return this.set(name, parseInt(index), value); 113 | break; 114 | case 'unique': 115 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 116 | this.initialize(name); 117 | this.lists[name] = [...new Set(this.lists[name])]; 118 | break; 119 | default: 120 | // do nothing 121 | break; 122 | } 123 | } 124 | 125 | /** 126 | * Set up the list for the given name (if one doesn't exist). 127 | * @param {string} name of the list 128 | */ 129 | initialize = (name) => { 130 | if (this.lists[name] === undefined) { 131 | this.lists[name] = []; 132 | } 133 | } 134 | 135 | /** 136 | * Add the value to the named list at the given index. 137 | * @param {string} name of the list 138 | * @param {string} value to add to the list 139 | * @param {string} index to add the value in the list 140 | */ 141 | add = (name, value, index) => { 142 | if (!isNaN(index) && index < this.lists[name].length && index >= 0) { 143 | this.lists[name].splice(index, 0, value); 144 | if (this.global_lists.indexOf(name) !== -1) { 145 | IDBService.set(`${this.GLOBAL_LIST_KEY_PREFIX}${name}`, this.lists[name]) 146 | } 147 | return { index: index, position: index + 1 } 148 | } else { 149 | this.lists[name].push(value); 150 | if (this.global_lists.indexOf(name) !== -1) { 151 | IDBService.set(`${this.GLOBAL_LIST_KEY_PREFIX}${name}`, this.lists[name]) 152 | } 153 | return { index: this.lists[name].length - 1, position: this.lists[name].length } 154 | } 155 | } 156 | 157 | /** 158 | * Set the value at the given index in the named list. 159 | * @param {string} name of the list 160 | * @param {string} index to add the value in the list 161 | * @param {string} value to add to the list 162 | */ 163 | set = (name, index, value) => { 164 | if (!isNaN(index) && index < this.lists[name].length && index >= 0) { 165 | this.lists[name][index] = value; 166 | if (this.global_lists.indexOf(name) !== -1) { 167 | IDBService.set(`${this.GLOBAL_LIST_KEY_PREFIX}${name}`, this.lists[name]) 168 | } 169 | return { index: index, position: index + 1, value: value } 170 | } 171 | } 172 | 173 | /** 174 | * Get the integer index from the input index value. 175 | * @param {string} name of the list 176 | * @param {string} index to retrieve a numeric value 177 | */ 178 | getIndexValue = (name, index) => { 179 | if (this.lists[name].length == 0) { 180 | return -1; 181 | } 182 | var intIndex = parseInt(index); 183 | if (isNaN(intIndex) && index !== undefined) { 184 | if (index.toLowerCase() === 'first') { 185 | return 0; 186 | } else if (index.toLowerCase() === 'last') { 187 | return this.lists[name].length - 1; 188 | } 189 | } 190 | if (isNaN(intIndex)) { 191 | return Math.floor(Math.random() * this.lists[name].length); 192 | } 193 | return intIndex; 194 | } 195 | 196 | /** 197 | * Get the value at the given index in the named list. 198 | * @param {string} name of the list 199 | * @param {string} index to add the value in the list 200 | */ 201 | get = (name, index) => { 202 | var intIndex = this.getIndexValue(name, index); 203 | return this.getIndex(name, intIndex); 204 | } 205 | 206 | /** 207 | * Get the value at the given index in the named list. 208 | * @param {string} name of the list 209 | * @param {string} index to add the value in the list 210 | */ 211 | getIndex = (name, index) => { 212 | if (index >= 0 && index < this.lists[name].length) { 213 | return { value: this.lists[name][index], index: index, position: index + 1 }; 214 | } else { 215 | return { value: 'None found', index: -1, position: -1 }; 216 | } 217 | } 218 | 219 | /** 220 | * Remove the value at the given index in the named list. 221 | * @param {string} name of the list 222 | * @param {string} index to add the value in the list 223 | */ 224 | remove = (name, index) => { 225 | var intIndex = this.getIndexValue(name, index); 226 | var response = this.getIndex(name, intIndex); 227 | if (intIndex != -1) { 228 | this.lists[name].splice(intIndex, 1); 229 | if (this.global_lists.indexOf(name) !== -1) { 230 | IDBService.set(`${this.GLOBAL_LIST_KEY_PREFIX}${name}`, this.lists[name]) 231 | } 232 | } 233 | return response; 234 | } 235 | 236 | /** 237 | * Create a named list from the provided items. 238 | * @param {string} name of the list 239 | * @param {array} items to add to the named list 240 | */ 241 | createList = (name, items) => { 242 | this.initialize(name); 243 | items.forEach(item => { 244 | this.add(name, item); 245 | }); 246 | } 247 | } 248 | 249 | /** 250 | * Create a handler 251 | */ 252 | function listHandlerExport() { 253 | var list = new ListHandler(); 254 | } 255 | listHandlerExport(); 256 | -------------------------------------------------------------------------------- /js/message/messageHandler.js: -------------------------------------------------------------------------------- 1 | class MessageHandler extends Handler { 2 | /** 3 | * Create a new Message handler. 4 | */ 5 | constructor() { 6 | super('Message', ['OnMessage']); 7 | this.success(); 8 | this.messages = []; 9 | this.messagesTriggers = {}; 10 | } 11 | 12 | /** 13 | * Register trigger from user input. 14 | * @param {string} trigger name to use for the handler 15 | * @param {array} triggerLine contents of trigger line 16 | * @param {number} id of the new trigger 17 | */ 18 | addTriggerData = (trigger, triggerLine, triggerId) => { 19 | var { messages } = Parser.getInputs(triggerLine, ['messages'], true); 20 | // Handles aliases for OnMessage 21 | messages.forEach(message => { 22 | if (this.messages.indexOf(message) === -1) { 23 | this.messages.push(message); 24 | this.messagesTriggers[message] = []; 25 | } 26 | this.messagesTriggers[message].push(triggerId); 27 | }); 28 | } 29 | 30 | /** 31 | * Handle the input data (take an action). 32 | * @param {array} triggerData contents of trigger line 33 | */ 34 | handleData = async (triggerData) => { 35 | var action = Parser.getAction(triggerData, 'Message'); 36 | if (action === 'send') { 37 | var { message, data } = Parser.getInputs(triggerData, ['action', 'message', 'data'], false, 1); 38 | data = data || ''; 39 | if (this.messages.indexOf(message) !== -1) { 40 | this.messagesTriggers[message].forEach((triggerId) => { 41 | controller.handleData(triggerId, { 42 | message: message, 43 | data: data 44 | }); 45 | }) 46 | } 47 | if (this.messages.indexOf('*') !== -1) { 48 | this.messagesTriggers['*'].forEach((triggerId) => { 49 | controller.handleData(triggerId, { 50 | message: message, 51 | data: data 52 | }); 53 | }) 54 | } 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * Create a handler 61 | */ 62 | function messageHandlerExport() { 63 | var message = new MessageHandler(); 64 | } 65 | messageHandlerExport(); 66 | -------------------------------------------------------------------------------- /js/mqtt/mqtt-websocket.js: -------------------------------------------------------------------------------- 1 | class MQTTWebSocket { 2 | 3 | constructor(address, username, password, onConnect) { 4 | this.address = address; 5 | this.topics = []; 6 | 7 | const options = { 8 | // Clean session 9 | clean: true, 10 | connectTimeout: 4000, 11 | }; 12 | 13 | if (username !== undefined && username !== '' && password !== undefined && password !== '') { 14 | options.username = username; 15 | options.password = password; 16 | } 17 | 18 | this.client = mqtt.connect(this.address, options); 19 | this.client.on('connect', onConnect); 20 | this.client.on('message', (topic, message) => { 21 | if (Debug.All || Debug.MQTT) { 22 | console.error(`MQTT Client ${topic} message received: ${message.toString()}`); 23 | } 24 | // message is Buffer 25 | var s_message = message.toString(); 26 | this._handleCallbacks(topic, s_message); 27 | }); 28 | } 29 | 30 | publish = async (topic, message) => { 31 | if (!this.client.connected) { 32 | return; 33 | } 34 | this.client.publish(topic, message); 35 | } 36 | 37 | subscribe = async (topic, callback) => { 38 | if (this.topics[topic] === undefined) { 39 | this.topics[topic] = []; 40 | this.client.subscribe(topic); 41 | } 42 | this.topics[topic].push(callback); 43 | } 44 | 45 | _handleCallbacks = (topic, message) => { 46 | if (this.topics[topic] !== undefined) { 47 | this.topics[topic].forEach(callback => { 48 | callback(message); 49 | }); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /js/mqtt/mqttHandler.js: -------------------------------------------------------------------------------- 1 | class MQTTHandler extends Handler { 2 | /** 3 | * Create a new MQTT handler. 4 | */ 5 | constructor() { 6 | super('MQTT', ['OnMQTT']); 7 | } 8 | 9 | /** 10 | * Initialize the connection to MQTT broker with the input settings. 11 | * @param {string} address broker websocket address 12 | * @param {string} address username to use for the connection, if any 13 | * @param {string} address password to use for the connection, if any 14 | */ 15 | init = (address, username, password) => { 16 | this.mqtt = new MQTTWebSocket(address, username, password, this.success); 17 | } 18 | 19 | /** 20 | * Register trigger from user input. 21 | * @param {string} trigger name to use for the handler 22 | * @param {array} triggerLine contents of trigger line 23 | * @param {number} id of the new trigger 24 | */ 25 | addTriggerData = (trigger, triggerLine, triggerId) => { 26 | trigger = trigger.toLowerCase(); 27 | switch (trigger) { 28 | case 'onmqtt': 29 | var { topic } = Parser.getInputs(triggerLine, ['topic'], true); 30 | this.mqtt.subscribe(topic, function (message) { 31 | if (Debug.All || Debug.MQTT) { 32 | console.error(`MQTT ${topic} message received: ${message}`); 33 | } 34 | controller.handleData(triggerId, { 35 | topic: topic, 36 | message: message 37 | }); 38 | }); 39 | break; 40 | default: 41 | // Do nothing 42 | } 43 | } 44 | 45 | /** 46 | * Handle the input data (take an action). 47 | * @param {array} triggerData contents of trigger line 48 | */ 49 | handleData = async (triggerData) => { 50 | var action = Parser.getAction(triggerData, 'MQTT'); 51 | switch (action) { 52 | case 'publish': 53 | var { topic, message } = Parser.getInputs(triggerData, ['action', 'topic', 'message']); 54 | await this.mqtt.publish(topic, message); 55 | break; 56 | default: 57 | console.error(`Unable to determine the MQTT to be taken. Found: "${action}" within ${JSON.stringify(triggerData)}.`); 58 | break; 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * Create a handler and read user settings 65 | */ 66 | async function mqttHandlerExport() { 67 | var mqttHandler = new MQTTHandler(); 68 | var address = await readFile('settings/mqtt/websocket.txt'); 69 | var username = await readFile('settings/mqtt/username.txt'); 70 | var password = await readFile('settings/mqtt/password.txt'); 71 | mqttHandler.init(address.trim(), username.trim(), password.trim()); 72 | } 73 | mqttHandlerExport(); 74 | -------------------------------------------------------------------------------- /js/obs/obs-wrapper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Connect to the OBS websocket and setup the event handlers 3 | * @param {string} address obs websocket address 4 | * @param {string} password obs websocket password 5 | * @param {Handler} obsHandler handler to mark successful initialization 6 | * @param {function} onSwitchScenes handle switch scene messages 7 | * @param {function} onTransitionBegin handle transition starts 8 | * @param {function} onStreamStateChange handle stream state change messages 9 | * @param {function} onRecordingStateChange handle recording state change messages 10 | * @param {function} onCustomMessage handle custom messages 11 | * @param {function} onOBSSourceVisibility handle scene item visibility changes 12 | * @param {function} onOBSSourceFilterVisibility handle source filter visibility changes 13 | */ 14 | function connectOBSWebsocket(address, password, obsHandler, onSwitchScenes, onTransitionBegin, onStreamStateChange, onRecordingStateChange, onCustomMessage, onOBSSourceVisibility, onOBSSourceFilterVisibility) { 15 | var obs = new OBSWebSocket(); 16 | obs.connect(address, password).then(async () => { 17 | const initialize = () => { 18 | obs.getVersion().then(async data => { 19 | if (data === undefined) { 20 | console.error("Initial OBS request failed. Retrying...") 21 | setTimeout(initialize, 500); 22 | } else { 23 | console.error(`Kruiz Control connected to the OBS Websocket v${data.obsWebSocketVersion}`); 24 | obsHandler.setCurrentScene(await obs.getCurrentScene()); 25 | obsHandler.success(); 26 | } 27 | }); 28 | } 29 | initialize(); 30 | }).catch(err => { // Promise convention dictates you have a catch on every chain. 31 | console.error(JSON.stringify(err)); 32 | }); 33 | 34 | 35 | // You must add this handler to avoid uncaught exceptions. 36 | obs.on('error', err => { 37 | console.error('OBS Websocket Error:', err); 38 | }); 39 | 40 | // Ws OnClose : try reconnect 41 | obs.on('onclose', function() { 42 | console.error('OBS Websocket Closed'); 43 | }); 44 | 45 | obs.on('Exiting', function() { 46 | console.error('OBS Websocket Exiting'); 47 | obs.disconnect(); 48 | }); 49 | 50 | obs.on('CurrentProgramSceneChanged', onSwitchScenes); 51 | obs.on('SceneTransitionStarted', onTransitionBegin); 52 | obs.on('StreamStateChanged', onStreamStateChange); 53 | obs.on('RecordStateChanged', onRecordingStateChange); 54 | obs.on('CustomEvent', onCustomMessage); 55 | obs.on('SceneItemEnableStateChanged', onOBSSourceVisibility); 56 | obs.on('SourceFilterEnableStateChanged', onOBSSourceFilterVisibility); 57 | 58 | obs.getInputSettings = async function(source) { 59 | return await this.call('GetInputSettings', { 60 | 'inputName': source 61 | }).then(data => { 62 | return data; 63 | }) 64 | } 65 | 66 | obs.getSceneItemId = async function(scene, source) { 67 | return await obs.call('GetSceneItemId', { 68 | sceneName: scene, 69 | sourceName: source 70 | }).then(data => { 71 | return data.sceneItemId; 72 | }).catch(async err => { 73 | if (err.code === 600) { 74 | var group = await this.getSourceGroupName(scene, source); 75 | if (group) { 76 | return await this.getSceneItemId(group, source); 77 | } 78 | } 79 | console.error(JSON.stringify(err)); 80 | }); 81 | } 82 | 83 | obs.getSceneItemName = async function(scene, sceneItemId) { 84 | return await this.call('GetSceneItemList', { 85 | sceneName: scene 86 | }).then(data => { 87 | for (var item of data.sceneItems) { 88 | if (item.sceneItemId === sceneItemId) { 89 | return item.sourceName; 90 | } 91 | }; 92 | }).catch(async err => { 93 | if (err.code === 602) { 94 | return await this.call('GetGroupSceneItemList', { 95 | sceneName: scene 96 | }).then(data => { 97 | for (var item of data.sceneItems) { 98 | if (item.sceneItemId === sceneItemId) { 99 | return item.sourceName; 100 | } 101 | }; 102 | }).catch(groupError => { 103 | console.error(JSON.stringify(groupError)); 104 | }); 105 | } else { 106 | console.error(JSON.stringify(err)); 107 | } 108 | }); 109 | } 110 | 111 | obs.getScenesForGroup = async function(group) { 112 | return await this.call('GetSceneList').then(async data => { 113 | var scenes = data.scenes.map(item => item.sceneName) 114 | if (scenes.indexOf(group) !== -1) { 115 | return [group]; 116 | } 117 | var groupScenes = []; 118 | for (var item of scenes) { 119 | await this.call('GetSceneItemList', { 120 | sceneName: item 121 | }).then(sceneItems => { 122 | for (var subitem of sceneItems.sceneItems) { 123 | if (subitem.sourceName === group) { 124 | groupScenes.push(item); 125 | } 126 | }; 127 | }).catch(sceneItemsError => { 128 | console.error(JSON.stringify(sceneItemsError)); 129 | }); 130 | }; 131 | return groupScenes; 132 | }).catch(groupError => { 133 | console.error(JSON.stringify(groupError)); 134 | }); 135 | } 136 | 137 | // Get the group the contains the provided source within the given scene 138 | obs.getSourceGroupName = async function(scene, source) { 139 | return await this.call('GetSceneItemList', { 140 | sceneName: scene 141 | }).then(async data => { 142 | // Identify groups 143 | for (var item of data.sceneItems) { 144 | if (item.isGroup) { 145 | // Get sources within group until match is found 146 | var result = await this.call('GetGroupSceneItemList', { 147 | sceneName: item.sourceName 148 | }).then(data => { 149 | for (var subitem of data.sceneItems) { 150 | if (subitem.sourceName === source) { 151 | return item.sourceName; 152 | } 153 | }; 154 | }).catch(groupError => { 155 | console.error(JSON.stringify(groupError)); 156 | }); 157 | if (result) { 158 | return result; 159 | } 160 | } 161 | } 162 | }).catch(async err => { 163 | console.error(JSON.stringify(err)); 164 | }); 165 | } 166 | 167 | obs.setInputSettings = async function(source, inputSettings) { 168 | await this.call('SetInputSettings', { 169 | 'inputName': source, 170 | 'inputSettings': inputSettings 171 | }).catch(err => { 172 | console.error(JSON.stringify(err)); 173 | }); 174 | } 175 | 176 | obs.getMediaInputStatus = async function(source) { 177 | return await this.call('GetMediaInputStatus', { 178 | 'inputName': source 179 | }).then(data => { 180 | return data; 181 | }).catch(err => { 182 | console.error(JSON.stringify(err)); 183 | }); 184 | } 185 | 186 | obs.getMediaDuration = async function(source) { 187 | return await this.getMediaInputStatus(source) 188 | .then(data => { 189 | return data.mediaDuration; 190 | }).catch(err => { 191 | console.error(JSON.stringify(err)); 192 | }); 193 | } 194 | 195 | obs.getCurrentScene = async function() { 196 | return await this.call('GetCurrentProgramScene') 197 | .then(data => { 198 | return data.currentProgramSceneName; 199 | }).catch(err => { 200 | console.error(JSON.stringify(err)); 201 | }); 202 | }; 203 | 204 | obs.getSourceVisibility = async function(scene, source) { 205 | return await this.call('GetSceneItemEnabled', { 206 | sceneName: scene, 207 | sceneItemId: await this.getSceneItemId(scene, source) 208 | }).then(data => { 209 | return data.sceneItemEnabled; 210 | }).catch(async err => { 211 | if (err.code === 600) { 212 | var group = await this.getSourceGroupName(scene, source); 213 | if (group) { 214 | return await this.getSourceVisibility(group, source); 215 | } 216 | } 217 | console.error(JSON.stringify(err)); 218 | }); 219 | }; 220 | 221 | obs.getSourceActiveStatus = async function(source) { 222 | return await this.call('GetSourceActive', { 223 | 'sourceName': source 224 | }).then(data => { 225 | return data.videoActive; 226 | }).catch(err => { 227 | console.error(JSON.stringify(err)); 228 | }); 229 | }; 230 | 231 | obs.setSourceVisibility = async function(source, enabled, scene) { 232 | if (!scene) { 233 | scene = await this.getCurrentScene(); 234 | } 235 | await this.call('SetSceneItemEnabled', { 236 | 'sceneItemId': await this.getSceneItemId(scene, source), 237 | 'sceneName': scene, 238 | 'sceneItemEnabled': enabled 239 | }).catch(async err => { 240 | if (err.code === 600) { 241 | var group = await this.getSourceGroupName(scene, source); 242 | if (group) { 243 | await this.setSourceVisibility(source, enabled, group); 244 | return; 245 | } 246 | } 247 | console.error(JSON.stringify(err)); 248 | }); 249 | }; 250 | 251 | obs.setFilterVisibility = async function(source, filter, enabled) { 252 | await this.call('SetSourceFilterEnabled', { 253 | 'sourceName': source, 254 | 'filterName': filter, 255 | 'filterEnabled': enabled 256 | }).catch(err => { 257 | console.error(JSON.stringify(err)); 258 | }); 259 | }; 260 | 261 | obs.setCurrentScene = async function(scene) { 262 | await this.call('SetCurrentProgramScene', { 263 | 'sceneName': scene 264 | }).catch(err => { 265 | console.error(JSON.stringify(err)); 266 | }); 267 | }; 268 | 269 | obs.setMute = async function(source, enabled) { 270 | await this.call('SetInputMute', { 271 | 'inputName': source, 272 | 'inputMuted': enabled 273 | }).catch(err => { 274 | console.error(JSON.stringify(err)); 275 | }); 276 | }; 277 | 278 | obs.toggleMute = async function(source) { 279 | return await this.call('ToggleInputMute', { 280 | 'inputName': source, 281 | }).then(data => { 282 | return data.inputMuted; 283 | }).catch(err => { 284 | console.error(JSON.stringify(err)); 285 | }); 286 | }; 287 | 288 | obs.getVersion = async function() { 289 | return await this.call('GetVersion') 290 | .then(data => { 291 | return data; 292 | }).catch(err => { 293 | console.error(JSON.stringify(err)); 294 | }); 295 | }; 296 | 297 | obs.getVolume = async function(source) { 298 | return await this.call('GetInputVolume', { 299 | 'inputName': source 300 | }).then(data => { 301 | return data; 302 | }).catch(err => { 303 | console.error(JSON.stringify(err)); 304 | }); 305 | }; 306 | 307 | obs.setVolume = async function(source, volume, useDecibel) { 308 | const inputVolumeType = (useDecibel === true) ? 'inputVolumeDb' : 'inputVolumeMul'; 309 | await this.call('SetInputVolume', { 310 | 'inputName': source, 311 | [inputVolumeType]: volume, 312 | }).catch(err => { 313 | console.error(JSON.stringify(err)); 314 | }); 315 | }; 316 | 317 | obs.takeSourceScreenshot = async function(source, filePath) { 318 | let imageFormat = filePath.split('.').pop(); 319 | await this.call('SaveSourceScreenshot', { 320 | 'sourceName': source, 321 | 'imageFilePath': filePath, 322 | 'imageFormat': imageFormat 323 | }).catch(err => { 324 | console.error(JSON.stringify(err)); 325 | }); 326 | }; 327 | 328 | obs.triggerMediaInputAction = async function(sourceName, mediaAction) { 329 | await this.call('TriggerMediaInputAction', { 330 | 'inputName': sourceName, 331 | 'mediaAction': mediaAction 332 | }).catch(err => { 333 | console.error(JSON.stringify(err)); 334 | }); 335 | }; 336 | 337 | obs.startStream = async function() { 338 | await this.call('StartStream').catch(err => { 339 | console.error(JSON.stringify(err)); 340 | }); 341 | }; 342 | 343 | obs.stopStream = async function() { 344 | await this.call('StopStream').catch(err => { 345 | console.error(JSON.stringify(err)); 346 | }); 347 | }; 348 | 349 | obs.getRecordingStatus = async function() { 350 | return await this.call('GetRecordStatus').then(data => { 351 | return data; 352 | }).catch(err => { 353 | console.error(JSON.stringify(err)); 354 | }); 355 | }; 356 | 357 | obs.pauseRecording = async function() { 358 | await this.call('PauseRecord').catch(err => { 359 | console.error(JSON.stringify(err)); 360 | }); 361 | }; 362 | 363 | obs.resumeRecording = async function() { 364 | await this.call('ResumeRecord').catch(err => { 365 | console.error(JSON.stringify(err)); 366 | }); 367 | }; 368 | 369 | obs.startRecording = async function() { 370 | await this.call('StartRecord').catch(err => { 371 | console.error(JSON.stringify(err)); 372 | }); 373 | }; 374 | 375 | obs.stopRecording = async function() { 376 | await this.call('StopRecord').catch(err => { 377 | console.error(JSON.stringify(err)); 378 | }); 379 | }; 380 | 381 | obs.startReplayBuffer = async function() { 382 | await this.call('StartReplayBuffer').catch(err => { 383 | console.error(JSON.stringify(err)); 384 | }); 385 | }; 386 | 387 | obs.stopReplayBuffer = async function() { 388 | await this.call('StopReplayBuffer').catch(err => { 389 | console.error(JSON.stringify(err)); 390 | }); 391 | }; 392 | 393 | obs.saveReplayBuffer = async function() { 394 | await this.call('SaveReplayBuffer').catch(err => { 395 | console.error(JSON.stringify(err)); 396 | }); 397 | }; 398 | 399 | obs.broadcastCustomMessage = async function(message, data) { 400 | await this.call('BroadcastCustomEvent', { 401 | 'eventData': { 402 | 'realm': 'kruiz-control', 403 | 'data': { 404 | 'message': message, 405 | 'data': data 406 | } 407 | } 408 | }).catch(err => { 409 | console.error(JSON.stringify(err)); 410 | }); 411 | }; 412 | 413 | obs.refreshBrowser = async function(sourceName) { 414 | await this.call('PressInputPropertiesButton', { 415 | 'inputName': sourceName, 416 | 'propertyName': 'refreshnocache' 417 | }).catch(err => { 418 | console.error(JSON.stringify(err)); 419 | }); 420 | }; 421 | 422 | obs.addSceneItem = async function(scene, source, status) { 423 | await this.call('CreateSceneItem', { 424 | 'sceneName': scene, 425 | 'sourceName': source, 426 | 'sceneItemEnabled': status 427 | }).catch(err => { 428 | console.error(JSON.stringify(err)); 429 | }); 430 | }; 431 | 432 | obs.getSceneItemTransform = async function(scene, source) { 433 | var data = await this.call('GetSceneItemTransform', { 434 | 'sceneName': scene, 435 | 'sceneItemId': await this.getSceneItemId(scene, source) 436 | }).catch(async err => { 437 | if (err.code === 600) { 438 | var group = await this.getSourceGroupName(scene, source); 439 | if (group) { 440 | return await this.getSceneItemTransform(group, source); 441 | } 442 | } 443 | console.error(JSON.stringify(err)); 444 | }); 445 | return data; 446 | } 447 | 448 | obs.getSceneItemPosition = async function(scene, source) { 449 | return await this.getSceneItemTransform(scene, source) 450 | .then(data => { 451 | return { 452 | x: data.sceneItemTransform.positionX, 453 | y: data.sceneItemTransform.positionY 454 | }; 455 | }); 456 | } 457 | 458 | obs.setSceneItemPosition = async function(scene, source, x, y) { 459 | await this.call('SetSceneItemTransform', { 460 | 'sceneName': scene, 461 | 'sceneItemId': await this.getSceneItemId(scene, source), 462 | 'sceneItemTransform': { 463 | 'positionX': x, 464 | 'positionY': y 465 | } 466 | }).catch(async err => { 467 | if (err.code === 600) { 468 | var group = await this.getSourceGroupName(scene, source); 469 | if (group) { 470 | await this.setSceneItemPosition(group, source, x, y); 471 | return; 472 | } 473 | } 474 | console.error(JSON.stringify(err)); 475 | }); 476 | }; 477 | 478 | obs.getSourceFilter = async function(source, filter) { 479 | return await this.call('GetSourceFilter', { 480 | 'sourceName': source, 481 | 'filterName': filter 482 | }).catch(err => { 483 | console.error(JSON.stringify(err)); 484 | }); 485 | } 486 | 487 | obs.getSceneItemSize = async function(scene, source) { 488 | return await this.getSceneItemTransform(scene, source) 489 | .then(data => { 490 | return { 491 | height: data.sceneItemTransform.height, 492 | sourceHeight: data.sceneItemTransform.sourceHeight, 493 | sourceWidth: data.sceneItemTransform.sourceWidth, 494 | width: data.sceneItemTransform.width 495 | }; 496 | }).catch(err => { 497 | console.error(JSON.stringify(err)); 498 | }); 499 | } 500 | 501 | obs.setSceneItemSize = async function(scene, source, scaleX, scaleY) { 502 | await this.call('SetSceneItemTransform', { 503 | 'sceneName': scene, 504 | 'sceneItemId': await this.getSceneItemId(scene, source), 505 | 'sceneItemTransform': { 506 | 'scaleX': scaleX, 507 | 'scaleY': scaleY 508 | } 509 | }).catch(async err => { 510 | if (err.code === 600) { 511 | var group = await this.getSourceGroupName(scene, source); 512 | if (group) { 513 | await this.setSceneItemSize(group, source, scaleX, scaleY); 514 | return; 515 | } 516 | } 517 | console.error(JSON.stringify(err)); 518 | }); 519 | }; 520 | 521 | obs.setSceneItemRotation = async function(scene, source, rotation) { 522 | await this.call('SetSceneItemTransform', { 523 | 'sceneName': scene, 524 | 'sceneItemId': await this.getSceneItemId(scene, source), 525 | 'sceneItemTransform': { 526 | 'rotation': rotation 527 | } 528 | }).catch(async err => { 529 | if (err.code === 600) { 530 | var group = await this.getSourceGroupName(scene, source); 531 | if (group) { 532 | await this.setSceneItemRotation(group, source, rotation); 533 | return; 534 | } 535 | } 536 | console.error(JSON.stringify(err)); 537 | }); 538 | }; 539 | 540 | obs.getCurrentTransition = async function() { 541 | return await this.call('GetCurrentSceneTransition') 542 | .then(data => { 543 | return data.transitionName; 544 | }).catch(err => { 545 | console.error(JSON.stringify(err)); 546 | }); 547 | }; 548 | 549 | obs.setCurrentTransition = async function(transition) { 550 | await this.call('SetCurrentSceneTransition', { 551 | 'transitionName': transition 552 | }).catch(err => { 553 | console.error(JSON.stringify(err)); 554 | }); 555 | }; 556 | 557 | obs.getStats = async function() { 558 | return await this.call('GetStats').then(data => { 559 | return data; 560 | }).catch(err => { 561 | console.error(JSON.stringify(err)); 562 | }); 563 | } 564 | 565 | obs.getStreamStatus = async function() { 566 | return await this.call('GetStreamStatus').then(data => { 567 | return data; 568 | }).catch(err => { 569 | console.error(JSON.stringify(err)); 570 | }); 571 | } 572 | 573 | obs.getSceneItemIndex = async function(scene, source) { 574 | var data = await this.call('GetSceneItemIndex', { 575 | 'sceneName': scene, 576 | 'sceneItemId': await this.getSceneItemId(scene, source) 577 | }).catch(async err => { 578 | if (err.code === 600) { 579 | var group = await this.getSourceGroupName(scene, source); 580 | if (group) { 581 | return await this.getSceneItemIndex(group, source); 582 | } 583 | } 584 | console.error(JSON.stringify(err)); 585 | }); 586 | return data; 587 | } 588 | 589 | obs.setSceneItemIndex = async function(scene, source, index) { 590 | await this.call('SetSceneItemIndex', { 591 | 'sceneName': scene, 592 | 'sceneItemId': await this.getSceneItemId(scene, source), 593 | 'sceneItemIndex': index 594 | }).catch(async err => { 595 | if (err.code === 600) { 596 | var group = await this.getSourceGroupName(scene, source); 597 | if (group) { 598 | await this.setSceneItemIndex(group, source, index); 599 | return; 600 | } 601 | } 602 | console.error(JSON.stringify(err)); 603 | }); 604 | }; 605 | 606 | return obs; 607 | } 608 | -------------------------------------------------------------------------------- /js/param/paramHandler.js: -------------------------------------------------------------------------------- 1 | class ParamHandler extends Handler { 2 | /** 3 | * Create a new Param handler. 4 | */ 5 | constructor() { 6 | super('Param', []); 7 | this.success(); 8 | } 9 | 10 | /** 11 | * Handle the input data (take an action). 12 | * @param {array} triggerData contents of trigger line 13 | * @param {array} parameters current trigger parameters 14 | */ 15 | handleData = async (triggerData, parameters) => { 16 | var action = Parser.getAction(triggerData); 17 | 18 | switch (action) { 19 | case 'create': 20 | var { name, value } = Parser.getInputs(triggerData, ['action', 'name', 'value']); 21 | return { [name]: value }; 22 | break; 23 | case 'lower': 24 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 25 | if (parameters.hasOwnProperty(name)) { 26 | return { [name]: parameters[name].toLowerCase() }; 27 | } 28 | break; 29 | case 'upper': 30 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 31 | if (parameters.hasOwnProperty(name)) { 32 | return { [name]: parameters[name].toUpperCase() }; 33 | } 34 | break; 35 | case 'proper': 36 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 37 | if (parameters.hasOwnProperty(name)) { 38 | return { [name]: parameters[name].toProperCase() }; 39 | } 40 | break; 41 | case 'add': 42 | var { name, value } = Parser.getInputs(triggerData, ['action', 'name', 'value']); 43 | if (parameters.hasOwnProperty(name)) { 44 | value = parseFloat(value); 45 | var paramValue = parseFloat(parameters[name]); 46 | if (!isNaN(value) && !isNaN(paramValue)) { 47 | return { [name]: paramValue + value }; 48 | } 49 | } 50 | break; 51 | case 'subtract': 52 | var { name, value } = Parser.getInputs(triggerData, ['action', 'name', 'value']); 53 | if (parameters.hasOwnProperty(name)) { 54 | value = parseFloat(value); 55 | var paramValue = parseFloat(parameters[name]); 56 | if (!isNaN(value) && !isNaN(paramValue)) { 57 | return { [name]: paramValue - value }; 58 | } 59 | } 60 | break; 61 | case 'negate': 62 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 63 | if (parameters.hasOwnProperty(name)) { 64 | switch(String(parameters[name]).toLowerCase()) { 65 | case "false": 66 | case "no": 67 | case "0": 68 | case "": 69 | return { [name]: true }; 70 | default: 71 | return { [name]: false }; 72 | } 73 | } 74 | break; 75 | case 'exists': 76 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 77 | return { exists: parameters.hasOwnProperty(name) }; 78 | break; 79 | case 'copy': 80 | var { name, toName } = Parser.getInputs(triggerData, ['action', 'name', 'toName']); 81 | if (parameters.hasOwnProperty(name)) { 82 | return { [toName]: parameters[name] }; 83 | } 84 | break; 85 | case 'replace': 86 | var { name, toReplace, replacement } = Parser.getInputs(triggerData, ['action', 'name', 'toReplace', 'replacement']); 87 | if (parameters.hasOwnProperty(name)) { 88 | return { [name]: parameters[name].replace(new RegExp(escapeRegExp(toReplace), 'g'), () => replacement) }; 89 | } 90 | break; 91 | case 'keyword': 92 | var { name, keywords } = Parser.getInputs(triggerData, ['action', 'name', 'keywords'], true); 93 | if (parameters.hasOwnProperty(name)) { 94 | keywords.forEach((keyword, index) => { 95 | keywords[index] = escapeRegExp(keyword.trim()); 96 | }); 97 | var regex = new RegExp('(?:^|\\s)' + keywords.join('(?:$|\\s)|(?:^|\\s)') + '(?:$|\\s)', 'gi'); 98 | var result = parameters[name].match(regex); 99 | var matched = false; 100 | if (result) { 101 | matched = true; 102 | var match = result[0].trim(); 103 | result.forEach((res, index) => { 104 | result[index] = res.trim(); 105 | }); 106 | } 107 | return { matched, match, keywords: result }; 108 | } 109 | break; 110 | default: 111 | console.error(`Unexpected Param action (${action}). Check your event code.`); 112 | break; 113 | } 114 | } 115 | } 116 | 117 | /** 118 | * Create a handler 119 | */ 120 | function paramHandlerExport() { 121 | var param = new ParamHandler(); 122 | } 123 | paramHandlerExport(); 124 | -------------------------------------------------------------------------------- /js/parser.js: -------------------------------------------------------------------------------- 1 | class Parser { 2 | 3 | /** 4 | * Splits the input string into arguments. 5 | * @param {string} line string to split 6 | */ 7 | static splitLine(line) { 8 | var items = []; 9 | try { 10 | items = shlexSplit(line); 11 | } catch (err) { 12 | items = line.split(' '); 13 | } 14 | return items; 15 | } 16 | 17 | /** 18 | * Parse the file contents into a list of commands. 19 | * Primarily to account for multi-line strings in trigger files. 20 | * @param {array} lines file content containing commands 21 | */ 22 | static parseCommands(lines) { 23 | var compiledLineData = []; 24 | for (var i = 0; i < lines.length; i++) { 25 | var line = lines[i].trim(); 26 | if (!line.startsWith('#')) { 27 | var [isParsed, lineData] = Parser.tryParseCommand(line); 28 | 29 | // If failed to parse, look for a multi-line input 30 | if (!isParsed) { 31 | var j = i; 32 | var compiledLine = lines[i]; 33 | while(!isParsed && j < lines.length) { 34 | j++; 35 | var compiledLine = `${compiledLine}\n${lines[j]}`; 36 | [isParsed, lineData] = Parser.tryParseCommand(compiledLine); 37 | } 38 | 39 | // If no multi-line string found, simply split the line 40 | if (!isParsed) { 41 | lineData = line.split(' '); 42 | } else { 43 | // Skip all lines in the multi-line string 44 | i = j; 45 | } 46 | } 47 | 48 | compiledLineData.push(lineData); 49 | } 50 | } 51 | 52 | return compiledLineData; 53 | } 54 | 55 | /** 56 | * Try to parse the input string using the shlex parsing library. 57 | * @param {string} data input string to parse for commands 58 | */ 59 | static tryParseCommand(data) { 60 | try { 61 | var commandData = shlexSplit(data); 62 | return [true, commandData]; 63 | } catch (err) { 64 | return [false, null]; 65 | } 66 | } 67 | 68 | /** 69 | * Get the action from the input line information. 70 | * @param {array} lineData input to grab the action 71 | * @param {string} handler the name of the handler retrieving the action 72 | * @param {numeric} offset offset to apply when retrieving the action 73 | */ 74 | static getAction(lineData, handler, offset) { 75 | offset = offset || 0; 76 | if (lineData.length > offset + 1) { 77 | return lineData[offset + 1].toLowerCase(); 78 | } 79 | console.error(`Unable to get a ${handler} action from: ${JSON.stringify(lineData)}`); 80 | return ""; 81 | } 82 | 83 | /** 84 | * Get the input values for the given line. 85 | * @param {array} lineData input to grab the action 86 | * @param {array} inputNames the list of input names to grab 87 | * @param {false} supportsAliases true if the input allows aliases 88 | * @param {numeric} optionalInputs number of optional inputs to check 89 | */ 90 | static getInputs(lineData, inputNames, supportsAliases, optionalInputs) { 91 | supportsAliases = supportsAliases || false; 92 | optionalInputs = optionalInputs || 0; 93 | var res = {}; 94 | 95 | var totalPossibleInputs = inputNames.length; 96 | if (supportsAliases) { 97 | res[inputNames[totalPossibleInputs-1]] = []; 98 | } 99 | 100 | if (lineData.length > totalPossibleInputs - optionalInputs) { 101 | if (!supportsAliases && lineData.length > totalPossibleInputs + 1) { 102 | console.error(`Kruiz Control: Too many inputs provided for ${lineData[0]}.\r\nExpected (${totalPossibleInputs} inputs): ${JSON.stringify(inputNames)}\r\nFound (${lineData.length - 1} inputs): ${JSON.stringify(lineData.slice(1))}`); 103 | } 104 | 105 | lineData.forEach((item, i) => { 106 | if (i != 0) { 107 | if (i < totalPossibleInputs) { 108 | res[inputNames[i-1]] = item; 109 | } else if (i == totalPossibleInputs && supportsAliases) { 110 | res[inputNames[i-1]] = lineData.slice(i); 111 | } else if (i == totalPossibleInputs) { 112 | res[inputNames[i-1]] = lineData.slice(i).join(' '); 113 | } 114 | } 115 | }); 116 | 117 | if (Debug.All || Debug.Parser) { 118 | console.error(`Values parsed from ${JSON.stringify(lineData)}: ${JSON.stringify(res)}`); 119 | } 120 | return res; 121 | } 122 | 123 | console.error(`Kruiz Control: Unable to retrieve enough inputs for ${lineData[0]}.\r\nExpected (${totalPossibleInputs} inputs): ${JSON.stringify(inputNames)}\r\nFound (${lineData.length - 1} inputs): ${JSON.stringify(lineData.slice(1))}`); 124 | return {}; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /js/random/randomHandler.js: -------------------------------------------------------------------------------- 1 | class RandomHandler extends Handler { 2 | /** 3 | * Create a new Chat handler. 4 | */ 5 | constructor() { 6 | super('Random', []); 7 | this.success(); 8 | } 9 | 10 | /** 11 | * Handle the input data (take an action). 12 | * @param {array} triggerData contents of trigger line 13 | */ 14 | handleData = async (triggerData) => { 15 | var action = Parser.getAction(triggerData, 'Random'); 16 | if (action == 'number') { 17 | var { tmpmin, tmpmax } = Parser.getInputs(triggerData, ['action', 'tmpmin', 'tmpmax'], false, 2); 18 | var min = 0; 19 | var max = 100; 20 | if (tmpmin) { 21 | tmpmin = parseFloat(tmpmin); 22 | if (!isNaN(tmpmin)) { 23 | min = tmpmin; 24 | } 25 | if (tmpmax) { 26 | tmpmax = parseFloat(tmpmax); 27 | if (!isNaN(tmpmax)) { 28 | max = tmpmax; 29 | } 30 | } 31 | } 32 | var value = Math.floor((Math.random() * (max + 1 - min)) + min); 33 | return { number: value }; 34 | } else if (action == 'probability') { 35 | var actions = {}; 36 | var total = 0; 37 | for (var i = 2; i + 1 < triggerData.length; i = i + 2) { 38 | var action = triggerData[i]; 39 | var prob = parseFloat(triggerData[i+1]); 40 | if (isNaN(prob)) { 41 | return; 42 | } 43 | actions[action] = prob; 44 | total += prob; 45 | } 46 | if (total > 0) { 47 | var multiplier = 100 / total; 48 | var index = 0; 49 | var prev = 0; 50 | var actionIndexes = []; 51 | for (var action in actions) { 52 | var value = (actions[action] * multiplier) + prev; 53 | prev = value; 54 | actionIndexes.push([value, action]); 55 | } 56 | var probability = Math.random() * 100; 57 | for (var i = 0; i < actionIndexes.length; i++) { 58 | if (probability < actionIndexes[i][0]) { 59 | return { actions: [actionIndexes[i][1]] }; 60 | } 61 | } 62 | } 63 | } else { 64 | var exclude = 2; 65 | if (triggerData[1].toLowerCase() !== 'equal') { 66 | exclude = 1; 67 | } 68 | var choice = Math.floor(Math.random() * (triggerData.length - exclude)) + exclude; 69 | return { actions: [triggerData[choice]] }; 70 | } 71 | } 72 | } 73 | 74 | /** 75 | * Create a handler 76 | */ 77 | function randomHandlerExport() { 78 | var random = new RandomHandler(); 79 | } 80 | randomHandlerExport(); 81 | -------------------------------------------------------------------------------- /js/slobs/slobs-websocket.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Connect to Streamlabs OBS JSON RPC API and setup the event handlers 3 | * @param {Handler} slobsHandler handler to mark successful initialization 4 | * @param {string} token SLOBS API Token 5 | * @param {function} onSwitchScenes handle switch scene messages 6 | * @param {function} onStreamStart handle stream start messages 7 | * @param {function} onStreamStop handle stream stop messages 8 | */ 9 | function connectSLOBSWebsocket(slobsHandler, token, onSwitchScenes, onStreamStarted, onStreamStopped) { 10 | var socket = new SockJS('http://127.0.0.1:59650/api'); 11 | var slobsSocket = { 12 | requestId: 1, 13 | socket: socket, 14 | scenes: {}, 15 | activeScene: '', 16 | responses: {} 17 | }; 18 | 19 | socket.onopen = () => { 20 | slobsHandler.success(); 21 | slobsSocket.sendSLOBS("auth", "TcpServerService", [token]) 22 | slobsSocket.sendSLOBS("getScenes", "ScenesService"); 23 | slobsSocket.sendSLOBS("activeScene", "ScenesService"); 24 | slobsSocket.sendSLOBS("sceneSwitched", "ScenesService"); 25 | slobsSocket.sendSLOBS("sceneAdded", "ScenesService"); 26 | slobsSocket.sendSLOBS("sceneRemoved", "ScenesService"); 27 | slobsSocket.sendSLOBS("streamingStatusChange", "StreamingService"); 28 | }; 29 | 30 | socket.onmessage = (e) => { 31 | if (e.type === 'message') { 32 | if (Debug.All || Debug.SLOBS) { 33 | console.error('SLOBS Message: ' + e.data); 34 | } 35 | var data = JSON.parse(e.data); 36 | 37 | if (data.error) { 38 | console.error(`Received SLOBS error: ${JSON.stringify(data)}`); 39 | } else { 40 | if (data.id === 2) { 41 | data.result.forEach(scene => { 42 | slobsSocket.scenes[scene.name] = scene; 43 | }); 44 | } else if (data.id === 3) { 45 | slobsSocket.activeScene = data.result.name; 46 | } else if (data.result && data.result.resourceId === 'ScenesService.sceneAdded' && data.result.data) { 47 | slobsSocket.scenes[data.result.data.name] = data.result.data; 48 | } else if (data.result && data.result.resourceId === 'ScenesService.sceneRemoved' && data.result.data) { 49 | delete slobsSocket.scenes[data.result.data.name]; 50 | } else if (data.result && data.result.resourceId === 'ScenesService.sceneSwitched' && data.result.data) { 51 | slobsSocket.activeScene = data.result.data.name; 52 | onSwitchScenes(data.result.data); 53 | } else if (data.result && data.result.resourceId === 'StreamingService.streamingStatusChange' && data.result.data) { 54 | if (data.result.data === 'starting') { 55 | onStreamStarted(); 56 | } else if (data.result.data === 'ending') { 57 | onStreamStopped(); 58 | } 59 | } else if (slobsSocket.responses.hasOwnProperty(data.id.toString())) { 60 | slobsSocket.responses[data.id][0](data, ...slobsSocket.responses[data.id][1]); 61 | delete slobsSocket.responses[data.id]; 62 | } 63 | } 64 | } 65 | }; 66 | 67 | socket.onclose = (e) => { 68 | console.log('Closed SLOBS connection', e); 69 | }; 70 | 71 | slobsSocket.getCurrentScene = function() { 72 | return slobsSocket.activeScene; 73 | } 74 | 75 | slobsSocket.setCurrentScene = function(scene) { 76 | if (slobsSocket.scenes[scene]) { 77 | var current = slobsSocket.activeScene; 78 | slobsSocket.sendSLOBS('makeSceneActive', 'ScenesService', [slobsSocket.scenes[scene].id]) 79 | return { previous_scene: current }; 80 | } else { 81 | console.error('No scene found with name', scene); 82 | } 83 | } 84 | 85 | slobsSocket.setSourceVisibility = function(scene, source, enabled) { 86 | scene = scene || slobsSocket.activeScene; 87 | var sceneInfo = slobsSocket.scenes[scene]; 88 | if (sceneInfo) { 89 | sceneInfo.nodes.forEach(sceneItem => { 90 | if (sceneItem.name === source) { 91 | var sceneItemId = `SceneItem["${sceneInfo.id}","${sceneItem.id}","${sceneItem.sourceId}"]`; 92 | slobsSocket.sendSLOBS("setVisibility", sceneItemId, [enabled]); 93 | } 94 | }); 95 | } 96 | } 97 | 98 | slobsSocket.getSourceVisibility = async function(scene, source) { 99 | scene = scene || slobsSocket.activeScene; 100 | var sceneInfo = slobsSocket.scenes[scene]; 101 | return await new Promise((resolve) => { 102 | var id = slobsSocket.sendSLOBS("getScene", "ScenesService", [sceneInfo.id]); 103 | slobsSocket.responses[id] = [this._getSourceVisibility, [resolve, source]]; 104 | }); 105 | } 106 | 107 | slobsSocket._getSourceVisibility = async function(sceneInfo, resolve, source) { 108 | sceneInfo.result.nodes.forEach(async sceneItem => { 109 | if (sceneItem.name === source) { 110 | resolve(sceneItem.visible); 111 | } 112 | }); 113 | } 114 | 115 | slobsSocket.setFolderVisibility = function(scene, folder, enabled) { 116 | scene = scene || slobsSocket.activeScene; 117 | var sceneInfo = slobsSocket.scenes[scene]; 118 | if (sceneInfo) { 119 | sceneInfo.nodes.forEach(sceneItem => { 120 | if (sceneItem.name === folder && sceneItem.sceneNodeType === 'folder') { 121 | sceneInfo.nodes.forEach(source => { 122 | if (sceneItem.childrenIds.indexOf(source.id) !== -1) { 123 | var sceneItemId = `SceneItem["${sceneInfo.id}","${source.id}","${source.sourceId}"]`; 124 | slobsSocket.sendSLOBS("setVisibility", sceneItemId, [enabled]); 125 | } 126 | }); 127 | } 128 | }); 129 | } 130 | } 131 | 132 | slobsSocket.flipSourceX = function(scene, source) { 133 | scene = scene || slobsSocket.activeScene; 134 | var sceneInfo = slobsSocket.scenes[scene]; 135 | if (sceneInfo) { 136 | sceneInfo.nodes.forEach(sceneItem => { 137 | if (sceneItem.name === source) { 138 | var sceneItemId = `SceneItem["${sceneInfo.id}","${sceneItem.id}","${sceneItem.sourceId}"]`; 139 | slobsSocket.sendSLOBS("flipX", sceneItemId); 140 | } 141 | }); 142 | } 143 | } 144 | 145 | slobsSocket.flipSourceY = function(scene, source) { 146 | scene = scene || slobsSocket.activeScene; 147 | var sceneInfo = slobsSocket.scenes[scene]; 148 | if (sceneInfo) { 149 | sceneInfo.nodes.forEach(sceneItem => { 150 | if (sceneItem.name === source) { 151 | var sceneItemId = `SceneItem["${sceneInfo.id}","${sceneItem.id}","${sceneItem.sourceId}"]`; 152 | slobsSocket.sendSLOBS("flipY", sceneItemId); 153 | } 154 | }); 155 | } 156 | } 157 | 158 | slobsSocket.setVolume = async function(source, volume) { 159 | return await new Promise((resolve) => { 160 | var id = slobsSocket.sendSLOBS("getSources", "AudioService"); 161 | slobsSocket.responses[id] = [slobsSocket._setVolume, [resolve, source, volume]]; 162 | }); 163 | } 164 | 165 | slobsSocket._setVolume = async function(data, resolve, source, volume) { 166 | data.result.forEach((sourceByName, i) => { 167 | if (sourceByName.name === source) { 168 | resolve(sourceByName.fader.deflection); 169 | slobsSocket.sendSLOBS("setDeflection", sourceByName.resourceId, [volume]); 170 | } 171 | }); 172 | } 173 | 174 | slobsSocket.rotateSource = function(scene, source, degree) { 175 | scene = scene || slobsSocket.activeScene; 176 | var sceneInfo = slobsSocket.scenes[scene]; 177 | if (sceneInfo) { 178 | sceneInfo.nodes.forEach(sceneItem => { 179 | if (sceneItem.name === source) { 180 | var sceneItemId = `SceneItem["${sceneInfo.id}","${sceneItem.id}","${sceneItem.sourceId}"]`; 181 | slobsSocket.sendSLOBS("setTransform", sceneItemId, [{rotation: degree}]); 182 | } 183 | }); 184 | } 185 | } 186 | 187 | slobsSocket.setAudioMute = function(source, isMuted) { 188 | var id = slobsSocket.sendSLOBS("getSourcesForCurrentScene", "AudioService"); 189 | slobsSocket.responses[id] = [slobsSocket._setAudioMute, [source, isMuted]]; 190 | } 191 | 192 | slobsSocket._setAudioMute = function(data, source, isMuted) { 193 | data.result.forEach((audioSource, i) => { 194 | if (audioSource.name === source) { 195 | if (isMuted == 'toggle') { 196 | isMuted = !audioSource.muted 197 | } 198 | slobsSocket.sendSLOBS("setMuted", audioSource.resourceId, [isMuted]); 199 | } 200 | }); 201 | } 202 | 203 | slobsSocket.setPosition = function(scene, source, x, y) { 204 | scene = scene || slobsSocket.activeScene; 205 | var sceneInfo = slobsSocket.scenes[scene]; 206 | if (sceneInfo) { 207 | sceneInfo.nodes.forEach(sceneItem => { 208 | if (sceneItem.name === source) { 209 | var sceneItemId = `SceneItem["${sceneInfo.id}","${sceneItem.id}","${sceneItem.sourceId}"]`; 210 | slobsSocket.sendSLOBS("setTransform", sceneItemId, [{position: { x: x, y: y} }]); 211 | } 212 | }); 213 | } 214 | } 215 | 216 | slobsSocket.toggleStream = function() { 217 | slobsSocket.sendSLOBS("toggleStreaming", 'StreamingService'); 218 | } 219 | 220 | slobsSocket.startReplayBuffer = function() { 221 | slobsSocket.sendSLOBS("startReplayBuffer", 'StreamingService'); 222 | } 223 | 224 | slobsSocket.stopReplayBuffer = function() { 225 | slobsSocket.sendSLOBS("stopReplayBuffer", 'StreamingService'); 226 | } 227 | 228 | slobsSocket.saveReplayBuffer = function() { 229 | slobsSocket.sendSLOBS("saveReplay", 'StreamingService'); 230 | } 231 | 232 | slobsSocket.pushNotification = function(message) { 233 | slobsSocket.sendSLOBS("push", 'NotificationsService', [{message: message}]); 234 | } 235 | 236 | slobsSocket.sendSLOBS = function(method, resource, args) { 237 | args = args || []; 238 | var request = { 239 | jsonrpc: "2.0", 240 | method: method, 241 | params: { 242 | resource: resource, 243 | args: args 244 | }, 245 | id: slobsSocket.requestId++ 246 | } 247 | slobsSocket.socket.send(JSON.stringify(request)); 248 | return request.id; 249 | } 250 | 251 | return slobsSocket; 252 | } 253 | -------------------------------------------------------------------------------- /js/slobs/slobsHandler.js: -------------------------------------------------------------------------------- 1 | class SLOBSHandler extends Handler { 2 | /** 3 | * Create a new SLOBS handler. 4 | */ 5 | constructor() { 6 | super('SLOBS', ['OnSLOBSSwitchScenes', 'OnSLOBSStreamStarted', 'OnSLOBSStreamStopped']); 7 | this.onSwitch = []; 8 | this.onSwitchTrigger = {}; 9 | this.onStartTrigger = []; 10 | this.onStopTrigger = []; 11 | } 12 | 13 | /** 14 | * Initialize the connection to SLOBS with the input settings. 15 | * @param {string} token slobs api token 16 | */ 17 | init(token) { 18 | this.slobs = connectSLOBSWebsocket( 19 | this, 20 | token, 21 | this.onSwitchScenes, 22 | this.onStreamStart, 23 | this.onStreamStop 24 | ); 25 | } 26 | 27 | /** 28 | * Register trigger from user input. 29 | * @param {string} trigger name to use for the handler 30 | * @param {array} triggerLine contents of trigger line 31 | * @param {number} id of the new trigger 32 | */ 33 | addTriggerData = (trigger, triggerLine, triggerId) => { 34 | trigger = trigger.toLowerCase(); 35 | switch (trigger) { 36 | case 'onslobsswitchscenes': 37 | var { scenes } = Parser.getInputs(triggerLine, ['scenes'], true); 38 | scenes.forEach(scene => { 39 | if (this.onSwitch.indexOf(scene) === -1) { 40 | this.onSwitch.push(scene); 41 | this.onSwitchTrigger[scene] = []; 42 | } 43 | this.onSwitchTrigger[scene].push(triggerId); 44 | }); 45 | break; 46 | case 'onslobsstreamstarted': 47 | this.onStartTrigger.push(triggerId); 48 | break; 49 | case 'onslobsstreamstopped': 50 | this.onStopTrigger.push(triggerId); 51 | break; 52 | default: 53 | // do nothing 54 | } 55 | return; 56 | } 57 | 58 | /** 59 | * Handle switch scene messages from slobs subscription. 60 | * @param {Object} data scene information 61 | */ 62 | onSwitchScenes = async (data) => { 63 | var sceneTriggers = []; 64 | if (this.onSwitch.indexOf(data.name) !== -1) { 65 | sceneTriggers.push(...this.onSwitchTrigger[data.name]); 66 | } 67 | if (this.onSwitch.indexOf('*') !== -1) { 68 | sceneTriggers.push(...this.onSwitchTrigger['*']); 69 | } 70 | if (sceneTriggers.length > 0) { 71 | sceneTriggers.sort((a,b) => a-b); 72 | sceneTriggers.forEach(triggerId => { 73 | controller.handleData(triggerId, { 74 | scene: data.name 75 | }); 76 | }); 77 | } 78 | } 79 | 80 | /** 81 | * Handle stream start messages from slobs subscription. 82 | */ 83 | onStreamStart = () => { 84 | if (this.onStartTrigger.length > 0) { 85 | this.onStartTrigger.forEach(trigger => { 86 | controller.handleData(trigger); 87 | }) 88 | } 89 | } 90 | 91 | /** 92 | * Handle stream stop messages from slobs subscription. 93 | */ 94 | onStreamStop = () => { 95 | if (this.onStopTrigger.length > 0) { 96 | this.onStopTrigger.forEach(trigger => { 97 | controller.handleData(trigger); 98 | }) 99 | } 100 | } 101 | 102 | /** 103 | * Handle the input data (take an action). 104 | * @param {array} triggerData contents of trigger line 105 | */ 106 | handleData = async (triggerData) => { 107 | var action = Parser.getAction(triggerData, 'SLOBS'); 108 | switch (action) { 109 | case 'currentscene': 110 | var scene = this.slobs.getCurrentScene(); 111 | return { current_scene: scene }; 112 | break; 113 | case 'isscenesourcevisible': 114 | var { scene, source } = Parser.getInputs(triggerData, ['action', 'scene', 'source']); 115 | var data = await this.slobs.getSourceVisibility(scene, source); 116 | return { is_visible: data }; 117 | break; 118 | case 'scene': 119 | var { scene } = Parser.getInputs(triggerData, ['action', 'scene']); 120 | return await this.slobs.setCurrentScene(scene); 121 | break; 122 | case 'source': 123 | var { source, status } = Parser.getInputs(triggerData, ['action', 'source', 'status']); 124 | status = status.toLowerCase() === 'on' ? true : false; 125 | await this.slobs.setSourceVisibility('', source, status); 126 | break; 127 | case 'scenesource': 128 | var { scene, source, status } = Parser.getInputs(triggerData, ['action', 'scene', 'source', 'status']); 129 | status = status.toLowerCase() === 'on' ? true : false; 130 | await this.slobs.setSourceVisibility(scene, source, status); 131 | break; 132 | case 'scenefolder': 133 | var { scene, folder, status } = Parser.getInputs(triggerData, ['action', 'scene', 'folder', 'status']); 134 | status = status.toLowerCase() === 'on' ? true : false; 135 | await this.slobs.setFolderVisibility(scene, folder, status); 136 | break; 137 | case 'flip': 138 | var { scene, source, direction } = Parser.getInputs(triggerData, ['action', 'scene', 'source', 'direction']); 139 | if (direction.toLowerCase() === 'y') { 140 | await this.slobs.flipSourceY(scene, source); 141 | } else { 142 | await this.slobs.flipSourceX(scene, source); 143 | } 144 | break; 145 | case 'mute': 146 | var { source, status } = Parser.getInputs(triggerData, ['action', 'source', 'status']); 147 | status = status.toLowerCase(); 148 | if (status != 'toggle') { 149 | status = status === 'on' ? true : false; 150 | } 151 | await this.slobs.setAudioMute(source, status); 152 | break; 153 | case 'notification': 154 | var { message } = Parser.getInputs(triggerData, ['action', 'message']); 155 | await this.slobs.pushNotification(message); 156 | break; 157 | case 'position': 158 | var { scene, source, x, y } = Parser.getInputs(triggerData, ['action', 'scene', 'source', 'x', 'y']); 159 | var x = parseFloat(x); 160 | var y = parseFloat(y); 161 | if (!isNaN(x) && !isNaN(y)) { 162 | await this.slobs.setPosition(scene, source, x, y); 163 | } 164 | break; 165 | case 'rotate': 166 | var { scene, source, degree } = Parser.getInputs(triggerData, ['action', 'scene', 'source', 'degree']); 167 | degree = parseFloat(degree); 168 | if (!isNaN(degree)) { 169 | await this.slobs.rotateSource(scene, source, degree); 170 | } 171 | break; 172 | case 'savereplaybuffer': 173 | await this.slobs.saveReplayBuffer(); 174 | break; 175 | case 'startreplaybuffer': 176 | await this.slobs.startReplayBuffer(); 177 | break; 178 | case 'stopreplaybuffer': 179 | await this.slobs.stopReplayBuffer(); 180 | break; 181 | case 'togglestream': 182 | await this.slobs.toggleStream(); 183 | break; 184 | case 'volume': 185 | var { source, volume } = Parser.getInputs(triggerData, ['action', 'source', 'volume']); 186 | volume = parseFloat(volume); 187 | if (!isNaN(volume)) { 188 | var currentVolume = await this.slobs.setVolume(source, volume); 189 | return { previous_volume: currentVolume }; 190 | } else { 191 | console.error('Unable to parse volume value: ' + triggerData[triggerData.length - 1]); 192 | } 193 | break; 194 | } 195 | return; 196 | } 197 | } 198 | 199 | /** 200 | * Create a handler. 201 | */ 202 | async function slobsHandlerExport() { 203 | var slobsHandler = new SLOBSHandler(); 204 | var token = await readFile('settings/slobs/token.txt'); 205 | slobsHandler.init(token.trim()); 206 | } 207 | slobsHandlerExport(); 208 | -------------------------------------------------------------------------------- /js/streamElementsAlert/streamElements-socket.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Connect to the StreamElements websocket and setup the event handlers 3 | * @param {Handler} streamElementHandler StreamElements Handler 4 | * @param {string} token StreamElements JWT token 5 | * @param {method} onTestEvent method to call when test events are received 6 | * @param {method} onEvent method to call when events are received 7 | */ 8 | function connectStreamElementsWebsocket(streamElementHandler, token, onTestEvent, onEvent) { 9 | const socket = io('https://realtime.streamelements.com', { 10 | transports: ['websocket'] 11 | }); 12 | // Socket connected 13 | socket.on('connect', function() { 14 | console.log('Successfully connected to the streamelements websocket'); 15 | streamElementHandler.success(); 16 | socket.emit('authenticate', { 17 | method: 'jwt', 18 | token: token 19 | }); 20 | }); 21 | 22 | // Socket got disconnected 23 | socket.on('disconnect', function() { 24 | console.log('Disconnected from websocket'); 25 | }); 26 | 27 | // Socket is authenticated 28 | socket.on('authenticated', function(data) { 29 | const channelId = data.channelId; 30 | console.log(`Successfully connected to channel ${channelId}`); 31 | }); 32 | 33 | socket.on('event:test', onTestEvent); 34 | socket.on('event', onEvent); 35 | } 36 | -------------------------------------------------------------------------------- /js/streamElementsAlert/streamElementsAlertHandler.js: -------------------------------------------------------------------------------- 1 | class StreamElementsAlertHandler extends Handler { 2 | /** 3 | * Create a new StreamElements Alert handler. 4 | */ 5 | constructor() { 6 | super('StreamElementsAlert',['OnSETwitchBits','OnSETwitchBulkGiftSub','OnSEDonation','OnSETwitchFollow','OnSETwitchGiftSub','OnSETwitchHost','OnSETwitchRaid','OnSETwitchSub']); 7 | this.alerts = []; 8 | this.alertsTrigger = { 9 | 'cheer': [], 10 | 'bulk_sub': [], 11 | 'tip': [], 12 | 'follow': [], 13 | 'gift_sub': [], 14 | 'host': [], 15 | 'raid': [], 16 | 'subscriber': [] 17 | }; 18 | this.alertMapping = { 19 | 'onsetwitchbits': 'cheer', 20 | 'onsetwitchbulkgiftsub': 'bulk_sub', 21 | 'onsedonation': 'tip', 22 | 'onsetwitchfollow': 'follow', 23 | 'onsetwitchgiftsub': 'gift_sub', 24 | 'onsetwitchhost': 'host', 25 | 'onsetwitchraid': 'raid', 26 | 'onsetwitchsub': 'subscriber' 27 | }; 28 | this.eventHandlers = { 29 | 'cheer': this.getBitParameters, 30 | 'bulk_sub': this.getBulkGiftParameters, 31 | 'tip': this.getDonationParameters, 32 | 'follow': this.getFollowParameters, 33 | 'gift_sub': this.getGiftSubParameters, 34 | 'host': this.getHostParameters, 35 | 'raid': this.getRaidParameters, 36 | 'subscriber': this.getSubParameters 37 | }; 38 | this.testEventMapper = { 39 | 'cheer-latest':'cheer', 40 | 'bulk_sub': 'bulk_sub', 41 | 'tip-latest': 'tip', 42 | 'follower-latest': 'follow', 43 | 'gift_sub': 'gift_sub', 44 | 'host-latest': 'host', 45 | 'raid-latest': 'raid', 46 | 'subscriber-latest': 'subscriber' 47 | }; 48 | } 49 | 50 | /** 51 | * Initialize the connection to streamelements with the input token. 52 | * @param {string} jwtToken streamelements jwt token 53 | */ 54 | init = (jwtToken) => { 55 | connectStreamElementsWebsocket(this, jwtToken, this.onStreamElementsTestMessage, this.onStreamElementsMessage); 56 | } 57 | 58 | /** 59 | * Register trigger from user input. 60 | * @param {string} trigger name to use for the handler 61 | * @param {array} triggerLine contents of trigger line 62 | * @param {number} id of the new trigger 63 | */ 64 | addTriggerData = (trigger, triggerLine, triggerId) => { 65 | trigger = trigger.toLowerCase() 66 | this.alerts.push(this.alertMapping[trigger]); 67 | this.alertsTrigger[this.alertMapping[trigger]].push(triggerId); 68 | return; 69 | } 70 | 71 | /** 72 | * Handle event messages from streamelements websocket. 73 | * @param {Object} message streamelements test event message 74 | */ 75 | onStreamElementsTestMessage = (message) => { 76 | if (Debug.All || Debug.StreamElements) { 77 | console.error('StreamElements Message: ' + JSON.stringify(message)); 78 | } 79 | var type = message.listener; 80 | if (type === 'subscriber-latest') { 81 | if (message.event.gifted) { 82 | type = 'gift_sub'; 83 | } else if (message.event.bulkGifted) { 84 | type = 'bulk_sub'; 85 | } 86 | } 87 | type = this.testEventMapper[type]; 88 | if (this.alerts.indexOf(type) != -1) { 89 | var params = this.eventHandlers[type](message.event); 90 | this.alertsTrigger[type].forEach(triggerId => { 91 | controller.handleData(triggerId, params); 92 | }); 93 | } 94 | } 95 | 96 | /** 97 | * Handle event messages from streamelements websocket. 98 | * @param {Object} message streamelements event message 99 | */ 100 | onStreamElementsMessage = (message) => { 101 | if (Debug.All || Debug.StreamElements) { 102 | console.error('StreamElements Message: ' + JSON.stringify(message)); 103 | } 104 | var type = message.type; 105 | if (type === 'subscriber') { 106 | if (message.data.gifted) { 107 | type = 'gift_sub'; 108 | } else if (message.data.bulkGifted) { 109 | type = 'bulk_sub'; 110 | } 111 | } 112 | if (this.alerts.indexOf(type) != -1) { 113 | var params = this.eventHandlers[type](message.data); 114 | this.alertsTrigger[type].forEach(triggerId => { 115 | controller.handleData(triggerId, params); 116 | }); 117 | } 118 | } 119 | 120 | /** 121 | * Retrieve the parameters for the bit event. 122 | * @param {Object} event streamelements event 123 | */ 124 | getBitParameters = (event) => { 125 | return { 126 | 'data': event, 127 | 'amount': event.amount, 128 | 'message': htmlDecode(event.message), 129 | 'user': (event.displayName) ? event.displayName : event.name 130 | } 131 | } 132 | 133 | /** 134 | * Retrieve the parameters for the bulk gift sub event. 135 | * @param {Object} event streamelements event 136 | */ 137 | getBulkGiftParameters = (event) => { 138 | return { 139 | 'data': event, 140 | 'amount': event.amount, 141 | 'user': event.sender 142 | } 143 | } 144 | 145 | /** 146 | * Retrieve the parameters for the donation event. 147 | * @param {Object} event streamelements event 148 | */ 149 | getDonationParameters = (event) => { 150 | return { 151 | 'data': event, 152 | 'amount': event.amount, 153 | 'message': htmlDecode(event.message), 154 | 'user': (event.username) ? event.username : event.name 155 | } 156 | } 157 | 158 | /** 159 | * Retrieve the parameters for the follow event. 160 | * @param {Object} event streamelements event 161 | */ 162 | getFollowParameters = (event) => { 163 | return { 164 | 'data': event, 165 | 'user': (event.displayName) ? event.displayName : event.name 166 | } 167 | } 168 | 169 | /** 170 | * Retrieve the parameters for the gift sub event. 171 | * @param {Object} event streamelements event 172 | */ 173 | getGiftSubParameters = (event) => { 174 | return { 175 | 'data': event, 176 | 'user': (event.displayName) ? event.displayName : event.name, 177 | 'gifter': event.sender, 178 | 'tier': event.tier === 'prime' ? 'Prime' : 'Tier ' + (parseInt(event.tier) / 1000) 179 | } 180 | } 181 | 182 | /** 183 | * Retrieve the parameters for the host event. 184 | * @param {Object} event streamelements event 185 | */ 186 | getHostParameters = (event) => { 187 | return { 188 | 'data': event, 189 | 'user': (event.displayName) ? event.displayName : event.name, 190 | 'viewers': event.amount 191 | } 192 | } 193 | 194 | /** 195 | * Retrieve the parameters for the raid event. 196 | * @param {Object} event streamelements event 197 | */ 198 | getRaidParameters = (event) => { 199 | return { 200 | 'data': event, 201 | 'user': (event.displayName) ? event.displayName : event.name, 202 | 'raiders': event.amount 203 | } 204 | } 205 | 206 | /** 207 | * Retrieve the parameters for the sub event. 208 | * @param {Object} event streamelements event 209 | */ 210 | getSubParameters = (event) => { 211 | return { 212 | 'data': event, 213 | 'user': (event.displayName) ? event.displayName : event.name, 214 | 'months': event.amount, 215 | 'message': htmlDecode(event.message), 216 | 'tier': event.tier === 'prime' ? 'Prime' : 'Tier ' + (parseInt(event.tier) / 1000) 217 | } 218 | } 219 | } 220 | 221 | /** 222 | * Create a handler and read user settings 223 | */ 224 | async function streamElementsAlertHandlerExport() { 225 | var streamElementsAlert = new StreamElementsAlertHandler(); 226 | var token = await readFile('settings/streamelements/jwtToken.txt'); 227 | streamElementsAlert.init(token.trim()); 228 | } 229 | streamElementsAlertHandlerExport(); 230 | -------------------------------------------------------------------------------- /js/streamlabs/streamlabs-socket.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Connect to the Streamlabs websocket and setup the event handlers 3 | * @param {Handler} streamlabsAlertHandler Streamlabs Alert Handler 4 | * @param {string} token Streamlabs Socket API token 5 | * @param {method} onEvent method to call when events are received 6 | */ 7 | function connectStreamlabsWebsocket(streamlabsAlertHandler, token, onEvent) { 8 | //Connect to socket 9 | var streamlabs = io(`https://sockets.streamlabs.com?token=${token}`, {transports: ['websocket']}); 10 | 11 | streamlabs.on('connect', function() { 12 | console.log('Successfully connected to the streamlabs websocket'); 13 | streamlabsAlertHandler.success(); 14 | }); 15 | 16 | streamlabs.onclose = function () { 17 | console.error('Error connecting to streamlabs socket: Incorrect token or connection error'); 18 | } 19 | 20 | //Perform Action on event 21 | streamlabs.on('event', onEvent); 22 | } 23 | -------------------------------------------------------------------------------- /js/streamlabs/streamlabsHandler.js: -------------------------------------------------------------------------------- 1 | class StreamlabsHandler extends Handler { 2 | /** 3 | * Create a new Streamlabs handler. 4 | */ 5 | constructor() { 6 | super('Streamlabs', ['OnSLTwitchBits','OnSLDonation','OnSLTiltifyDonation','OnSLPatreonPledge','OnSLTwitchFollow','OnSLTwitchGiftSub','OnSLTwitchCommunityGiftSub','OnSLTwitchHost','OnSLTwitchRaid','OnSLTwitchSub','OnSLTwitchBitsNoSync','OnSLDonationNoSync','OnSLTiltifyDonationNoSync','OnSLPatreonPledgeNoSync','OnSLTwitchFollowNoSync','OnSLTwitchGiftSubNoSync','OnSLTwitchCommunityGiftSubNoSync','OnSLTwitchHostNoSync','OnSLTwitchRaidNoSync','OnSLTwitchSubNoSync']); 7 | this.alerts = []; 8 | this.alertsTrigger = { 9 | 'bits': [], 10 | 'donation': [], 11 | 'follow': [], 12 | 'gift_sub': [], 13 | 'cgift_sub': [], 14 | 'host': [], 15 | 'pledge': [], 16 | 'raid': [], 17 | 'subscription': [], 18 | 'tiltifydonation': [] 19 | }; 20 | this.alertsNoSync = []; 21 | this.alertsNoSyncTrigger = { 22 | 'bits': [], 23 | 'donation': [], 24 | 'follow': [], 25 | 'gift_sub': [], 26 | 'cgift_sub': [], 27 | 'host': [], 28 | 'pledge': [], 29 | 'raid': [], 30 | 'subscription': [], 31 | 'tiltifydonation': [] 32 | }; 33 | this.alertMapping = { 34 | 'onsltwitchbits': 'bits', 35 | 'onsldonation': 'donation', 36 | 'onsltiltifydonation': 'tiltifydonation', 37 | 'onslpatreonpledge': 'pledge', 38 | 'onsltwitchfollow': 'follow', 39 | 'onsltwitchgiftsub': 'gift_sub', 40 | 'onsltwitchcommunitygiftsub': 'cgift_sub', 41 | 'onsltwitchhost': 'host', 42 | 'onsltwitchraid': 'raid', 43 | 'onsltwitchsub': 'subscription', 44 | 'onsltwitchbitsnosync': 'bits', 45 | 'onsldonationnosync': 'donation', 46 | 'onsltiltifydonationnosync': 'tiltifydonation', 47 | 'onslpatreonpledgenosync': 'pledge', 48 | 'onsltwitchfollownosync': 'follow', 49 | 'onsltwitchgiftsubnosync': 'gift_sub', 50 | 'onsltwitchcommunitygiftsubnosync': 'cgift_sub', 51 | 'onsltwitchhostnosync': 'host', 52 | 'onsltwitchraidnosync': 'raid', 53 | 'onsltwitchsubnosync': 'subscription' 54 | }; 55 | this.eventHandlers = { 56 | 'bits': this.getBitParameters, 57 | 'donation': this.getDonationParameters, 58 | 'follow': this.getFollowParameters, 59 | 'gift_sub': this.getGiftSubParameters, 60 | 'cgift_sub': this.getCommunityGiftSubParameters, 61 | 'host': this.getHostParameters, 62 | 'pledge': this.getPatreonPledgeParameters, 63 | 'raid': this.getRaidParameters, 64 | 'subscription': this.getSubParameters, 65 | 'tiltifydonation': this.getTiltifyDonationParameters 66 | }; 67 | this.alertIds = []; 68 | this.alertIdsNoSync = []; 69 | this.onSLMessageQueue = async.queue(this.parseStreamlabsMessage, 1); 70 | } 71 | 72 | /** 73 | * Initialize the connection to streamlabs with the input token. 74 | * @param {string} socketToken streamlabs socket api token 75 | */ 76 | init = (socketToken) => { 77 | connectStreamlabsWebsocket(this, socketToken, this.onStreamlabsMessage); 78 | } 79 | 80 | /** 81 | * Register trigger from user input. 82 | * @param {string} trigger name to use for the handler 83 | * @param {array} triggerLine contents of trigger line 84 | * @param {number} id of the new trigger 85 | */ 86 | addTriggerData = (trigger, triggerLine, triggerId) => { 87 | trigger = trigger.toLowerCase(); 88 | if (trigger.endsWith('nosync')) { 89 | this.alertsNoSync.push(this.alertMapping[trigger]); 90 | this.alertsNoSyncTrigger[this.alertMapping[trigger]].push(triggerId); 91 | } else { 92 | this.alerts.push(this.alertMapping[trigger]); 93 | this.alertsTrigger[this.alertMapping[trigger]].push(triggerId); 94 | } 95 | return; 96 | } 97 | 98 | /** 99 | * Handle event messages from streamlabs websocket. 100 | * @param {Object} message streamlabs event message 101 | */ 102 | onStreamlabsMessage = (message) => { 103 | if (Debug.All || Debug.Streamlabs) { 104 | console.error('Streamlabs Message: ' + JSON.stringify(message)); 105 | } 106 | this.onSLMessageQueue.push(message); 107 | } 108 | 109 | /** 110 | * Handle event messages from streamlabs websocket. 111 | * @param {Object} message streamlabs event message 112 | */ 113 | parseStreamlabsMessage = async (message, callback) => { 114 | if (message.type === 'alertPlaying') { 115 | if (this.alertIds.indexOf(message.message['_id']) === -1) { 116 | this.alertIds.push(message.message['_id']); 117 | var type = message.message.type; 118 | if (type === 'subscription' && message.message.gifter_display_name) { 119 | type = 'gift_sub'; 120 | } else if (type === 'subMysteryGift') { 121 | type = 'cgift_sub'; 122 | } 123 | if (this.alerts.indexOf(type) != -1) { 124 | var params = this.eventHandlers[type](message.message); 125 | this.alertsTrigger[type].forEach(triggerId => { 126 | controller.handleData(triggerId, params); 127 | }); 128 | } 129 | } 130 | } else if (this.alertsNoSync.indexOf(message.type) !== -1) { 131 | message.message.forEach(alertMessage => { 132 | if (this.alertIdsNoSync.indexOf(alertMessage['_id']) === -1) { 133 | this.alertIdsNoSync.push(alertMessage['_id']); 134 | var type = message.type; 135 | if (type === 'subscription' && alertMessage.gifter_display_name) { 136 | type = 'gift_sub'; 137 | } else if (type === 'subMysteryGift') { 138 | type = 'cgift_sub'; 139 | } 140 | var params = this.eventHandlers[type](alertMessage); 141 | this.alertsNoSyncTrigger[type].forEach(triggerId => { 142 | controller.handleData(triggerId, params); 143 | }); 144 | } 145 | }); 146 | } 147 | } 148 | 149 | /** 150 | * Retrieve the parameters for the bit event. 151 | * @param {Object} message streamlabs event message 152 | */ 153 | getBitParameters = (message) => { 154 | return { 155 | 'data': message, 156 | 'amount': message.amount, 157 | 'message': message.message || "", 158 | 'user': message.name 159 | } 160 | } 161 | 162 | /** 163 | * Retrieve the parameters for the donation event. 164 | * @param {Object} message streamlabs event message 165 | */ 166 | getDonationParameters = (message) => { 167 | return { 168 | 'data': message, 169 | 'amount': (message.payload && message.payload.amount) ? message.payload.amount : message.amount, 170 | 'formatted': (message.payload && message.payload.formatted_amount) ? message.payload.formatted_amount : message.formatted_amount, 171 | 'message': message.message || "", 172 | 'user': message.from 173 | } 174 | } 175 | 176 | /** 177 | * Retrieve the parameters for the tiltify donation event. 178 | * @param {Object} message streamlabs event message 179 | */ 180 | getTiltifyDonationParameters = (message) => { 181 | return { 182 | 'data': message, 183 | 'amount': (message.payload && message.payload.amount) ? message.payload.amount : message.amount, 184 | 'formatted': (message.payload && message.payload.formatted_amount) ? message.payload.formattedAmount : message.formattedAmount, 185 | 'message': message.message || "", 186 | 'user': message.from 187 | } 188 | } 189 | 190 | /** 191 | * Retrieve the parameters for the patreon pledge event. 192 | * @param {Object} message streamlabs event message 193 | */ 194 | getPatreonPledgeParameters = (message) => { 195 | return { 196 | 'data': message, 197 | 'amount': (message.payload && message.payload.amount) ? message.payload.amount : message.amount, 198 | 'formatted': (message.payload && message.payload.formatted_amount) ? message.payload.formatted_amount : message.formatted_amount, 199 | 'user': message.from 200 | } 201 | } 202 | 203 | /** 204 | * Retrieve the parameters for the follow event. 205 | * @param {Object} message streamlabs event message 206 | */ 207 | getFollowParameters = (message) => { 208 | return { 209 | 'data': message, 210 | 'user': message.name 211 | } 212 | } 213 | 214 | /** 215 | * Retrieve the parameters for the gift sub event. 216 | * @param {Object} message streamlabs event message 217 | */ 218 | getGiftSubParameters = (message) => { 219 | var gifter = message.gifter_display_name; 220 | if (!gifter) { 221 | gifter = message.gifter; 222 | } 223 | 224 | var name = message.display_name; 225 | if (!name) { 226 | name = message.name; 227 | } 228 | 229 | var subPlan = message.sub_plan; 230 | if (!subPlan) { 231 | subPlan = message.subPlan; 232 | } 233 | 234 | return { 235 | 'data': message, 236 | 'user': name, 237 | 'gifter': gifter, 238 | 'months': message.months, 239 | 'tier': subPlan === 'Prime' ? 'Prime' : 'Tier ' + (parseInt(subPlan) / 1000) 240 | } 241 | } 242 | 243 | /** 244 | * Retrieve the parameters for the gift sub event. 245 | * @param {Object} message streamlabs event message 246 | */ 247 | getCommunityGiftSubParameters = (message) => { 248 | var gifter = message.gifter_display_name; 249 | if (!gifter) { 250 | gifter = message.gifter; 251 | } 252 | 253 | var subPlan = message.sub_plan; 254 | if (!subPlan) { 255 | subPlan = message.subPlan; 256 | } 257 | 258 | return { 259 | 'data': message, 260 | 'amount': message.amount, 261 | 'gifter': gifter, 262 | 'tier': subPlan === 'Prime' ? 'Prime' : 'Tier ' + (parseInt(subPlan) / 1000) 263 | } 264 | } 265 | 266 | /** 267 | * Retrieve the parameters for the host event. 268 | * @param {Object} message streamlabs event message 269 | */ 270 | getHostParameters = (message) => { 271 | return { 272 | 'data': message, 273 | 'user': message.name, 274 | 'viewers': message.viewers 275 | } 276 | } 277 | 278 | /** 279 | * Retrieve the parameters for the raid event. 280 | * @param {Object} message streamlabs event message 281 | */ 282 | getRaidParameters = (message) => { 283 | return { 284 | 'data': message, 285 | 'user': message.name, 286 | 'raiders': message.raiders 287 | } 288 | } 289 | 290 | /** 291 | * Retrieve the parameters for the sub event. 292 | * @param {Object} message streamlabs event message 293 | */ 294 | getSubParameters = (message) => { 295 | var name = message.display_name; 296 | if (!name) { 297 | name = message.name; 298 | } 299 | 300 | var subPlan = message.sub_plan; 301 | if (!subPlan) { 302 | subPlan = message.subPlan; 303 | } 304 | 305 | return { 306 | 'data': message, 307 | 'user': name, 308 | 'months': message.months, 309 | 'message': message.message || "", 310 | 'tier': subPlan === 'Prime' ? 'Prime' : 'Tier ' + (parseInt(subPlan) / 1000) 311 | } 312 | } 313 | } 314 | 315 | /** 316 | * Create a handler and read user settings 317 | */ 318 | async function streamlabsHandlerExport() { 319 | var streamlabs = new StreamlabsHandler(); 320 | var socket = await readFile('settings/streamlabs/socketAPIToken.txt'); 321 | streamlabs.init(socket.trim()); 322 | } 323 | streamlabsHandlerExport(); 324 | -------------------------------------------------------------------------------- /js/timer/timerHandler.js: -------------------------------------------------------------------------------- 1 | class TimerHandler extends Handler { 2 | /** 3 | * Create a new Timer handler. 4 | */ 5 | constructor() { 6 | super('Timer', ['OnTimer']); 7 | this.timerNames = []; 8 | this.timers = {}; 9 | this.intervals = {}; 10 | this.success(); 11 | } 12 | 13 | /** 14 | * Register trigger from user input. 15 | * @param {string} trigger name to use for the handler 16 | * @param {array} triggerLine contents of trigger line 17 | * @param {number} id of the new trigger 18 | */ 19 | addTriggerData = (trigger, triggerLine, triggerId) => { 20 | var { name, interval, temp } = Parser.getInputs(triggerLine, ['name', 'interval', 'temp'], false, 1); 21 | 22 | interval = parseFloat(interval); 23 | 24 | var offset = 0; 25 | if (temp !== undefined) { 26 | temp = parseFloat(temp); 27 | if (!isNaN(temp)) { 28 | offset = temp; 29 | } 30 | } 31 | if (this.timerNames.indexOf(name) === -1) { 32 | this.timerNames.push(name); 33 | this.timers[name] = []; 34 | this.intervals[name] = []; 35 | } 36 | this.timers[name].push([triggerId, name, interval, offset]); 37 | } 38 | 39 | /** 40 | * Handle the input data (take an action). 41 | * @param {array} triggerData contents of trigger line 42 | */ 43 | handleData = async (triggerData) => { 44 | var action = Parser.getAction(triggerData, 'Timer'); 45 | // Timer Reset Name 46 | if (action === 'reset' || action === 'start') { 47 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 48 | this.intervals[name].forEach((item, i) => { 49 | clearInterval(item); 50 | var info = this.timers[name][i]; 51 | var triggerId = info[0]; 52 | var interval = info[2]; 53 | this.intervals[name][i] = setInterval(function() { 54 | controller.handleData(triggerId); 55 | }, interval * 1000); 56 | }, this); 57 | } else if (action === 'stop') { 58 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 59 | this.intervals[name].forEach((item, i) => { 60 | clearInterval(item); 61 | }); 62 | } 63 | } 64 | 65 | /** 66 | * Called after parsing all user input. 67 | */ 68 | postParse = () => { 69 | this.timerNames.forEach((name) => { 70 | this.timers[name].forEach((timer) => { 71 | var triggerId = timer[0]; 72 | var name = timer[1]; 73 | var interval = timer[2]; 74 | var offset = timer[3]; 75 | setTimeout(function() { 76 | this.intervals[name].push(setInterval(function() { 77 | controller.handleData(triggerId); 78 | }, interval * 1000)); 79 | }.bind(this), (offset + 1) * 1000); 80 | }); 81 | }); 82 | } 83 | } 84 | 85 | /** 86 | * Create a handler 87 | */ 88 | function timerHandlerExport() { 89 | var timer = new TimerHandler(); 90 | } 91 | timerHandlerExport(); 92 | -------------------------------------------------------------------------------- /js/tts/ttsHandler.js: -------------------------------------------------------------------------------- 1 | class TTSHandler extends Handler { 2 | /** 3 | * Create a new TTS handler. 4 | */ 5 | constructor() { 6 | super('TTS', []); 7 | this.voices = {}; 8 | this.success(); 9 | } 10 | 11 | /** 12 | * Handle the input data (take an action). 13 | * @param {array} triggerData contents of trigger line 14 | * @param {array} parameters current trigger parameters 15 | */ 16 | handleData = async (triggerData, parameters) => { 17 | var action = Parser.getAction(triggerData, 'TTS'); 18 | if (action === 'stop') { 19 | window.speechSynthesis.cancel(); 20 | } else if (action === 'voices') { 21 | var { name } = Parser.getInputs(triggerData, ['action', 'name']); 22 | var listParser = controller.getParser("list"); 23 | var voices = window.speechSynthesis.getVoices(); 24 | if (voices.length === 0) { 25 | await timeout(1000); 26 | voices = window.speechSynthesis.getVoices(); 27 | } 28 | this.updateVoices(voices); 29 | listParser.createList(name, voices.map(voice => voice.name)); 30 | } else { 31 | var inputs = Parser.getInputs(triggerData, ['voice', 'volume', 'pitch', 'rate', 'wait', 'message']); 32 | if (Object.keys(inputs).length === 0) { 33 | inputs = Parser.getInputs(triggerData, ['voice', 'volume', 'wait', 'message']); 34 | } 35 | var { voice, volume, pitch, rate, wait, message } = inputs; 36 | 37 | volume = parseInt(volume); 38 | if (isNaN(volume)) { 39 | volume = 0.8; 40 | } else { 41 | volume = volume / 100; 42 | } 43 | 44 | pitch = parseInt(pitch); 45 | if (isNaN(pitch)) { 46 | pitch = 1; 47 | } else { 48 | pitch = pitch / 100 * 2; 49 | } 50 | 51 | rate = parseInt(rate); 52 | if (isNaN(rate)) { 53 | rate = 1; 54 | } else { 55 | rate = (rate / 100 * 9.9) + 0.1; 56 | } 57 | 58 | var msg = new SpeechSynthesisUtterance(); 59 | msg.text = message; 60 | msg.volume = volume; 61 | msg.pitch = pitch; 62 | msg.rate = rate; 63 | 64 | // Load voices if not loaded yet 65 | if (Object.keys(this.voices).length === 0) { 66 | await this.initializeVoices(); 67 | } 68 | 69 | if (this.voices[voice]) { 70 | msg.voice = this.voices[voice]; 71 | } 72 | 73 | if (wait === 'wait') { 74 | await new Promise((resolve) => { 75 | msg.addEventListener('end', value => { 76 | resolve(value); 77 | }); 78 | window.speechSynthesis.speak(msg); 79 | }); 80 | } else { 81 | window.speechSynthesis.speak(msg); 82 | } 83 | } 84 | 85 | return; 86 | } 87 | 88 | /** 89 | * Initialize the internal list of voices 90 | */ 91 | initializeVoices = async () => { 92 | var voices = window.speechSynthesis.getVoices(); 93 | if (voices.length === 0) { 94 | await timeout(1000); 95 | voices = window.speechSynthesis.getVoices(); 96 | } 97 | this.updateVoices(voices); 98 | } 99 | 100 | /** 101 | * Update the internal voice dictionary. 102 | * @param {array} voices list of SpeechSynthesisVoice objects 103 | */ 104 | updateVoices = (voices) => { 105 | this.voices = {}; 106 | voices.forEach(voice => { 107 | this.voices[voice.name] = voice; 108 | }); 109 | 110 | } 111 | } 112 | 113 | /** 114 | * Create a handler and read user settings 115 | */ 116 | async function ttsHandlerExport() { 117 | var ttsHandler = new TTSHandler(); 118 | } 119 | ttsHandlerExport(); 120 | -------------------------------------------------------------------------------- /js/twitch/twitch-eventsub-handler.js: -------------------------------------------------------------------------------- 1 | class EventSubHandler { 2 | wsUrl = "wss://eventsub.wss.twitch.tv/ws"; 3 | /** 4 | * Create an EventSub handler to control the websocket connection 5 | * 6 | * @param {TwitchAPI} api twitch api interface 7 | * @param {string} channelId twitch channel id 8 | * @param {function} onMessage method to call when events are received 9 | */ 10 | constructor(api, channelId, onMessage) { 11 | this.api = api; 12 | this.channelId = channelId; 13 | this.onMessage = onMessage; 14 | this.wsId = ""; 15 | 16 | this.addConnection(async (id) => { 17 | this.wsId = id; 18 | await this.addSubscriptions(); 19 | await this.deleteOldSubscriptions(); 20 | }, this.wsUrl); 21 | } 22 | 23 | addConnection = (onWelcome, url = this.wsUrl) => { 24 | const ws = new WebSocket(url); 25 | ws.onmessage = (event) => { 26 | if (Debug.Twitch || Debug.All) { 27 | console.error(`Received EventSub Message: ${JSON.stringify(event)}`); 28 | } 29 | 30 | const { 31 | metadata: { message_type }, 32 | payload, 33 | } = JSON.parse(event.data); 34 | if (message_type === "session_welcome") { 35 | const { 36 | session: { id, keepalive_timeout_seconds }, 37 | } = payload; 38 | if (Debug.Twitch || Debug.All) { 39 | console.error(`Received welcome message for session "${id}"`); 40 | } 41 | ws.resetTimeout = () => { 42 | if (ws.keepaliveTimeout) { 43 | clearTimeout(ws.keepaliveTimeout); 44 | } 45 | ws.keepaliveTimeout = setTimeout(() => { 46 | console.error("Connection to Twitch EventSub lost"); 47 | ws.close(); 48 | }, keepalive_timeout_seconds * 1000 + 100); 49 | }; 50 | ws.resetTimeout(); 51 | onWelcome(id); 52 | } else if (message_type === "session_keepalive") { 53 | ws.resetTimeout(); 54 | } else if (message_type === "session_reconnect") { 55 | const { 56 | session: { id, reconnect_url }, 57 | } = payload; 58 | if (Debug.Twitch || Debug.All) { 59 | console.error(`Received reconnect message for session "${id}"`); 60 | } 61 | this.addConnection((newId) => { 62 | clearTimeout(ws.keepaliveTimeout); 63 | ws.close(); 64 | this.wsId = newId; 65 | }, reconnect_url); 66 | } else if (message_type === "notification") { 67 | ws.resetTimeout(); 68 | const { subscription, event } = payload; 69 | if (Debug.Twitch || Debug.All) { 70 | console.error(`Received notification for type ${subscription.type}`); 71 | } 72 | this.onMessage(event, subscription); 73 | } else if (message_type === "revocation") { 74 | ws.resetTimeout(); 75 | const { subscription } = payload; 76 | if (Debug.Twitch || Debug.All) { 77 | console.error(`Received revocation notification for subscription id ${subscription.id}`); 78 | } 79 | this.resubscribe(subscription.type, subscription.version, subscription.condition); 80 | } else { 81 | console.error(`Unhandled WebSocket message type "${message_type}"`); 82 | } 83 | }; 84 | ws.onclose = (event) => { 85 | const { code, reason } = event; 86 | console.error(`Twitch EventSub WebSocket connection closed. ${code}:${reason}`); 87 | this.addConnection(async (newId) => { 88 | console.error("Twitch EventSub reconnected after connection closed.") 89 | clearTimeout(ws.keepaliveTimeout); 90 | if (this.wsId !== newId) { 91 | this.wsId = newId; 92 | await this.addSubscriptions(); 93 | await this.deleteOldSubscriptions(); 94 | } 95 | }); 96 | }; 97 | } 98 | 99 | resubscribe = async (type, version, condition) => { 100 | if (Debug.Twitch || Debug.All) { 101 | console.error(`Resubscribing to ${type}`); 102 | } 103 | await this.api.createEventSubSubscription(type, version, condition, this.wsId); 104 | if (Debug.Twitch || Debug.All) { 105 | console.error(`Resubscribing to ${type}`); 106 | } 107 | } 108 | 109 | addSubscriptions = async () => { 110 | const subscriptions = [ 111 | { 112 | type: 'channel.update', 113 | data: { broadcaster_user_id: this.channelId }, 114 | version: 1 115 | }, 116 | { 117 | type: 'channel.follow', 118 | data: { broadcaster_user_id: this.channelId, moderator_user_id: this.channelId }, 119 | version: "2" 120 | }, 121 | { 122 | type: 'channel.ad_break.begin', 123 | data: { broadcaster_user_id: this.channelId }, 124 | version: 1 125 | }, 126 | { 127 | type: 'channel.chat.clear', 128 | data: { broadcaster_user_id: this.channelId, user_id: this.channelId }, 129 | version: 1 130 | }, 131 | { 132 | type: 'channel.chat.clear_user_messages', 133 | data: { broadcaster_user_id: this.channelId, user_id: this.channelId }, 134 | version: 1 135 | }, 136 | { 137 | type: 'channel.subscribe', 138 | data: { broadcaster_user_id: this.channelId }, 139 | version: 1 140 | }, 141 | { 142 | type: 'channel.subscription.gift', 143 | data: { broadcaster_user_id: this.channelId }, 144 | version: 1 145 | }, 146 | { 147 | type: 'channel.subscription.message', 148 | data: { broadcaster_user_id: this.channelId }, 149 | version: 1 150 | }, 151 | { 152 | type: 'channel.cheer', 153 | data: { broadcaster_user_id: this.channelId }, 154 | version: 1 155 | }, 156 | { 157 | type: 'channel.raid', 158 | data: { to_broadcaster_user_id: this.channelId }, 159 | version: 1 160 | }, 161 | { 162 | type: 'channel.ban', 163 | data: { broadcaster_user_id: this.channelId }, 164 | version: 1 165 | }, 166 | { 167 | type: 'channel.unban', 168 | data: { broadcaster_user_id: this.channelId }, 169 | version: 1 170 | }, 171 | { 172 | type: 'channel.moderator.add', 173 | data: { broadcaster_user_id: this.channelId }, 174 | version: 1 175 | }, 176 | { 177 | type: 'channel.moderator.remove', 178 | data: { broadcaster_user_id: this.channelId }, 179 | version: 1 180 | }, 181 | { 182 | type: 'channel.channel_points_custom_reward_redemption.add', 183 | data: { broadcaster_user_id: this.channelId }, 184 | version: 1 185 | }, 186 | { 187 | type: 'channel.channel_points_custom_reward_redemption.update', 188 | data: { broadcaster_user_id: this.channelId }, 189 | version: 1 190 | }, 191 | { 192 | type: 'channel.poll.begin', 193 | data: { broadcaster_user_id: this.channelId }, 194 | version: 1 195 | }, 196 | { 197 | type: 'channel.poll.progress', 198 | data: { broadcaster_user_id: this.channelId }, 199 | version: 1 200 | }, 201 | { 202 | type: 'channel.poll.end', 203 | data: { broadcaster_user_id: this.channelId }, 204 | version: 1 205 | }, 206 | { 207 | type: 'channel.prediction.begin', 208 | data: { broadcaster_user_id: this.channelId }, 209 | version: 1 210 | }, 211 | { 212 | type: 'channel.prediction.progress', 213 | data: { broadcaster_user_id: this.channelId }, 214 | version: 1 215 | }, 216 | { 217 | type: 'channel.prediction.lock', 218 | data: { broadcaster_user_id: this.channelId }, 219 | version: 1 220 | }, 221 | { 222 | type: 'channel.prediction.end', 223 | data: { broadcaster_user_id: this.channelId }, 224 | version: 1 225 | }, 226 | { 227 | type: 'channel.suspicious_user.message', 228 | data: { broadcaster_user_id: this.channelId, moderator_user_id: this.channelId }, 229 | version: 1 230 | }, 231 | { 232 | type: 'channel.vip.add', 233 | data: { broadcaster_user_id: this.channelId }, 234 | version: 1 235 | }, 236 | { 237 | type: 'channel.vip.remove', 238 | data: { broadcaster_user_id: this.channelId }, 239 | version: 1 240 | }, 241 | { 242 | type: 'channel.charity_campaign.donate', 243 | data: { broadcaster_user_id: this.channelId }, 244 | version: 1 245 | }, 246 | { 247 | type: 'channel.charity_campaign.start', 248 | data: { broadcaster_user_id: this.channelId }, 249 | version: 1 250 | }, 251 | { 252 | type: 'channel.charity_campaign.progress', 253 | data: { broadcaster_user_id: this.channelId }, 254 | version: 1 255 | }, 256 | { 257 | type: 'channel.charity_campaign.stop', 258 | data: { broadcaster_user_id: this.channelId }, 259 | version: 1 260 | }, 261 | { 262 | type: 'channel.goal.begin', 263 | data: { broadcaster_user_id: this.channelId }, 264 | version: 1 265 | }, 266 | { 267 | type: 'channel.goal.progress', 268 | data: { broadcaster_user_id: this.channelId }, 269 | version: 1 270 | }, 271 | { 272 | type: 'channel.goal.end', 273 | data: { broadcaster_user_id: this.channelId }, 274 | version: 1 275 | }, 276 | { 277 | type: 'channel.hype_train.begin', 278 | data: { broadcaster_user_id: this.channelId }, 279 | version: 1 280 | }, 281 | { 282 | type: 'channel.hype_train.progress', 283 | data: { broadcaster_user_id: this.channelId }, 284 | version: 1 285 | }, 286 | { 287 | type: 'channel.hype_train.end', 288 | data: { broadcaster_user_id: this.channelId }, 289 | version: 1 290 | }, 291 | { 292 | type: 'channel.shield_mode.begin', 293 | data: { broadcaster_user_id: this.channelId, moderator_user_id: this.channelId }, 294 | version: 1 295 | }, 296 | { 297 | type: 'channel.shield_mode.end', 298 | data: { broadcaster_user_id: this.channelId, moderator_user_id: this.channelId }, 299 | version: 1 300 | }, 301 | { 302 | type: 'channel.shoutout.create', 303 | data: { broadcaster_user_id: this.channelId, moderator_user_id: this.channelId }, 304 | version: 1 305 | }, 306 | { 307 | type: 'channel.shoutout.receive', 308 | data: { broadcaster_user_id: this.channelId, moderator_user_id: this.channelId }, 309 | version: 1 310 | }, 311 | { 312 | type: 'stream.online', 313 | data: { broadcaster_user_id: this.channelId }, 314 | version: 1 315 | }, 316 | { 317 | type: 'stream.offline', 318 | data: { broadcaster_user_id: this.channelId }, 319 | version: 1 320 | } 321 | ]; 322 | 323 | for (var i = 0; i < subscriptions.length; i++) { 324 | var {type, data, version} = subscriptions[i]; 325 | await new Promise(async (resolve) => { 326 | await this.api.createEventSubSubscription(type, version, data, this.wsId); 327 | resolve(); 328 | }); 329 | } 330 | } 331 | 332 | deleteOldSubscriptions = async () => { 333 | var { total, data } = await this.api.getEventSubSubscriptions(); 334 | if (total > 0) { 335 | for (var i = 0; i < data.length; i++) { 336 | if (data[i]["transport"]["session_id"] !== this.wsId) { 337 | await this.api.deleteEventSubSubscription(data[i]["id"]); 338 | } 339 | } 340 | } 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /js/twitch/twitch-pub-socket.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Connect to the Twitch PubSub websocket and setup the event handlers 3 | * @param {string} channelId twitch channel id 4 | * @param {function} onMessage method to call when events are received 5 | */ 6 | function connectPubSubWebsocket(channelId, onMessage) { 7 | // Create the websocket connection 8 | var socket = new WebSocket('wss://pubsub-edge.twitch.tv'); 9 | 10 | var nonce = randomString(18); 11 | var wasOpened = false; 12 | 13 | // WS OnOpen event : authenticate 14 | socket.onopen = function() { 15 | console.error('Twitch Pubsub Websocket Opened'); 16 | wasOpened = true; 17 | 18 | // Create authentication payload and request required events 19 | var auth = { 20 | "type": "LISTEN", 21 | "nonce": nonce, 22 | "data": { 23 | "topics": [ 24 | "community-points-channel-v1." + channelId 25 | ], 26 | } 27 | }; 28 | 29 | // Send authentication payload to Twitch PubSub 30 | socket.send(JSON.stringify(auth)); 31 | }; 32 | 33 | setInterval(function() { 34 | setTimeout(function() { 35 | socket.send(JSON.stringify({ 36 | "type": "PING" 37 | })); 38 | }, Math.random() * 10000 + 1000); 39 | }, 240000); 40 | 41 | // Ws OnClose : try reconnect 42 | socket.onclose = function() { 43 | console.error('Twitch Pubsub Websocket Closed'); 44 | socket = null; 45 | if (wasOpened) { 46 | connectPubSubWebsocket(channelId, onMessage); 47 | } 48 | }; 49 | 50 | // WS OnMessage event : handle events 51 | socket.onmessage = function(message) { 52 | if (message.type === "RECONNECT" || message.type === "AUTH_REVOKED") { 53 | console.error(`Twitch Pubsub Received ${message.type} Message`); 54 | socket.close(); 55 | } else if (message.type === "PONG") { 56 | console.error('Twitch Pubsub Received Pong Message'); 57 | } else { 58 | onMessage(message); 59 | } 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /js/utils/idb-service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * IDBService is a proxy for interacting with IndexedDB through idbKeyval. 3 | */ 4 | class IDBService { 5 | 6 | /** 7 | * Waits for the idbKeyval global module to be loaded. 8 | */ 9 | static async awaitModuleLoad() { 10 | if (typeof idbKeyval !== 'undefined') { 11 | return; 12 | } 13 | 14 | return await new Promise((resolve) => { 15 | setTimeout(async () => { 16 | await IDBService.awaitModuleLoad(); 17 | resolve(); 18 | }, 100); 19 | }); 20 | } 21 | 22 | /** 23 | * Retrieve a value from IndexedDB by the key. 24 | * @param {string} key id of the value to retrieve 25 | */ 26 | static async get(key) { 27 | await IDBService.awaitModuleLoad(); 28 | return await idbKeyval.get(key); 29 | } 30 | 31 | /** 32 | * Retrieve all keys from IndexedDB. 33 | */ 34 | static async keys() { 35 | await IDBService.awaitModuleLoad(); 36 | return await idbKeyval.keys(); 37 | } 38 | 39 | /** 40 | * Retrieve all entries from IndexedDB. 41 | */ 42 | static async entries() { 43 | await IDBService.awaitModuleLoad(); 44 | var entries = await idbKeyval.entries() 45 | return entries.reduce( 46 | (prev, cur) => ({ ...prev, [cur[0]]: cur[1] }), 47 | {} 48 | ); 49 | } 50 | 51 | /** 52 | * Set a key-value pair in IndexedDB. 53 | * @param {string} key id of the entry to store 54 | * @param {object} value object to store in the entry 55 | */ 56 | static async set(key, value) { 57 | await IDBService.awaitModuleLoad(); 58 | await idbKeyval.set(key, value); 59 | } 60 | 61 | /** 62 | * Delete a key-value entry from IndexedDB by the key. 63 | * @param {string} key id of the value to delete 64 | */ 65 | static async delete(key) { 66 | await IDBService.awaitModuleLoad(); 67 | idbKeyval.del(key); 68 | } 69 | 70 | /** 71 | * Clear all entries from IndexedDB. 72 | */ 73 | static async clear() { 74 | await IDBService.awaitModuleLoad(); 75 | idbKeyval.clear(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /js/utils/shlex.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Port of a subset of the features of CPython's shlex module, which provides a 3 | * shell-like lexer. Original code by Eric S. Raymond and other contributors. 4 | * 5 | * Ported to single js file by Kruiser8 6 | * 7 | * Author: Ryan Govostes (rgov) 8 | * License: MIT 9 | * Repository: https://github.com/rgov/node-shlex 10 | */ 11 | class Shlexer { 12 | constructor (string) { 13 | this.i = 0 14 | this.string = string 15 | 16 | /** 17 | * Characters that will be considered whitespace and skipped. Whitespace 18 | * bounds tokens. By default, includes space, tab, linefeed and carriage 19 | * return. 20 | */ 21 | this.whitespace = ' \t\r\n' 22 | 23 | /** 24 | * Characters that will be considered string quotes. The token accumulates 25 | * until the same quote is encountered again (thus, different quote types 26 | * protect each other as in the shell.) By default, includes ASCII single 27 | * and double quotes. 28 | */ 29 | this.quotes = `'"` 30 | 31 | /** 32 | * Characters that will be considered as escape. Just `\` by default. 33 | */ 34 | this.escapes = '\\' 35 | 36 | /** 37 | * The subset of quote types that allow escaped characters. Just `"` by default. 38 | */ 39 | this.escapedQuotes = '"' 40 | 41 | /** 42 | * Whether to support ANSI C-style $'' quotes 43 | * https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html 44 | */ 45 | this.ansiCQuotes = true 46 | 47 | /** 48 | * Whether to support localized $"" quotes 49 | * https://www.gnu.org/software/bash/manual/html_node/Locale-Translation.html 50 | * 51 | * The behavior is as if the current locale is set to C or POSIX, i.e., the 52 | * contents are not translated. 53 | */ 54 | this.localeQuotes = true 55 | 56 | this.debug = false 57 | } 58 | 59 | readChar () { 60 | return this.string.charAt(this.i++) 61 | } 62 | 63 | processEscapes (string, quote, isAnsiCQuote) { 64 | if (!isAnsiCQuote && !this.escapedQuotes.includes(quote)) { 65 | // This quote type doesn't support escape sequences 66 | return string 67 | } 68 | 69 | // We need to form a regex that matches any of the escape characters, 70 | // without interpreting any of the characters as a regex special character. 71 | let anyEscape = '[' + this.escapes.replace(/(.)/g, '\\$1') + ']' 72 | 73 | // In regular quoted strings, we can only escape an escape character, and 74 | // the quote character itself. 75 | if (!isAnsiCQuote && this.escapedQuotes.includes(quote)) { 76 | let re = new RegExp( 77 | anyEscape + '(' + anyEscape + '|\\' + quote + ')', 'g') 78 | return string.replace(re, '$1') 79 | } 80 | 81 | // ANSI C quoted strings support a wide variety of escape sequences 82 | if (isAnsiCQuote) { 83 | let patterns = { 84 | // Literal characters 85 | '([\\\\\'"?])': (x) => x, 86 | 87 | // Non-printable ASCII characters 88 | 'a': () => '\x07', 89 | 'b': () => '\x08', 90 | 'e|E': () => '\x1b', 91 | 'f': () => '\x0c', 92 | 'n': () => '\x0a', 93 | 'r': () => '\x0d', 94 | 't': () => '\x09', 95 | 'v': () => '\x0b', 96 | 97 | // Octal bytes 98 | '([0-7]{1,3})': (x) => String.fromCharCode(parseInt(x, 8)), 99 | 100 | // Hexadecimal bytes 101 | 'x([0-9a-fA-F]{1,2})': (x) => String.fromCharCode(parseInt(x, 16)), 102 | 103 | // Unicode code units 104 | 'u([0-9a-fA-F]{1,4})': (x) => String.fromCharCode(parseInt(x, 16)), 105 | 'U([0-9a-fA-F]{1,8})': (x) => String.fromCharCode(parseInt(x, 16)), 106 | 107 | // Control characters 108 | // https://en.wikipedia.org/wiki/Control_character#How_control_characters_map_to_keyboards 109 | 'c(.)': (x) => { 110 | if (x === '?') { 111 | return '\x7f' 112 | } else if (x === '@') { 113 | return '\x00' 114 | } else { 115 | return String.fromCharCode(x.charCodeAt(0) & 31) 116 | } 117 | } 118 | } 119 | 120 | // Construct an uber-RegEx that catches all of the above pattern 121 | let re = new RegExp( 122 | anyEscape + '(' + Object.keys(patterns).join('|') + ')', 'g') 123 | 124 | // For each match, figure out which subpattern matched, and apply the 125 | // corresponding function 126 | return string.replace(re, function (m, p1) { 127 | for (let matched in patterns) { 128 | let mm = new RegExp('^' + matched + '$').exec(p1) 129 | if (mm === null) { 130 | continue 131 | } 132 | 133 | return patterns[matched].apply(null, mm.slice(1)) 134 | } 135 | }) 136 | } 137 | 138 | // Should not get here 139 | return undefined 140 | } 141 | 142 | * [Symbol.iterator] () { 143 | let inQuote = false 144 | let inDollarQuote = false 145 | let escaped = false 146 | let lastDollar = -2 // position of last dollar sign we saw 147 | let token 148 | 149 | if (this.debug) { 150 | console.log('full input:', '>' + this.string + '<') 151 | } 152 | 153 | while (true) { 154 | const pos = this.i 155 | const char = this.readChar() 156 | 157 | if (this.debug) { 158 | console.log( 159 | 'position:', pos, 160 | 'input:', '>' + char + '<', 161 | 'accumulated:', token, 162 | 'inQuote:', inQuote, 163 | 'inDollarQuote:', inDollarQuote, 164 | 'lastDollar:', lastDollar, 165 | 'escaped:', escaped 166 | ) 167 | } 168 | 169 | // Ran out of characters, we're done 170 | if (char === '') { 171 | if (inQuote) { throw new Error('Got EOF while in a quoted string') } 172 | if (escaped) { throw new Error('Got EOF while in an escape sequence') } 173 | if (token !== undefined) { yield token } 174 | return 175 | } 176 | 177 | // We were in an escape sequence, complete it 178 | if (escaped) { 179 | if (char === '\n') { 180 | // An escaped newline just means to continue the command on the next 181 | // line. We just need to ignore it. 182 | } else if (inQuote) { 183 | // If we are in a quote, just accumulate the whole escape sequence, 184 | // as we will interpret escape sequences later. 185 | token = (token || '') + escaped + char 186 | } else { 187 | // Just use the literal character 188 | token = (token || '') + char 189 | } 190 | 191 | escaped = false 192 | continue 193 | } 194 | 195 | if (this.escapes.includes(char)) { 196 | if (!inQuote || inDollarQuote !== false || this.escapedQuotes.includes(inQuote)) { 197 | // We encountered an escape character, which is going to affect how 198 | // we treat the next character. 199 | escaped = char 200 | continue 201 | } else { 202 | // This string type doesn't use escape characters. Ignore for now. 203 | } 204 | } 205 | 206 | // We were in a string 207 | if (inQuote !== false) { 208 | // String is finished. Don't grab the quote character. 209 | if (char === inQuote) { 210 | token = this.processEscapes(token, inQuote, inDollarQuote === '\'') 211 | inQuote = false 212 | inDollarQuote = false 213 | continue 214 | } 215 | 216 | // String isn't finished yet, accumulate the character 217 | token = (token || '') + char 218 | continue 219 | } 220 | 221 | // This is the start of a new string, don't accumulate the quotation mark 222 | if (this.quotes.includes(char)) { 223 | inQuote = char 224 | if (lastDollar === pos - 1) { 225 | if (char === '\'' && !this.ansiCQuotes) { 226 | // Feature not enabled 227 | } else if (char === '"' && !this.localeQuotes) { 228 | // Feature not enabled 229 | } else { 230 | inDollarQuote = char 231 | } 232 | } 233 | 234 | token = (token || '') // fixes blank string 235 | 236 | if (inDollarQuote !== false) { 237 | // Drop the opening $ we captured before 238 | token = token.slice(0, -1) 239 | } 240 | 241 | continue 242 | } 243 | 244 | // This is a dollar sign, record that we saw it in case it's the start of 245 | // an ANSI C or localized string 246 | if (inQuote === false && char === '$') { 247 | lastDollar = pos 248 | } 249 | 250 | // This is whitespace, so yield the token if we have one 251 | if (this.whitespace.includes(char)) { 252 | if (token !== undefined) { yield token } 253 | token = undefined 254 | continue 255 | } 256 | 257 | // Otherwise, accumulate the character 258 | token = (token || '') + char 259 | } 260 | } 261 | } 262 | 263 | 264 | /** 265 | * Splits a given string using shell-like syntax. 266 | * 267 | * @param {String} s String to split. 268 | * @returns {String[]} 269 | */ 270 | shlexSplit = function (s) { 271 | return Array.from(new Shlexer(s)) 272 | } 273 | 274 | /** 275 | * Escapes a potentially shell-unsafe string using quotes. 276 | * 277 | * @param {String} s String to quote 278 | * @returns {String} 279 | */ 280 | shlexQuote = function (s) { 281 | if (s === '') { return '\'\'' } 282 | 283 | var unsafeRe = /[^\w@%\-+=:,./]/ 284 | if (!unsafeRe.test(s)) { return s } 285 | 286 | return '\'' + s.replace(/'/g, '\'"\'"\'') + '\'' 287 | } 288 | -------------------------------------------------------------------------------- /js/utils/storage-emitter.js: -------------------------------------------------------------------------------- 1 | class StorageEmitter { 2 | constructor() { 3 | this.data = new Map(); 4 | this.events = new Map(); 5 | } 6 | 7 | onChange = (key, callback, fireOnInit) => { 8 | if (this.events.has(key)) { 9 | this.events.get(key).push(callback); 10 | } else { 11 | this.events.set(key, [callback]); 12 | } 13 | 14 | if (fireOnInit && this.data.has(key)) { 15 | if (Debug.All || Debug.Storage) { 16 | console.error(`Storage onChange fireOnInit for ${key} with value: ${this.data.get(key)}`); 17 | } 18 | callback(key, this.data.get(key)); 19 | } 20 | } 21 | 22 | set = (key, value) => { 23 | if (Debug.All || Debug.Storage) { 24 | console.error(`Storage set ${key} to: ${value}`); 25 | } 26 | if ( 27 | !this.data.has(key) || this.data.get(key) !== value 28 | ) { 29 | if (this.events.has(key)) { 30 | if (Debug.All || Debug.Storage) { 31 | console.error(`Storage callback requests for ${key} initiated`); 32 | } 33 | this.events.get(key).forEach(callback => 34 | callback(key, value, this.data.has(key) ? this.data.get(key) : null)); 35 | } 36 | this.data.set(key, value); 37 | } 38 | } 39 | } 40 | 41 | var Storage = new StorageEmitter(); 42 | -------------------------------------------------------------------------------- /js/utils/utils.js: -------------------------------------------------------------------------------- 1 | let AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; 2 | 3 | /** 4 | * Clamp a number between two values. 5 | * @param {number} num number to clamp 6 | * @param {number} min minimum number allowed 7 | * @param {number} max maximum number allowed 8 | */ 9 | const clamp = (num, min, max) => Math.min(Math.max(num, min), max); 10 | 11 | /** 12 | * Create a random string of the provided length. 13 | * @param {number} length string length to generate 14 | */ 15 | function randomString(length) { 16 | var text = ""; 17 | var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 18 | for(var i = 0; i < length; i++) { 19 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 20 | } 21 | return text; 22 | } 23 | 24 | /** 25 | * Retrieve the data from a url and convert to a blob. 26 | * @param {string} url URL to download data 27 | */ 28 | function getBlobFromUrl(url) { 29 | return new Promise((resolve, reject) => { 30 | let request = new XMLHttpRequest(); 31 | request.open('GET', url, true); 32 | request.responseType = 'blob'; 33 | request.onload = () => { 34 | resolve(request.response); 35 | }; 36 | request.onerror = reject; 37 | request.send(); 38 | }) 39 | } 40 | 41 | /** 42 | * Convert the URL to a file into a File object. 43 | * @param {string} fileUrl contents of trigger line 44 | */ 45 | async function convertUrlToFileObj(fileUrl) { 46 | try { 47 | var fileName = fileUrl.substring(fileUrl.lastIndexOf('/')); 48 | var fileBlob = await getBlobFromUrl(fileUrl); 49 | var file = new File([fileBlob], fileName); 50 | return file; 51 | } catch (err) { 52 | console.log(err); 53 | return null; 54 | } 55 | } 56 | 57 | /** 58 | * Read the file and send the data to the callback. 59 | * @param {string} method type of API call 60 | * @param {string} url url to call 61 | * @param {object} data parameters to send with the call 62 | * @param {object} headers headers to send with the call 63 | * @param {object} ajaxArgs options added to the $.ajax call 64 | */ 65 | async function callAPI(method, url, data, headers, ajaxArgs) { 66 | data = data || {}; 67 | headers = headers || {}; 68 | ajaxArgs = ajaxArgs || {}; 69 | var response = null; 70 | try { 71 | await $.ajax({ 72 | url: url, 73 | type: method, 74 | data: data, 75 | headers: headers, 76 | success: function(data) { 77 | response = data; 78 | }, 79 | error: function(data) { 80 | console.error(`Error calling the ${url} API: ${JSON.stringify(data)}`); 81 | }, 82 | ...ajaxArgs 83 | }); 84 | } catch (err) { 85 | response = 'Error'; 86 | } 87 | 88 | return response; 89 | } 90 | 91 | /** 92 | * Read the file and send the data to the callback. 93 | * @param {string} file name of the file 94 | */ 95 | async function readFile(file) { 96 | var response = ""; 97 | await $.ajax({ 98 | url: file, 99 | type: 'GET', 100 | dataType: 'text', 101 | success: function(data) { 102 | response = data; 103 | }, 104 | error: function(data) { 105 | console.error(`Error reading the ${file} file! Please open the html in Microsoft Edge or your broadcasting software.`); 106 | } 107 | }); 108 | return response; 109 | } 110 | 111 | /** 112 | * Get the user's id and send to the callback. 113 | * @param {string} user name user 114 | * @param {function} callback function to call with file data 115 | */ 116 | async function getIdFromUser(user) { 117 | var response = ""; 118 | await $.ajax({ 119 | url: 'https://decapi.me/twitch/id/' + user, 120 | type: 'GET', 121 | dataType: 'text', 122 | success: function(data) { 123 | response = data; 124 | }, 125 | error: function(data) { 126 | console.error(`Error getting the user id for ${user}! Please double-check that your user is spelled correctly.`); 127 | } 128 | }); 129 | 130 | if (response === `User not found: ${user}`) { 131 | console.error(`Unable to get the ID for the input user: ${user}`); 132 | return ''; 133 | } 134 | 135 | return response; 136 | } 137 | 138 | /** 139 | * Escape a string for use in a RegExp 140 | * @param {string} value input string to escape 141 | */ 142 | function escapeRegExp(value) { 143 | return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 144 | } 145 | 146 | /** 147 | * Check if the input is numeric 148 | * @param {mixed} n input 149 | */ 150 | function isNumeric(n) { 151 | return !isNaN(parseFloat(n)) && isFinite(n); 152 | } 153 | 154 | /** 155 | * Decode any html encoded values in the input 156 | * @param {string} value input string to decode 157 | */ 158 | function htmlDecode(value) { 159 | return new DOMParser().parseFromString(value,'text/html').querySelector('html').textContent; 160 | } 161 | 162 | /** 163 | * Increment the value by the input increment. 164 | * If the value is not numeric, sets to 0. 165 | * 166 | * @param {mixed} value starting value 167 | * @param {numeric} increment incremental value 168 | */ 169 | function incrementVar(value, increment) { 170 | if (!isNumeric(value)) { 171 | value = 0; 172 | } else { 173 | value = parseFloat(value); 174 | } 175 | return value + increment; 176 | } 177 | 178 | /** 179 | * Proper cases the input string. 180 | * - capitalizes the first letter of every word. 181 | */ 182 | Object.defineProperty(String.prototype, "toProperCase", { 183 | value: function toProperCase() { 184 | var sentence = this.toLowerCase().split(" "); 185 | for(var i = 0; i < sentence.length; i++){ 186 | sentence[i] = sentence[i][0].toUpperCase() + sentence[i].slice(1); 187 | } 188 | return sentence.join(' '); 189 | }, 190 | writable: true, 191 | configurable: true 192 | }); 193 | 194 | /** 195 | * Return a promise for the specified amount of milliseconds 196 | * @param {number} ms Milliseconds to wait in the promise 197 | */ 198 | function timeout(ms) { 199 | return new Promise(resolve => setTimeout(resolve, ms)); 200 | } 201 | -------------------------------------------------------------------------------- /js/variable/variableHandler.js: -------------------------------------------------------------------------------- 1 | class VariableHandler extends Handler { 2 | /** 3 | * Create a new Variable handler. 4 | */ 5 | constructor() { 6 | super('Variable', []); 7 | this.autoload = false; 8 | this.globals = {}; 9 | this.variables = {}; 10 | this.success(); 11 | } 12 | 13 | /** 14 | * Initialize the variable handler with the input settings. 15 | * @param {string} autoload on/off to toggle variables autoloading 16 | */ 17 | init = (autoload) => { 18 | this.autoload = autoload.toLowerCase() === "on" ? true : false; 19 | if (this.autoload) { 20 | this.loadGlobalVariables(); 21 | } 22 | } 23 | 24 | /** 25 | * Handle the input data (take an action). 26 | * @param {array} triggerData contents of trigger line 27 | */ 28 | handleData = async (triggerData) => { 29 | var action = Parser.getAction(triggerData, 'Variable'); 30 | if (action === 'global') { 31 | action = Parser.getAction(triggerData, 'Variable', 1) 32 | // Loads a global variable 33 | if (action === 'load') { 34 | var { varName } = Parser.getInputs(triggerData, ['global', 'action', 'varName']); 35 | var variable = await IDBService.get(varName) || 'No variable found'; 36 | this.globals[varName] = variable; 37 | return {[varName]: variable}; 38 | } 39 | // Clears all global variables 40 | else if (action === 'clear') { 41 | this.globals = {}; 42 | IDBService.clear(); 43 | } 44 | // Remove a global variable 45 | else if (action === 'remove') { 46 | var { varName } = Parser.getInputs(triggerData, ['global', 'action', 'varName']); 47 | delete this.globals[varName]; 48 | IDBService.delete(varName); 49 | } 50 | // Set a global variable 51 | else if (action === 'set') { 52 | var { varName, variable } = Parser.getInputs(triggerData, ['global', 'action', 'varName', 'variable']); 53 | this.globals[varName] = variable; 54 | IDBService.set(varName, variable); 55 | return {[varName]: variable}; 56 | } 57 | } else { 58 | // Load a variable 59 | if (action === 'load') { 60 | var { varName } = Parser.getInputs(triggerData, ['action', 'varName']); 61 | var variable = this.variables[varName] || 'No variable found'; 62 | return {[varName]: variable}; 63 | } 64 | // Sets a variable 65 | else if (action === 'set') { 66 | var { varName, variable } = Parser.getInputs(triggerData, ['action', 'varName', 'variable']); 67 | this.variables[varName] = variable; 68 | return {[varName]: variable}; 69 | } 70 | // Removes a variable 71 | else if (action === 'remove') { 72 | var { varName } = Parser.getInputs(triggerData, ['action', 'varName']); 73 | if (this.variables.hasOwnProperty(varName)) { 74 | delete this.variables[varName]; 75 | } 76 | } 77 | } 78 | } 79 | 80 | /** 81 | * Retrieve all variables (session and global). 82 | */ 83 | getVariables = async () => { 84 | if (this.autoload) { 85 | return { 86 | ...this.globals, 87 | ...this.variables 88 | }; 89 | } 90 | 91 | return {}; 92 | } 93 | 94 | /** 95 | * Load all global variables from storage. 96 | */ 97 | loadGlobalVariables = async () => { 98 | this.globals = await IDBService.entries(); 99 | } 100 | } 101 | 102 | /** 103 | * Create a handler 104 | */ 105 | async function variableHandlerExport() { 106 | var variable = new VariableHandler(); 107 | var autoload = await readFile('settings/variable/autoload.txt'); 108 | variable.init(autoload.trim()); 109 | } 110 | variableHandlerExport(); 111 | -------------------------------------------------------------------------------- /js/voicemod/voicemod-socket.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Connect to the Voicemod websocket. 3 | * @param {string} address host for the Voicemod API 4 | * @param {string} apiKey Voicemod API key 5 | * @param {Function} onConnect Function to call when Voicemod is connected 6 | */ 7 | function connectVoicemodWebsocket(address, apiKey, onConnect) { 8 | var promises = {}; 9 | var apiWrapper = {}; 10 | 11 | //Connect to socket 12 | websocket = new WebSocket(address); 13 | 14 | websocket.onopen = () => { 15 | if (Debug.Voicemod || Debug.All) { 16 | console.error('Successfully opened the voicemod websocket'); 17 | } 18 | onConnect(); 19 | send('registerClient', { 20 | clientKey: apiKey 21 | }); 22 | }; 23 | 24 | websocket.onclose = (evt) => { 25 | console.error('Voicemod websocket connection closed'); 26 | }; 27 | 28 | websocket.onerror = (err) => { 29 | console.error('Voicemod websocket error'); 30 | console.error(JSON.stringify(err)); 31 | }; 32 | 33 | websocket.onmessage = (e) => { 34 | if (e.data) { 35 | message = JSON.parse(e.data); 36 | 37 | if (Debug.Voicemod || Debug.All) { 38 | console.error(e.data); 39 | } 40 | 41 | var messageId = message.id || message.actionId; 42 | 43 | if (promises[messageId]) { 44 | promises[messageId](message); 45 | } 46 | } 47 | } 48 | 49 | var send = async (action, payload) => { 50 | payload = payload || {}; 51 | var id = uuidv4(); 52 | var request = { 53 | id, 54 | action, 55 | payload 56 | }; 57 | 58 | return await new Promise((resolve) => { 59 | promises[id] = resolve; 60 | websocket.send(JSON.stringify(request)); 61 | }); 62 | } 63 | 64 | apiWrapper.getVoices = async () => { 65 | var data = await send('getVoices'); 66 | return data.payload.voices; 67 | } 68 | 69 | apiWrapper.getCurrentVoice = async () => { 70 | var data = await send('getVoices'); 71 | if (data.getCurrentVoice === 'nofx') { 72 | return 'nofx'; 73 | } 74 | 75 | var voice = data.actionObject.voices.filter(option => option.id === data.currentVoice)[0]; 76 | return voice.friendlyName; 77 | } 78 | 79 | apiWrapper.getAllSoundboard = async () => { 80 | var data = await send('getAllSoundboard'); 81 | return data.payload.soundboards; 82 | } 83 | 84 | apiWrapper.setVoice = async (voiceID) => { 85 | await send('loadVoice', { 86 | voiceID 87 | }); 88 | } 89 | 90 | apiWrapper.setRandomVoice = async (mode) => { 91 | var payload = {}; 92 | var modeOptions = { 93 | custom: "CustomVoices", 94 | favorite: "FavoriteVoices" 95 | } 96 | 97 | if (mode && modeOptions[mode]) { 98 | payload.mode = modeOptions[mode]; 99 | } 100 | send('selectRandomVoice', payload); 101 | } 102 | 103 | apiWrapper.getHearMyselfStatus = async () => { 104 | var data = await send('getHearMyselfStatus'); 105 | return data.actionObject.value; 106 | } 107 | 108 | apiWrapper.setHearMyselfStatus = async (status) => { 109 | if (status === 'toggle') { 110 | send('toggleHearMyVoice'); 111 | return; 112 | } 113 | 114 | var currentStatus = await apiWrapper.getHearMyselfStatus(); 115 | if ( 116 | (status === 'on' && !currentStatus) || 117 | (status === 'off' && currentStatus) 118 | ) { 119 | send('toggleHearMyVoice'); 120 | } 121 | } 122 | 123 | apiWrapper.getVoiceChangerStatus = async () => { 124 | var data = await send('getVoiceChangerStatus'); 125 | return data.actionObject.value; 126 | } 127 | 128 | apiWrapper.setVoiceChangerStatus = async (status) => { 129 | if (status === 'toggle') { 130 | send('toggleVoiceChanger'); 131 | return; 132 | } 133 | 134 | var currentStatus = await apiWrapper.getVoiceChangerStatus(); 135 | if ( 136 | (status === 'on' && !currentStatus) || 137 | (status === 'off' && currentStatus) 138 | ) { 139 | send('toggleVoiceChanger'); 140 | } 141 | } 142 | 143 | apiWrapper.getBackgroundEffectStatus = async () => { 144 | var data = await send('getBackgroundEffectStatus'); 145 | return data.actionObject.value; 146 | } 147 | 148 | apiWrapper.setBackgroundEffectStatus = async (status) => { 149 | if (status === 'toggle') { 150 | send('toggleBackground'); 151 | return; 152 | } 153 | 154 | var currentStatus = await apiWrapper.getBackgroundEffectStatus(); 155 | if ( 156 | (status === 'on' && !currentStatus) || 157 | (status === 'off' && currentStatus) 158 | ) { 159 | send('toggleBackground'); 160 | } 161 | } 162 | 163 | apiWrapper.getMuteMicStatus = async () => { 164 | var data = await send('getMuteMicStatus'); 165 | return data.actionObject.value; 166 | } 167 | 168 | apiWrapper.setMuteMicStatus = async (status) => { 169 | if (status === 'toggle') { 170 | send('toggleMuteMic'); 171 | return; 172 | } 173 | 174 | var currentStatus = await apiWrapper.getMuteMicStatus(); 175 | if ( 176 | (status === 'on' && !currentStatus) || 177 | (status === 'off' && currentStatus) 178 | ) { 179 | send('toggleMuteMic'); 180 | } 181 | } 182 | 183 | apiWrapper.beep = async (duration) => { 184 | send('setBeepSound', { badLanguage: 1 }); 185 | setTimeout(() => { 186 | send('setBeepSound', { badLanguage: 0 }) 187 | }, duration); 188 | } 189 | 190 | apiWrapper.playAudio = async (FileName) => { 191 | send('playMeme', { FileName, "IsKeyDown": true }); 192 | } 193 | 194 | apiWrapper.stopAudio = async () => { 195 | send('stopAllMemeSounds'); 196 | } 197 | 198 | return apiWrapper; 199 | } 200 | -------------------------------------------------------------------------------- /js/voicemod/voicemodHandler.js: -------------------------------------------------------------------------------- 1 | class VoicemodHandler extends Handler { 2 | /** 3 | * Create a new Voicemod handler. 4 | */ 5 | constructor() { 6 | super('Voicemod', []); 7 | } 8 | 9 | /** 10 | * Initialize the connection to voicemod with the input settings. 11 | * @param {string} address voicemod websocket address 12 | * @param {string} password voicemod websocket password 13 | */ 14 | init = (address, apiKey) => { 15 | this.api = connectVoicemodWebsocket( 16 | address, apiKey, this.success 17 | ); 18 | } 19 | 20 | /** 21 | * Handle the input data (take an action). 22 | * @param {array} triggerData contents of trigger line 23 | */ 24 | handleData = async (triggerData) => { 25 | var action = Parser.getAction(triggerData, 'Voicemod'); 26 | switch (action) { 27 | case 'background': 28 | var { status } = Parser.getInputs(triggerData, ['action', 'status']); 29 | 30 | status = status.toLowerCase(); 31 | var statuses = ['off', 'on', 'toggle']; 32 | if (statuses.indexOf(status) === -1) { 33 | console.error(`Invalid status value for Voicemod Background. Found "${status}", expected one of "${statuses.join('", "')}".`) 34 | return; 35 | } 36 | 37 | await this.api.setBackgroundEffectStatus(status); 38 | break; 39 | case 'beep': 40 | var { duration = 1 } = Parser.getInputs(triggerData, ['action', 'duration'], false, 1); 41 | 42 | var beep_duration = 1000; 43 | if (isNumeric(duration)) { 44 | beep_duration = parseInt(duration) * 1000; 45 | } 46 | await this.api.beep(beep_duration); 47 | break; 48 | case 'hear': 49 | var { status } = Parser.getInputs(triggerData, ['action', 'status']); 50 | 51 | status = status.toLowerCase(); 52 | var statuses = ['off', 'on', 'toggle']; 53 | if (statuses.indexOf(status) === -1) { 54 | console.error(`Invalid status value for Voicemod Hear. Found "${status}", expected one of "${statuses.join('", "')}".`) 55 | return; 56 | } 57 | 58 | await this.api.setHearMyselfStatus(status); 59 | break; 60 | case 'mute': 61 | var { status } = Parser.getInputs(triggerData, ['action', 'status']); 62 | 63 | status = status.toLowerCase(); 64 | var statuses = ['off', 'on', 'toggle']; 65 | if (statuses.indexOf(status) === -1) { 66 | console.error(`Invalid status value for Voicemod Mute. Found "${status}", expected one of "${statuses.join('", "')}".`) 67 | return; 68 | } 69 | 70 | await this.api.setMuteMicStatus(status); 71 | break; 72 | case 'random': 73 | var { mode = '' } = Parser.getInputs(triggerData, ['action', 'mode'], false, 1); 74 | 75 | mode = mode.toLowerCase(); 76 | var modes = ['custom', 'favorite']; 77 | if (mode && modes.indexOf(mode) === -1) { 78 | console.error(`Invalid mode value for Voicemod Random. Found "${mode}", expected one of "${modes.join('", "')}".`) 79 | return; 80 | } 81 | 82 | await this.api.setRandomVoice(mode); 83 | break; 84 | case 'voice': 85 | var { voice } = Parser.getInputs(triggerData, ['action', 'voice']); 86 | var voices = await this.api.getVoices(); 87 | var match = voices.filter(option => option.friendlyName === voice)[0]; 88 | 89 | if (!match) { 90 | console.error(`Unable to find voice id Voicemod Voice given: "${voice}"`) 91 | } 92 | 93 | await this.api.setVoice(match.id); 94 | break; 95 | case 'voicechanger': 96 | var { status } = Parser.getInputs(triggerData, ['action', 'status']); 97 | 98 | status = status.toLowerCase(); 99 | var statuses = ['off', 'on', 'toggle']; 100 | if (statuses.indexOf(status) === -1) { 101 | console.error(`Invalid status value for Voicemod VoiceChanger. Found "${status}", expected one of "${statuses.join('", "')}".`) 102 | return; 103 | } 104 | 105 | await this.api.setVoiceChangerStatus(status); 106 | break; 107 | case 'play': 108 | var { soundboard, sound } = Parser.getInputs(triggerData, ['action', 'soundboard', 'sound']); 109 | var soundboards = await this.api.getAllSoundboard(); 110 | 111 | var board = soundboards.filter(option => option.name === soundboard)[0]; 112 | if (!board) { 113 | console.error(`Unable to find Voicemod Play soundboard with name: "${soundboard}"`) 114 | return; 115 | } 116 | 117 | var match = board.sounds.filter(option => option.name === sound)[0]; 118 | if (!match) { 119 | console.error(`Unable to find Voicemod Play sound with name, "${sound}, in the soundboard, ${soundboard}"`) 120 | return; 121 | } 122 | 123 | await this.api.playAudio(match.id); 124 | break; 125 | case 'stop': 126 | this.api.stopAudio(); 127 | break; 128 | } 129 | } 130 | } 131 | 132 | /** 133 | * Create a handler 134 | */ 135 | async function voicemodHandlerExport() { 136 | var voicemod = new VoicemodHandler(); 137 | var address = await readFile('settings/voicemod/address.txt'); 138 | var apiKey = await readFile('settings/voicemod/apiKey.txt'); 139 | voicemod.init(address.trim(), apiKey.trim()); 140 | } 141 | voicemodHandlerExport(); 142 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kruiser8/Kruiz-Control/20f6dc7509847055ff95cebad1904524e3cab346/logo.png -------------------------------------------------------------------------------- /settings/Settings.md: -------------------------------------------------------------------------------- 1 | # Kruiz Control Settings 2 | 3 | The following details each of the settings files that need to be filled out for each handler. An example is provided for each file. 4 | 5 | All settings files are found in the `settings` folder. 6 | 7 | This script uses text files to improve the average user experience. 8 | 9 | ## Table of Contents 10 | 11 | - [Chat](#chat) 12 | - [MQTT](#mqtt) 13 | - [OBS](#obs) 14 | - [SLOBS](#slobs) 15 | - [StreamElements](#streamelements) 16 | - [Streamlabs](#streamlabs) 17 | - [Twitch](#twitch) 18 | - [Variable](#variable) 19 | - [Voicemod](#voicemod) 20 | 21 | *** 22 | 23 | ## Chat 24 | 25 | ### channel.txt 26 | **Location:** `settings/chat/channel.txt` 27 | 28 | Specify the twitch channel to connect for chatting. 29 | ``` 30 | kruiser8 31 | ``` 32 | 33 | ### code.txt 34 | **Location:** `settings/chat/code.txt` 35 | 36 | _**NOTE: Leave this file blank if you want Kruiz Control to send messages as your main Twitch account. That is, only use this if you want to send messages as a separate bot account.**_ 37 | 38 | **DO NOT COPY YOUR TWITCH CODE.TXT VALUE HERE** 39 | 40 | Follow the instructions in the [Twitch Code](#code) section below. At step 5, login with the desired account. Copy the generated code into this file. 41 | 42 | ``` 43 | exampleoauth4kruizcontrol12345 44 | ``` 45 | 46 | *Reminder:* 47 | - _channel.txt_ specifies the channel to connect to. 48 | - _code.txt_ specifies the user to send messages as. 49 | 50 | *** 51 | 52 | ## MQTT 53 | 54 | ### websocket.txt 55 | **Location:** `settings/mqtt/websocket.txt` 56 | 57 | Specify the IP (or hostname) + port to use for the MQTT websocket. 58 | ``` 59 | ws://localhost:8883 60 | ``` 61 | 62 | ### username.txt 63 | **Location:** `settings/mqtt/username.txt` 64 | 65 | _**NOTE: Leave this file blank if your broker doesn't require authentication.**_ 66 | 67 | Specify the username required by your broker, if any. 68 | ``` 69 | kcmqttuser 70 | ``` 71 | 72 | ### password.txt 73 | **Location:** `settings/mqtt/password.txt` 74 | 75 | _**NOTE: Leave this file blank if your broker doesn't require authentication.**_ 76 | 77 | Specify the password required by your broker, if any 78 | ``` 79 | p@55w0rd 80 | ``` 81 | 82 | *** 83 | 84 | ## OBS 85 | These settings can be found through **Tools** > **WebSockets Server Settings**. 86 | 87 | ### address.txt 88 | **Location:** `settings/obs/address.txt` 89 | 90 | Specify the IP + port to use for the OBS websocket. 91 | ``` 92 | ws://127.0.0.1:4455 93 | ``` 94 | 95 | *** 96 | 97 | ### password.txt 98 | **Location:** `settings/obs/password.txt` 99 | 100 | Specify the password to use when connecting to the websocket. 101 | ``` 102 | my0b5p455w0rd 103 | ``` 104 | 105 | *** 106 | 107 | ## SLOBS 108 | Follow the below steps to enable the SLOBS API for Kruiz Control. 109 | - Open SLOBS 110 | - Click the settings gear in the bottom left 111 | - Open the `Remote Control` tab 112 | - Click the QR Code so that it shows. 113 | - Click the `Show details` text/link that appears. 114 | - Copy the **API Token** value into the **token.txt** file (referenced below). 115 | - Close SLOBS. 116 | - Run SLOBS as administrator to enable the SLOBS Remote Control API. 117 | 118 | ### token.txt 119 | **Location:** `settings/slobs/token.txt` 120 | 121 | ``` 122 | eyJAFOI3qoi4ut6345ogno5iuyt890 123 | ``` 124 | 125 | *** 126 | 127 | ## StreamElements 128 | To capture alerts through [StreamElements](https://streamelements.com/), you'll need a JWT token. To get your JWT token, 129 | - Go to [your account settings](https://streamelements.com/dashboard/account/channels) 130 | - Click the **Show secrets** toggle on the right 131 | - Copy the JWT Token value that appears 132 | 133 | ### jwtToken.txt 134 | **Location:** `settings/streamelements/jwtToken.txt` 135 | 136 | ``` 137 | eyJAFOI3qoi4ut6345ogno5iuyt89058gn589tyjh589h98h509ASUDF98Uuf98adshf9asfha89hga9hg9H8HA98HG98DAH98ADH8HG98ha989a9H9HG98DHh9DSHG89shg98h89DH98hh8H98gsdhg9D8SHD89GH9dshg89DSHG98HFSFNLJKFH98HNSDINVC98DSHGFw08hwewf 138 | ``` 139 | 140 | *** 141 | 142 | ## Streamlabs 143 | To capture alerts through [Streamlabs](https://streamlabs.com/), you'll need your **Socket API Token**. To get your token, 144 | 145 | - Go to https://streamlabs.com/dashboard#/settings/api-settings 146 | - Click the **API Tokens** tab 147 | - Copy the **Your Socket API Token** value 148 | 149 | ### socketAPIToken.txt 150 | **Location:** `settings/streamlabs/socketAPIToken.txt` 151 | 152 | ``` 153 | eyJhaoiuh798a99h7HBN879DHF98A789aigfba8790gfh987Fb78987BgUYF4SD56gI9Uh98786rf7tVBg97Gf56dxCilbh8OYf6r5SDX6cuyoIB97768FD76d546SD6iGVBUIb9i980YH897676f8FiUB9OIu8g78D6d5BiIU 154 | ``` 155 | 156 | *** 157 | 158 | ## Twitch 159 | To capture Twitch events, you'll need a **Client Id** and **Client Secret** and a generated auth token for your Twitch user. 160 | 161 | ### Client ID and Secret 162 | To capture Twitch events, you'll need a **Client Id** and **Client Secret**. To create your own, 163 | 1. Go to https://dev.twitch.tv/login and login with your Twitch account. 164 | 2. Navigate to https://dev.twitch.tv/console. 165 | 3. Once logged in, on the left sidebar click `Applications`. 166 | 4. Click the `+ Register Your Application` button and enter the following details: 167 | - Name: `YOUR_USERNAME Kruiz Control` (Any name works, as long as it is unique) 168 | - OAuth Redirect URLs: `http://localhost` 169 | - Category: `Chat Bot` 170 | 5. Click the `Create` button at the bottom. 171 | 6. Click the `Manage` button on the right hand side for the application you created. 172 | 7. Copy the **Client ID** value and put it in the `settings/twitch/clientId.txt` file. 173 | 8. Click the `New Secret` button, confirm the prompt, and copy and paste the value into the `settings/twitch/clientSecret.txt` file. 174 | 175 | ### Code 176 | Some APIs and events require a user authenticated auth code. To generate one of these, first follow the Client ID and Secret instructions to update the `settings/twitch/clientId.txt` and `settings/twitch/clientSecret.txt` files. Then follow the below steps: 177 | 1. Add the below to your Kruiz Control `triggers.txt` file to add an authentication link to your OBS or SLOBS log file. 178 | ``` 179 | ### Twitch Authenticate ### 180 | OnInit 181 | Twitch Authenticate 182 | Error {auth_url} 183 | ``` 184 | 2. Reset Kruiz Control (refresh the browser source) to generate the link . 185 | 3. Open your OBS or SLOBS log file to find the URL. 186 | - For OBS, go to `Help` > `Log Files` > `View Current Log`. 187 | - For SLOBS, open the settings cog (bottom left) and then go to `Get Support` and click the `Show Cache Directory` option under `Cache Directory`. Open the `node-obs` folder and then the `logs` folder. Open the most recently modified file. 188 | 4. Copy the link at the bottom of the log file, and open it in a browser. 189 | - The link will start with `https://id.twitch.tv/oauth2/authorize` 190 | 5. Login to your Twitch account. This provides the ability to control your stream via the created https://dev.twitch.tv application. 191 | 6. After login, you'll be redirected to a link that looks like the below. 192 | ``` 193 | http://localhost/?code=YOUR_CODE_HERE&scope=bits%3Aread... 194 | ``` 195 | 7. Copy the `YOUR_CODE_HERE` value and put that into your `settings/twitch/code.txt` file. 196 | 8. Once you have generated a code, you can remove the `### Twitch Authenticate ###` event from your `triggers.txt` file. 197 | 198 | 199 | ### clientId.txt 200 | **Location:** `settings/twitch/clientId.txt` 201 | 202 | Specify the Twitch Client ID to use when making API calls. 203 | ``` 204 | wasdfe23r9yujasbnvo9qhfiuqa328 205 | ``` 206 | 207 | ### clientSecret.txt 208 | **Location:** `settings/twitch/clientSecret.txt` 209 | 210 | Specify the Twitch Client Secret to use when making API calls. 211 | ``` 212 | h30984thfa9uegh90awiejhtgfqgq3 213 | ``` 214 | 215 | ### code.txt 216 | **Location:** `settings/twitch/code.txt` 217 | 218 | Specify the Twitch auth code generated after logging in to your Twitch account. 219 | ``` 220 | 098wh4ijbngse7w9r87yqu3gbq398f 221 | ``` 222 | 223 | ### user.txt 224 | **Location:** `settings/twitch/user.txt` 225 | 226 | Specify the Twitch channel that Kruiz Control will react to and control through triggers and actions. 227 | ``` 228 | kruiser8 229 | ``` 230 | 231 | *** 232 | 233 | ## Variable 234 | Use the below settings to toggle how Variables are configured in Kruiz Control. 235 | 236 | ### autoload.txt 237 | **Location:** `settings/variable/autoload.txt` 238 | 239 | **Format:** `` 240 | 241 | Specify whether or not Kruiz Control should automatically load all variables (session and global) without having to use [`Variable Load`](js/Documentation.md#variable-load) or [`Variable Global Load`](js/Documentation.md#variable-global-load). 242 | 243 | (Default) If set to `off`, variables must be loaded before they can be used. 244 | 245 | If set to `on`, all variables are usable as soon as they are set or Kruiz Control initializes. 246 | ``` 247 | off 248 | ``` 249 | 250 | *** 251 | 252 | ## Voicemod 253 | To connect to [Voicemod](https://www.voicemod.net/), you'll need an **API Key**. To get your API Key, 254 | 255 | - Fill out [Voicemod's form](https://voicemod.typeform.com/to/Zh5ZHRED) to request a key. 256 | - After submitting the form, check your email inbox for an email from control.api.devs@voicemod.net with your `clientKey`. 257 | - Copy the `clientKey` value. It should look something like `abc-defh12345`. 258 | - Paste the `clientKey` into the `settings/voicemod/apiKey.txt` file. 259 | 260 | ### address.txt 261 | **Location:** `settings/voicemod/address.txt` 262 | 263 | ``` 264 | ws://localhost:59129/v1 265 | ``` 266 | 267 | ### apiKey.txt 268 | **Location:** `settings/voicemod/apiKey.txt` 269 | 270 | ``` 271 | abc-defg12345 272 | ``` 273 | -------------------------------------------------------------------------------- /settings/chat/channel.txt: -------------------------------------------------------------------------------- 1 | username -------------------------------------------------------------------------------- /settings/chat/code.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kruiser8/Kruiz-Control/20f6dc7509847055ff95cebad1904524e3cab346/settings/chat/code.txt -------------------------------------------------------------------------------- /settings/mqtt/password.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kruiser8/Kruiz-Control/20f6dc7509847055ff95cebad1904524e3cab346/settings/mqtt/password.txt -------------------------------------------------------------------------------- /settings/mqtt/username.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kruiser8/Kruiz-Control/20f6dc7509847055ff95cebad1904524e3cab346/settings/mqtt/username.txt -------------------------------------------------------------------------------- /settings/mqtt/websocket.txt: -------------------------------------------------------------------------------- 1 | ws://localhost:8883 -------------------------------------------------------------------------------- /settings/obs/address.txt: -------------------------------------------------------------------------------- 1 | ws://127.0.0.1:4455 2 | -------------------------------------------------------------------------------- /settings/obs/password.txt: -------------------------------------------------------------------------------- 1 | password 2 | -------------------------------------------------------------------------------- /settings/slobs/token.txt: -------------------------------------------------------------------------------- 1 | ex4mpletok3n -------------------------------------------------------------------------------- /settings/streamelements/jwtToken.txt: -------------------------------------------------------------------------------- 1 | jwtToken 2 | -------------------------------------------------------------------------------- /settings/streamlabs/socketAPIToken.txt: -------------------------------------------------------------------------------- 1 | socketAPIToken 2 | -------------------------------------------------------------------------------- /settings/twitch/clientId.txt: -------------------------------------------------------------------------------- 1 | exampleclientid 2 | -------------------------------------------------------------------------------- /settings/twitch/clientSecret.txt: -------------------------------------------------------------------------------- 1 | exampleclientsecret 2 | -------------------------------------------------------------------------------- /settings/twitch/code.txt: -------------------------------------------------------------------------------- 1 | examplecode 2 | -------------------------------------------------------------------------------- /settings/twitch/user.txt: -------------------------------------------------------------------------------- 1 | username 2 | -------------------------------------------------------------------------------- /settings/variable/autoload.txt: -------------------------------------------------------------------------------- 1 | off -------------------------------------------------------------------------------- /settings/voicemod/address.txt: -------------------------------------------------------------------------------- 1 | ws://localhost:59129/v1 2 | -------------------------------------------------------------------------------- /settings/voicemod/apiKey.txt: -------------------------------------------------------------------------------- 1 | api-key 2 | -------------------------------------------------------------------------------- /sounds/MashiahMusic__Kygo-Style-Melody.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kruiser8/Kruiz-Control/20f6dc7509847055ff95cebad1904524e3cab346/sounds/MashiahMusic__Kygo-Style-Melody.wav -------------------------------------------------------------------------------- /triggers.txt: -------------------------------------------------------------------------------- 1 | OnInit 2 | Chat Send "Kruiz Control Initialized" 3 | 4 | OnCommand b 0 !example 5 | Chat Send "Success! It worked!" 6 | 7 | OnCommand b 0 !kcreset 8 | Reset 9 | -------------------------------------------------------------------------------- /triggers/sample.txt: -------------------------------------------------------------------------------- 1 | OnCommand b 0 !sample1 2 | Delay 10 3 | Chat Send "The first sample" 4 | 5 | OnCommand b 0 !sample2 6 | Chat Send "The second sample" -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | v2.1.0 2 | --------------------------------------------------------------------------------