diff --git a/DiscordShredBot.js b/DiscordShredBot.js new file mode 100644 index 0000000..75b49b7 --- /dev/null +++ b/DiscordShredBot.js @@ -0,0 +1,494 @@ +// 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; diff --git a/README.md b/README.md index 9335d10..f691d62 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ # shred.ing-discord -Simple discord bot. Playback to voice channels and fetch title (setting bot discord status as the title changes). \ No newline at end of file +Simple discord bot. Playback to voice channels and fetch title (setting bot discord status as the title changes). + + 1. npm install + 2. mkdir /etc/shred.ing + 3. mv discord-channels.json /etc/shred.ing + 4. echo 'MY-SECRET-DISCORD-API-BOT-TOKEN' > /etc/shred.ing/.token + 5. node index.js + diff --git a/ShredPlaybackChannel.js b/ShredPlaybackChannel.js new file mode 100644 index 0000000..e53d4c8 --- /dev/null +++ b/ShredPlaybackChannel.js @@ -0,0 +1,139 @@ +// encapsulate a channel where we do playback + +class ShredPlaybackChannel { + constructor(guildID, vcID, vtID=null, autoJoin=false, active=true) { + this._guildID = guildID; + this._vcID = vcID; + this._vtID = vtID; + this._uri = ShredPlaybackChannel.defaultURI; + this._auto = autoJoin; + this._active = active; + } + + get guildID() { return this._guildID; } + get vcID() { return this._vcID; } + get vtID() { return this._vtID; } + get uri() { return this._uri; } + get autoJoin() { return this.autoJoin === true; } + get active() { return this._active === true; } + + stopPlayback(part=false) { + if(this.voiceConnection) { + // stop if we are already playing (allows start to reset) + console.log("Stopping player.."); + this.voiceConnection.stopPlaying(); + + if( part !== false ) { + this.voiceConnection.leave(); + } + } else { + console.log("Unable to stop playback: no voice connection."); + } + } + + restartPlaybackDelayed( + // miliseconds of delay before resuming playback + delay=ShredPlaybackChannel.defaultRestartPlaybackDelayMS + ) + { + setTimeout( function() { this.onVoiceConnection() }, delay ); + } + + startPlayback() { + if(this.voiceConnection) { + // stop if we are already playing (allows start to reset) + if(this.voiceConnection.playing) { + this.stopPlayback(); + } + + // invokes FFMPEG + this.voiceConnection.play( + ShredPlaybackChannel.defaultURI, + ShredPlaybackChannel.defaultStreamOptions + ); + + // if that didn't throw the stream is now active + this._active = true; + + // auto-reconnect unless stream if stream was active + this.voiceConnection.once("end", function () { + if(this.active) { + this._active = false; + this.restartPlaybackDelayed(); + } + }); + } else { + console.log("Unable to start playback: no voice connection."); + } + } + + onVoiceConnection(voiceConnection=null) { + if(voiceConnection) { + // save new voiceConnection when provided + this.voiceConnection = voiceConnection; + } + if(this.active) { + this.startPlayback(); + } + } + + endVoiceConnection() { + this._active = false; + if(this.voiceConnection) { + this.stopPlaying(); + this.voiceConnection = null; + } + else { + console.log(`Cannot end voice connection, no connection to channel ID ${this.vcID}`); + } + } + + startVoiceConnection(bot) { + const me = this; + bot.joinVoiceChannel( me.vcID ).catch((err) => { + console.log( 'ERROR: failed to join channel ' + + `${this.vcID} on guild ${this.guildID}: ` + + err); + console.debug( me ); + }).then(function (vc) { + me.onVoiceConnection(vc); + if( me.vtID ) { + bot.createMessage( me.vtID, `Now playing **${me.uri}**`) + } + }); + } + + static JoinAndPlay( + bot, + guildID, + voiceChannelID, + voiceTextChannelID=null, + autoJoin=false, + active=true + ) + { + try { + const ch = new ShredPlaybackChannel( + guildID, + voiceChannelID, + voiceTextChannelID, + autoJoin, + active + ); + ch.startVoiceConnection(bot); + return ch; + } catch (err) { + console.log( + 'ERROR: failed to join channel ' + +`${voiceChannelID} on guild ${guildID}: ` + + err + ); + } + } + + static get defaultURI() { return "https://shred.ing/live.ogg"; } + static get defaultStreamOptions() { return { voiceDataTimeout: -1 }; } + static get defaultRestartPlaybackDelayMS() { return 1500; } +} + +module.exports = ShredPlaybackChannel; diff --git a/discord-channels.json b/discord-channels.json new file mode 100644 index 0000000..8ab894b --- /dev/null +++ b/discord-channels.json @@ -0,0 +1,41 @@ +{ + "tokenFile": "/etc/shred.ing/.token", + "uri": "https://shred.ing/live.ogg", + "reconnectDelayMS": 1000, + "titleFetchIntervalMS": 5000, + "mcommands": [ + { + "name": "Join", + "doc": "Connect to channel and begin streaming. Issue this command from the text chat of a voice channel to which you are connected.", + "re": "join|play|start" + }, + { + "name": "Stop", + "doc": "Stop streaming and disconnect from channel. You must issue this command from the text chat of a voice channel to which both you and the bot are connected.", + "re": "stop|pause|part|end" + }, + { "name": "Title", + "doc": "Reply with the current track title and channel count. This command is availble from any text channel (including the text chat of a voice channel to which both you and the bot are connected). This does not hit the streaming server, which is usually queries ever few seconds automatically to update the bot \"status\".", + "re": "title|track|.?" + } + ], + "autoJoin": [ + { + "guild": "1368294440468217946", + "channel": "1371236850089594900" + } + ], + "active": [ + { + "guildID": "1368294440468217946", + "channel": { "id": "1368294441630306418" }, + "member": + { + "voiceState": + { + "channelID": "1368294441630306418" + } + } + } + ] +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..8d04b60 --- /dev/null +++ b/index.js @@ -0,0 +1,16 @@ +// version of the shred bot for development and testing + +// wrap eris, add timers, wrap a list of voice channels +const DiscordShredBot = require('./DiscordShredBot.js'); + +// env that can specify a (non default) config +const configEnvVar = 'SHRED_BOT_TEST_CONFIG'; + +// JSON config should usually be in /etc/shred.ing +const configFile = process.env.hasOwnProperty( configEnvVar ) + ? process.env[ configEnvVar ] + : '/etc/shred.ing/discord-channels.json'; + +const bot = new DiscordShredBot( configFile ); + +bot.Connect(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..4e2ebbe --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "shred.bot", + "version": "1.0.0", + "description": "discord audio relay for shred.ing", + "keywords": [ + "shred.ing" + ], + "license": "GPL-3.0-or-later", + "author": "Corwin Brust ", + "type": "commonjs", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "body-parser": "^2.2.0", + "eris": "^0.18.0", + "express": "^5.1.0" + } +}