495 lines
13 KiB
JavaScript
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;
|