/** * FG.fm Automation */ // Import modules const irc = require('irc'); const schedule = require('node-schedule'); const util = require('./lib/util'); const GHOBS = require('./lib/ghobs'); // Read internal configuration let config = require('./config.json'); config.vods = require(config.vodConfigFile); let snesGames = require('./conf/snesgames.json'); // Set up initial state let state = { "videoQueue": [], "recentlyPlayed": [], "currentVideo": null, "videoTimer": null, "lastCommercialShownAt": Date.now(), "commercialPlaying": false }; const obs = new GHOBS(config); obs.init() .then(() => {return twitchInit(config.twitch)}) .then(twitch => {return streamInit(config, twitch)}) .catch(console.error); // Connect to twitch, set up basic event listeners const twitchInit = (config) => { return new Promise((resolve, reject) => { console.log(`Connecting to Twitch / ${config.channel}...`); let defaultTwitchConfig = { autoRejoin: true, retryCount: 10, channels: [config.channel], debug: config.debug }; // Connect to Twitch with the bot account let botChat = new irc.Client( config.ircServer, config.botLogin.username, Object.assign({password: config.botLogin.oauth}, defaultTwitchConfig) ); // Connect to Twitch with an editor account let editorChat = new irc.Client( config.ircServer, config.editorLogin.username, Object.assign({password: config.editorLogin.oauth}, defaultTwitchConfig) ); let twitchErrorHandler = message => { if (message.command != 'err_unknowncommand') { console.error('Error from Twitch IRC Server: ', message); } }; // Set up bare minimum event listeners for Twitch botChat.addListener('error', twitchErrorHandler); editorChat.addListener('error', twitchErrorHandler); resolve({"botChat": botChat, "editorChat": editorChat}); }); }; // Initialize Stream automation const streamInit = (config, twitch) => { return new Promise((resolve, reject) => { // Set up the initial queue by randomly choosing the configured amount of vods included in shuffling state.videoQueue = config.vods.alttp.filter(e => e.includeInShuffle === true).sort(util.randSort).slice(0, config.initialQueueSize); console.log(`Initial video queue: ${state.videoQueue.map((c, i) => `[${i+1}] ${c.chatName}`).join(' | ')}`); // Show a gameplay vod const showVideo = video => { console.log(`Showing video: ${video.chatName}`); // play the next video when the previous finishes let handleVideoEnd = () => {nextVideo()}; obs.playVideoInScene(video, config.defaultSceneName, handleVideoEnd) .then(timer => { // track timer so we can cancel callback later on if necessary state.videoTimer = timer; // update activity label and show/hide appropriately if (video.hasOwnProperty('label') && video.label !== false) { obs.showActivity(video.label); } else { obs.hideActivity(); } }) .catch(console.error); }; // Picks the next video in the queue (shuffles if empty) // Also handles "commercial breaks" if enabled const nextVideo = () => { // Show a "commercial break" if it's been long enough since the last one let secondsSinceLastCommercial = (Date.now() - state.lastCommercialShownAt) / 1000; if (config.commercialsEnabled === true && secondsSinceLastCommercial >= config.commercialInterval) { state.commercialPlaying = true; console.log(`It has been ${secondsSinceLastCommercial} seconds since the last commercial break!`); // Random chance for it to be "everybody wow" let memeId = false; if ((Math.floor(Math.random() * 100) + 1) <= config.auwChance) { console.log(`Showing AUW!`); memeId = 'auw'; } showMeme(memeId).then(() => { state.lastCommercialShownAt = Date.now(); state.commercialPlaying = false; nextVideo(); }).catch(console.error); return; } // Keep track of recently played videos if (state.recentlyPlayed.length === config.recentlyPlayedMemory) { state.recentlyPlayed.shift(); } state.recentlyPlayed.push(state.currentVideo.id); // If a commercial is playing, wait until it's done to switch while (state.commercialPlaying === true) {} // play the next video in the queue, or pick one at random if the queue is empty if (state.videoQueue.length > 0) { state.currentVideo = state.videoQueue.shift(); } else { // Random chance for room grind to be played for an amount of time instead of another video be shuffled to if ((Math.floor(Math.random() * 100) + 1) <= config.roomGrindChance) { console.log(`Room grind selected!`); // show room-grind source obs.showRoomGrind(config.roomGrindPlaytime, () => {nextVideo()}) .then(timer => { videoTimer = timer; }) .catch(console.error); return; } // filter recently played from shuffle let freshVods = config.vods.alttp.filter(e => { return e.includeInShuffle === true && !state.recentlyPlayed.includes(e.id); }); state.currentVideo = freshVods.sort(util.randSort).slice(0, 1).shift(); } showVideo(state.currentVideo); }; // Start queue playback state.currentVideo = state.videoQueue.shift(); showVideo(state.currentVideo); // "Commercials" const showCommercial = (video, callback) => { return new Promise((resolve, reject) => { let handleFinish = () => { // unmute songrequest audio //twitch.editorChat.say(config.twitch.channel, `!volume ${config.defaultSRVolume}`); if (typeof callback !== 'undefined') callback(); }; obs.playVideoInScene(video, config.commercialSceneName, handleFinish) .then(timer => { // mute songrequest audio //twitch.editorChat.say(config.twitch.channel, `!volume 0`); resolve(timer); }) .catch(reject); }); }; // Memes-By-Id const showMeme = (id) => { return new Promise((resolve, reject) => { // find the vod in memes let video = config.vods.memes.find(e => e.id === id); if (!video) { reject(`No meme found matching ID ${id}`); } let handleFinish = () => { if (id === 'auw') { obs.hide("owen", config.commercialSceneName); } resolve(); }; showCommercial(video, handleFinish) .then(videoHasStarted => { // in the case of 'auw', show owen + tell chat what's up if (id === 'auw') { obs.show("owen", config.commercialSceneName); twitch.botChat.say(config.twitch.channel, 'Everybody OwenWow'); } }) .catch(console.error); }); }; // Twitch Chat Commands twitch.botChat.addListener('message', (from, to, message) => { // Ignore everything from blacklisted users if (config.twitch.blacklistedUsers.includes(from)) return; // Listen for commands that start with the designated prefix if (message.startsWith(config.twitch.cmdPrefix)) { let commandParts = message.slice(config.twitch.cmdPrefix.length).split(' '); let commandNoPrefix = commandParts[0] || ''; // ADMIN COMMANDS if (config.twitch.admins.includes(from) || from === config.twitch.username.toLowerCase()) { // SHOW/HIDE SOURCE if (commandNoPrefix === 'show' || commandNoPrefix === 'hide') { let newVisibility = (commandNoPrefix === 'show'); let sceneItem = commandParts[1] || false; if (!sceneItem) { twitch.botChat.say(to, `A scene item name is required!`); return; } let sceneOrGroup = commandParts[2] || obs.currentScene; obs.setVisible(sceneItem, sceneOrGroup, newVisibility).catch(console.error); // TOGGLE SOURCE VISIBILITY } else if (commandNoPrefix === 't') { let sceneItem = commandParts[1] || false; if (!sceneItem) { twitch.botChat.say(to, `A scene item name is required!`); return; } obs.toggleVisible(sceneItem).catch(console.error); // EVERYBODY WOW } else if (commandNoPrefix === 'auw') { state.commercialPlaying = true; showMeme('auw').then(() => state.commercialPlaying = false).catch(console.error); // MEMES ON-DEMAND } else if (commandNoPrefix === 'meme') { let memeId = commandParts[1] || false; if (memeId) { console.log(`${memeId} meme requested`); if ( config.vods.memes.findIndex(e => e.id === memeId) === -1) { twitch.botChat.say(to, `No meme with that ID exists!`); return; } } else { memeId = config.vods.memes.sort(util.randSort)[0].id; console.log(`${memeId} meme randomly selected`); } state.commercialPlaying = true; showMeme(memeId).then(() => state.commercialPlaying = false).catch(console.error); // SWITCH SCENES } else if (commandNoPrefix === 'switch') { let newScene = commandParts[1] || false; if (!newScene) { twitch.botChat.say(to, `A scene name is required!`); return; } obs.switchToScene(newScene).catch(console.error); // SET ON-SCREEN ACTIVITY } else if (commandNoPrefix === 'setactivity') { let newActivity = commandParts.slice(1).join(' '); if (!newActivity) { twitch.botChat.say(to, `Please provide a new activity`); return; } obs.showActivity(newActivity).then(() => twitch.botChat.say(to, `Activity updated!`)).catch(console.error); // REBOOT } else if (commandNoPrefix === 'reboot') { console.log('Received request from admin to reboot...'); twitch.botChat.say(to, 'Rebooting...'); process.exit(0); // SKIP } else if (commandNoPrefix === 'skip') { clearTimeout(state.videoTimer); obs.hide(state.currentVideo.sceneItem, config.defaultSceneName).then(nextVideo).catch(console.error); // ADD } else if (commandNoPrefix === 'add') { let requestedVideoId = commandParts[1] || false; if (requestedVideoId === false) { twitch.botChat.say(to, `Missing video ID`); return; } // make sure request vid isn't in the queue already if (state.videoQueue.findIndex(e => e.id == requestedVideoId) !== -1) { twitch.botChat.say(to, `That video is in the queue already!`); return; } // search for req'd vid by id in config.vods.alttp let vodIndex = config.vods.alttp.findIndex(e => e.id == requestedVideoId); if (vodIndex === -1) { twitch.botChat.say(to, `A video with that ID does not exist!`); return; } // add to queue if it exists state.videoQueue.push(config.vods.alttp[vodIndex]); twitch.botChat.say(to, `${config.vods.alttp[vodIndex].chatName} has been added to the queue [${state.videoQueue.length}]`); return; // START VOTE } else if (commandNoPrefix === 'startvote') { videoVoteJob.reschedule(`*/${config.videoPollIntervalMinutes} * * * *`); twitch.botChat.say(to, `Video Queue Voting will start in ${config.videoPollIntervalMinutes} minutes!`); // PAUSE VOTE } else if (commandNoPrefix === 'pausevote') { clearInterval(rtvInterval); videoVoteJob.cancel(); twitch.botChat.say(to, `Video Queue Voting has been paused.`); } } //////////////// // USER COMMANDS // // VOTE FOR VIDEO if (commandNoPrefix === 'vote') { let userVote = commandParts[1] || false; if (userVote === false) { rockTheVote(); return; } userVote = Number.parseInt(userVote); if (!Number.isInteger(userVote) || userVote < 1 || userVote > currentChoices.length) { return twitch.botChat.say(to, `@${from}, please choose an option from 1 - ${currentChoices.length}!`); } // Check for uniqueness of vote // if it's not unique, update the vote let prevVote = userVotes.findIndex(e => e.from === from); if (prevVote !== -1) { if (userVotes[prevVote].vote !== userVote) { // update vote and inform the user userVotes[prevVote].vote = userVote; twitch.botChat.say(to, `@${from}, your vote has been updated!`); } else { twitch.botChat.say(to, `@${from}, your vote is already in!`); } } else { // log user vote userVotes.push({"from": from, "vote": userVote}); twitch.botChat.say(to, `@${from}, your vote has been logged!`); } // QUEUE STATUS } else if (commandNoPrefix === 'queue') { if (state.videoQueue.length > 0) { let chatQueue = state.videoQueue.map((c, i) => { return `[${i+1}] ${c.chatName}`; }); twitch.botChat.say(to, chatQueue.join(' | ')); } else { twitch.botChat.say(to, `No videos currently in queue!`); } // CURRENT VIDEO } else if (commandNoPrefix === 'current') { twitch.botChat.say(to, `Now Playing: ${state.currentVideo.chatName}`); // NEXT VIDEO } else if (commandNoPrefix === 'next') { if (state.videoQueue.length > 0) { twitch.botChat.say(to, `Next Video: ${state.videoQueue[0].chatName}`); } else { twitch.botChat.say(to, `No videos currently in queue!`); } // VIDEO REQUEST } else if (commandNoPrefix === 'vr') { let requestedVideoId = commandParts[1] || false; if (requestedVideoId === false) { twitch.botChat.say(to, `Useage: ${config.twitch.cmdPrefix}vr | Videos: https://pastebin.com/qv0wDkvB`); return; } // make sure request vid isn't in the queue already if (state.videoQueue.findIndex(e => e.id === requestedVideoId) !== -1) { twitch.botChat.say(to, `That video is in the queue already!`); return; } // search for req'd vid by id in config.vods.alttp let vodIndex = config.vods.alttp.findIndex(e => e.id === requestedVideoId); if (vodIndex === -1) { twitch.botChat.say(to, `A video with that ID does not exist!`); return; } // add to queue if it exists state.videoQueue.push(config.vods.alttp[vodIndex]); twitch.botChat.say(to, `${config.vods.alttp[vodIndex].chatName} has been added to the queue [${state.videoQueue.length}]`); return; // RNGAMES } else if (commandNoPrefix === 'rngames') { twitch.botChat.say(to, snesGames.sort(util.randSort).slice(0, 10).join(' | ')); } //////////////// } }); // @TODO: Modularize timed events console.log(`Initializing stream timers...`); let userVotes = currentChoices = []; let rockTheVote = () => {}; let rtvInterval = setInterval(() => {rockTheVote()}, 300000); let videoVoteJob = new schedule.Job(async () => { // Tally votes from previous election (if there was one), add the winner to the queue let winner; if (currentChoices.length > 0) { if (userVotes.length === 0) { // choose a random element from currentChoices winner = util.randElement(currentChoices); console.log(`VIDEO CHOSEN RANDOMLY: ${winner.chatName}`); twitch.botChat.say(config.twitch.channel, `No Votes Logged -- Next Video Chosen at Random: ${winner.chatName}`); } else { // tally and sort votes let voteTallies = []; await util.asyncForEach(userVotes, async (vote) => { tallyIndex = voteTallies.findIndex(e => e.id === vote.vote); if (tallyIndex !== -1) { voteTallies[tallyIndex].count++; } else { voteTallies.push({id: vote.vote, count: 1}); } }); voteTallies.sort((a, b) => { if (a.count < b.count) { return -1; } if (a.count > b.count) { return 1; } // a must be equal to b return 0; }); console.log(`Voting Results: ${JSON.stringify(voteTallies)}`); winner = currentChoices[voteTallies[0].id-1]; console.log(`WINNER OF THE VOTE: ${winner.chatName}`); twitch.botChat.say(config.twitch.channel, `Winner of the Video Vote: ${winner.chatName}`); // clear user votes userVotes = []; } state.videoQueue.push(winner); } // choose more random videos from config.vods.alttp (that aren't already in the queue) let vodsNotInQueue = config.vods.alttp.filter(e => { let inQueue = state.videoQueue.findIndex(q => q.id === e.id) !== -1; return !inQueue; }); currentChoices = vodsNotInQueue.sort(util.randSort).slice(0, config.videoPollSize); // Poll the chat let chatChoices = currentChoices.map((c, i) => { return `[${i+1}] ${c.chatName}`; }); rockTheVote = () => { twitch.botChat.say(config.twitch.channel, `Vote for which video you'd like to add to the queue using ${config.twitch.cmdPrefix}vote #: ${chatChoices.join(' | ')}`) }; clearInterval(rtvInterval); rockTheVote(); rtvInterval = setInterval(() => {rockTheVote()}, 300000); }); resolve(obs); }); }; // catches Promise errors process.on('unhandledRejection', console.error);