add inital program versions

This commit is contained in:
2025-05-18 21:14:45 -05:00
parent 6c374871e7
commit 5aaa58bd23
6 changed files with 718 additions and 1 deletions

494
DiscordShredBot.js Normal file
View File

@ -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
// 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;

View File

@ -1,3 +1,10 @@
# shred.ing-discord # shred.ing-discord
Simple discord bot. Playback to voice channels and fetch title (setting bot discord status as the title changes). 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

139
ShredPlaybackChannel.js Normal file
View File

@ -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;

41
discord-channels.json Normal file
View File

@ -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"
}
}
}
]
}

16
index.js Normal file
View File

@ -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();

20
package.json Normal file
View File

@ -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 <corwin@bru.st>",
"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"
}
}