Files
shred.ing-discord/DiscordShredBot.js

495 lines
13 KiB
JavaScript

// 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
// handle<Foo>Message (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 ] )
}
}
// handle<FOO>Message functions are magic such function *can* be
// commands, given a value of <FOO> 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;