// main encapsulation class for the bot class DiscordShredBot { // (obj) DiscordShredBot.ReadConfig( JsonFileFullPath ); static ReadConfig( fs, file ) { return JSON.parse( fs.readFileSync( file ) ) } // millisecionds wait before joining channels after connection static get defaultTitleToStatusUpdateMS() { return 10000; } static get defaultResumeActiveDelayMS() { return 500; } // delay between track title (thus bot status) updates static get defaultConnectAutoJoinDelayMS() { return 7000; } // (DiscordShredBot) new DiscordShredBot( JsonFileFullPath ); constructor( configFile, // ignore optional props; they support DI/tests. Eventually. Maybe. eris=require('eris'), fs=require('fs'), ShredPlaybackChannelClass=require('./ShredPlaybackChannel.js') ) { // stop setup as soon as we see a critical issue ("fail fast") // attempt to read the given config file and parse it as JSON this._config = DiscordShredBot.ReadConfig( fs, configFile ); // looking good, setup Has-As in case things happen fast this._configFile = configFile; this._eris = eris; this._fs = fs; this._ShredPlaybackChannel = ShredPlaybackChannelClass; this._channels = [ ]; this._title = '/..loading../'; console.debug( this._config ); // fails if config has no tokenFile, file doesn't exist, ... const tokenFile = this._config.tokenFile; const token = this._fs.readFileSync( tokenFile ).toString(); //console.debug( {tokenFile:tokenFile,token:token}); // may detect certain token issues prior to connecting this._client = new eris.Client( token ); // let our clients subsubcribe directly to eris client events this.on = this._client.on; //this.connect = this._client.connect; this._commands = this._initBotCommands(); } // transform configured commands into list of names, docs, tests, handlers _initBotCommands() { const bot = this; if( this.config && this.config.mcommands && this.config.mcommands.length ) { return this.config.mcommands.map(function( cmd ) { const regexp = new RegExp( cmd.re, "i" ); const doc = cmd.doc; const handlerName = `handle${ cmd.name }Message`; if( handlerName in bot ) { return { ...cmd, test:regexp, method:handlerName }; } throw new Error( `No \"${handlerName}\" handler for command \"${cmd.name}\" in config file.` ); }); } else { throw new Error("Invalid or missing \"command\" list in config file."); } } // public propertiy accessors get client() { return this._client; } get config() { return this._config; } get channels() { return this._channels; } get title() { return this._title; } // main entry point for instantiated clients // (undefined) discordShredBot.Connect(); Connect() { const me = this; const client = me.client; if(! client) { throw new Error( "Cannot connect because there is no client" ); } client.on('error', function( err ) { me.onClientError( err ); }); client.on('messageCreate', async function( msg ) { await me.onClientMessage( msg ) }); client.connect(); this._afterConnect(); } _afterConnect() { this._autoJoinAfterDelay(); this._resumeActiveVoiceConnectionsAfterDelay(); this._startTitleToStatusOnInterval(); } _autoJoinAfterDelay() { const bot = this; if( bot._tm_autoJoin ) { clearTimeout( bot._tm_autoJoin ); } this._tm_autoJoin = setTimeout( function() { bot.handleAutoJoin(); bot.handleActiveChannels(); }, DiscordShredBot.defaultConnectAutoJoinDelayMS ); } _resumeActiveVoiceConnectionsAfterDelay() { const bot = this; if( bot._tm_resumeActive ) { clearTimeout( bot._tm_resumeActive ); } this._tm_resumeActive = setTimeout( function() { bot.handleAutoJoin(); bot.handleActiveChannels(); }, DiscordShredBot.defaultResumeActiveDelayMS ); } _startTitleToStatusOnInterval() { // start title -> status updates const bot = this; if( bot._tm_titleToStatus ) { clearTimeout( bot._tm_titleToStatus ); } bot._tm_titleToStatus = setInterval( async function() { if(! bot._doing_title_update) { bot._doing_title_update = true; await bot.handleTitleSync(); bot._doing_title_update = false; } }, DiscordShredBot.defaultTitleToStatusUpdateMS ); } // handle saving the config file saveConfig() { this._fs.writeFileSync( this._configFile, JSON.stringfiy( this.config )); } hasActiveChannels() { if(! this._config) return false; if(! this._config.active) return false; return this._config.active.length > 0; } findActiveChannelIndex( vcid ) { if( this.hasActiveChannels() ) { const activeChannels = this._config.active; for(let i = 0; i < activeChannels.length; ++i) { if( vcid === this._config.active[i].channel.id ) { return i; } } } return -1; } // removes the channel from the list of active channels // alters configuration in memory but not on disc removeActiveChannelAtIndex( activeChannelIndex ) { if( activeChannelIndex > -1 && activeChannelIndex < this._config.active.length ) { this._config.active.splice( activeChannelIndex, 1 ); return true; } return false; } // return value indicates whether a change was made removeActiveChannelByChannelId( vcid ) { return this.removeActiveChannelAtIndex( this.findActiveChannelIndex( vcid ) ); } // add channel if it doesn't already exist; format records to // emulate the msg that triggered our joining the given channel // we could (e.g.) part when the requester does (not implemented) addActiveChannel( guildid, vcid, mvscid ) { const oldIdx = this.findActiveChannelIndex( vcid ); if( -1 === oldIdx ) { const msg = { guildID:guildid, channel: { id: vcid }, member: { voiceState: { channelID: mvscid } } }; this.config.active.push( msg ); return true; } else { console.log( 'Cannot add active channel ID ' + vcid + ': already at index ' + oldIdx ); } return false; } findPlaybackChannelIndex( vcid ) { for(let i = 0; i < this.channels.length; ++i) { if( vcid === this.channels[i].vcid ) { return i; } } return -1; } findPlaybackChannel( vcid ) { const idx = this.findPlaybackChannelIndex( vcid ); return idx > -1 ? this.channels[ idx ] : null; } removePlaybackChannel( vcid ) { const idx = this.findPlaybackChannelIndex( vcid ); if( idx > -1 ) { this.channels.splice( idx, 1 ); return true; } return false; } addPlaybackChannel( shredPlaybackChannel ) { if( shredPlaybackChannel ) { const oldIdx = this.findPlaybackChannelIndex( shredPlaybackChannel.vcID ); if(oldIdx === -1) { this.channels.push( shredPlaybackChannel ); } else { console.log( 'Cannot add auto-join channel ID ' + shredPlaybackChannel.vcID + ': already at index ' + oldIdx ); } } } // onClient message functions may eventually become "special", like // handleMessage (which see), but for now: no. onClientError(err) { // it may be better to die and get restarted (e.g. by systemd) //throw new Error("Unhandled DiscordShredBot client error", err); console.warn(`Unhandled DiscordShredBot client error ${err}`); } async onClientMessage(msg) { const botWasMentioned = msg.mentions.find( mentionedUser => mentionedUser.id === this.client.user.id, ); if(! botWasMentioned) { return; } let method = false; const text = msg.content; for(let i = 0; i < this._commands.length; ++i) { if( this._commands[ i ].test.test( text ) ) { method = this._commands[ i ].method; break; } } if( method ) { //console.debug({cmd:cmd,msg:msg}); console.log(`Dispatching ${ method } from ${ text }`); this[ method ].apply( this, [ msg ] ) } } // handleMessage functions are magic such function *can* be // commands, given a value of suitable for use as a regular // expression appears in the commands list in the config file. handleStopMessage( msg, part=true, remove=true ) { const vcid = msg.member.voiceState ? msg.member.voiceState.channelID : null; if(! vcid ) { this.client.createMessage( msg.channel.id, "You are not in a voice channel." ); } else { const ch = this.findPlaybackChannel( vcid ); if(! ch) { this.client.createMessage( msg.channel.id, "I'm not in that channel." ); } else { ch.stopPlayback( part ); this.client.createMessage( msg.channel.id, "Playback stopped." ); if( remove ) { this.removePlaybackChannel( vcid ); this.client.createMessage( msg.channel.id, "Removed channel." ); } } } } handleTitleMessage( msg ) { const messageText = "Playing **" + this.title + "** on `" + this.channels.length.toString() + "` channels."; this.client.createMessage( msg.channel.id, messageText ); } handleJoinMessage( msg, startPlaying=true, autoJoin=false ) { if(! msg.member.voiceState.channelID ) { this.client.createMessage( msg.channel.id, "You are not in a voice channel." ); } else { const ch = this._ShredPlaybackChannel.JoinAndPlay( this.client, msg.guildID, msg.member.voiceState.channelID, msg.channel.id, autoJoin, startPlaying ); if(! ch) { this.client.createMessage( msg.channel.id, "Unable to join voice channel." ); } else { // track if the config file needs to be written to disc // initially it does if we added an "active" channel // the active channel list tracks channels we joined // by request to reconnect after bot restart // this means the bot will not rejoin such channels // unless it is playing (however pause isn't implemented) let saveConfig = startPlaying !== true ? false : this.addActiveChannel( msg.guildID, msg.member.voiceState.channelID, msg.channel.id ); // add the channel to our internal stack const didAdd = this.addPlaybackChannel( ch ); // avoid adding to autoJoin config unless we connect to prevent // race condition or auto-joining channels hit an error joining // even if we didn't connect add autoJoin config if config is // being saved already; this protects against connection problem // being on our side then resolved with a bot restart if( (saveConfig || didAdd) && autoJoin === true ) { this.config.autoJoin.push({ guild: ch.guildID, channel: ch.vcID }); saveConfig = true; } if( saveConfig ) { this.saveConfig(); } } } } /// Walks the list of autoJoin objects in config, delegating /// channel instance construction to a class method, as such: // (shredPlaybackChannel) ShredPlaybackChannel.JoinAndPlay( // bot, // guildID, // voiceChannelID, // voiceTextChannelID=null, // autoJoin=false, // active=true // ) handleAutoJoin() { for(let i = 0; i < this.config.autoJoin.length; ++i) { const c = this.config.autoJoin[ i ]; const oldVc = this.channels.find( vc => vc.vcID === c.channel ); if(oldVc) { console.log( 'WARNING: attempt to auto-join connected channel ' + oldVc ); // TODO: setup a "actvive vs isPlaying" check here? return; } this.addPlaybackChannel( this._ShredPlaybackChannel.JoinAndPlay( this.client, c.guild, c.channel, null, // no text channel associated with autojoined channel true // yes, this is an autojoin // leave "active" at default per the static method we call // so we can flip it all at once if necssary ) ); } } handleActiveChannels() { for(let i = 0; i < this.config.active.length; ++i) { const c = this.config.active[ i ]; this.handleJoinMessage( c ); } } async handleTitleSync(msg=null) { try { const response = await fetch( 'https://shred.ing/json' ); if(! response.ok) { throw('ungood service response status: ' + response.status); } let didChange = false; const json = await response.json(); if(json && json.title && json.title != this.title) { didChange = true; this._title = json.title; } if( msg ) { this.handleTitleMessage(msg); } if( didChange ) { console.log(`DEBUG: updated status to ${this.title}`); this.client.editStatus( "online", [ { name: this.title, type: 0, url: "https://shred.ing" } ] ); } } catch (err) { console.log('ERROR: fetching title ' + err); } } } module.exports = DiscordShredBot;