Various fixes and improvements

- Added debug statements
- Added option to limit commands to specific roles
- Handle process exiting explicitly
This commit is contained in:
Erik 2024-04-13 10:37:10 +03:00
parent 1fc86a56c2
commit 2a3a034e59
13 changed files with 130 additions and 49 deletions

View File

@ -1,17 +1,6 @@
import { LoggerMasterOptions } from '@navy.gif/logger';
import { ClientOptions } from './DiscordClient.js';
export type ControllerOptions = {
rootDir: string,
logger: LoggerMasterOptions,
shardOptions: {
totalShards: 'auto' | number,
shardList?: 'auto' | number[]
respawn?: boolean,
},
discord: ClientOptions
}
export type ShardingOptions = {
shardList?: 'auto' | number[],
totalShards?: 'auto' | number,
@ -21,5 +10,13 @@ export type ShardingOptions = {
execArgv?: string[],
token?: string,
path?: string,
clientOptions?: ClientOptions
clientOptions?: ClientOptions,
debug?: boolean
}
export type ControllerOptions = {
rootDir: string,
logger: LoggerMasterOptions,
shardOptions: ShardingOptions,
discord: ClientOptions
}

3
@types/Shard.d.ts vendored
View File

@ -5,7 +5,8 @@ export type ShardOptions = {
args?: string[];
respawn?: boolean,
clientOptions: ClientOptions
totalShards: number
totalShards: number,
debug?: boolean
}
export type ShardMethod = 'eval' | 'fetchClientValue'

View File

@ -17,5 +17,5 @@ COPY build build
COPY --from=builder /musicbot/node_modules ./node_modules
COPY package.json package.json
VOLUME [ "/musicbot/cache" ]
# CMD ["node", "--enable-source-maps", "build/index.js"]
CMD ["/bin/ash"]
CMD ["node", "--enable-source-maps", "build/index.js"]
# CMD ["/bin/ash"]

View File

@ -68,8 +68,8 @@ class DiscordClient extends Client
});
process.on('message', this.#handleMessage.bind(this));
process.on('SIGINT', () => this.shutdown());
process.on('SIGTERM', () => this.shutdown());
process.on('SIGINT', () => this.#logger.info('Received SIGINT'));
process.on('SIGTERM', () => this.#logger.info('Received SIGTERM'));
this.#built = false;
}

View File

@ -12,6 +12,7 @@ import similarity from 'similarity';
import MusicDownloader from './MusicDownloader.js';
import MusicPlayerError from '../../errors/MusicPlayerError.js';
import { DownloaderResult } from '../../../@types/Downloader.js';
import { inspect } from 'node:util';
const linkReg = /(https?:\/\/(www\.)?)?(?<domain>([a-z0-9-]{1,63}\.)?([a-z0-9-]{1,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/[^()\s]*)?/iu;
const defaultStats: MusicStatsEntry = {
@ -91,12 +92,14 @@ class MusicLibrary implements Initialisable
if (!domain)
throw new MusicPlayerError('Invalid link');
this.#logger.debug(`Requesting download for ${keyword}`);
let result: DownloaderResult | null = null;
if (domain.includes('spotify.com'))
result = await this.#downloader.download('spotify', keyword);
else
throw new MusicPlayerError('Unsupported domain');
this.#logger.debug(`Result ${inspect(result)} for keyword ${keyword}`);
if (!result)
return null;

View File

@ -82,7 +82,7 @@ class MusicPlayer implements Initialisable
this.playNext();
}
stop (): void | Promise<void>
async stop (): Promise<void>
{
this.#ready = false;
this.#logger.info('Stopping music player');
@ -91,9 +91,10 @@ class MusicPlayer implements Initialisable
{
this.#logger.debug(`Disconnecting ${guildId}`);
subscription.unsubscribe();
connection.removeAllListeners();
connection.disconnect();
}
this.#library.stop();
await this.#library.stop();
const config = {
volume: this.#volume,
@ -239,6 +240,7 @@ class MusicPlayer implements Initialisable
guildId: guild.id,
adapterCreator: guild.voiceAdapterCreator
});
connection.removeAllListeners();
const subscription = connection.subscribe(this.#player);
if (!subscription)
@ -270,6 +272,8 @@ class MusicPlayer implements Initialisable
async #handleDisconnect (oldState: VoiceConnectionState, newState: VoiceConnectionState, guild: Guild): Promise<void>
{
this.#logger.debug(`Voice connection in ${guild.name} changed state from ${oldState.status} to ${newState.status}`);
if (!this.#ready)
return;
const connectionData = this.#connections.get(guild.id);
if (!connectionData)
return;
@ -279,16 +283,22 @@ class MusicPlayer implements Initialisable
const { me } = guild.members;
if (!me || !config)
return;
try
{
await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(connection, VoiceConnectionStatus.Connecting, 5_000)
]);
this.#logger.info('Connection was restored, checking if in right VC');
// console.log(me.voice.channelId, config.voiceChannel);
// If we find ourselves here, the bot was successfully able to reconnect to a VC
// Now we check if it's the one it's meant to be in
if (me.voice.channelId !== config.voiceChannel)
{
this.#logger.info('Moving back to designated channel');
this.#joinVoiceChannel(guild, config.voiceChannel, config.textOutput);
}
}
catch
{
@ -329,6 +339,11 @@ class MusicPlayer implements Initialisable
return this.#queue;
}
get current ()
{
return this.#currentSong;
}
}
export default MusicPlayer;

View File

@ -20,7 +20,7 @@ class QueueCommand extends Command
type: OptionType.INTEGER
}, {
name: 'song'
}],
}]
});
}
@ -31,12 +31,11 @@ class QueueCommand extends Command
if (!Object.keys(args).length)
{
const { queue } = this.client.musicPlayer;
const { queue, current } = this.client.musicPlayer;
const base = `**Now playing:** \`${current?.title} by ${current?.artist}\`\n`;
if (!queue.length)
return 'Queue empty';
return `
**Music queue:**\n\`\`\`${queue.map(entry => `\t\\- ${entry.title} by ${entry.artist}`).join('\n')}\`\`\`
`;
return base + '**Queue empty**';
return base + `**Music queue:**\n\`\`\`${queue.map((entry, idx) => `\t[${idx + 1}] ${entry.title} by ${entry.artist}`).join('\n')}\`\`\``;
}
if (!member?.voice || member.voice.channelId !== me?.voice.channelId)

View File

@ -10,8 +10,9 @@ class SkipCommand extends Command
name: 'skip',
description: 'Skips the current song.',
guildOnly: true,
restricted: true,
sameVc: true
// restricted: true,
sameVc: true,
limited: [ '1076274430520594514' ]
});
}

View File

@ -21,8 +21,9 @@ class VolumeCommand extends Command
}
],
guildOnly: true,
restricted: true,
// restricted: true,
sameVc: true,
limited: [ '1076274430520594514' ],
});
}

View File

@ -0,0 +1,34 @@
import { Message } from 'discord.js';
import Inhibitor from '../../../interfaces/Inhibitor.js';
import DiscordClient from '../../DiscordClient.js';
import Command from '../../../interfaces/Command.js';
import { InhibitorResponse } from '../../../../@types/DiscordClient.js';
import Util from '../../../utilities/Util.js';
class LimitedInhibitor extends Inhibitor
{
constructor (client: DiscordClient)
{
super(client, {
name: 'Limited',
priority: 5
});
}
override async execute (message: Message<boolean>, command: Command): Promise<InhibitorResponse>
{
if (!command.limited?.length || this.client.isDeveloper(message.author))
return super._succeed();
const { member } = message;
if (!member)
return super._fail('Invalid member');
const allowedRoles = command.limited;
if (Util.hasAny(member.roles.cache.map(role => role.id), allowedRoles))
return super._succeed();
return super._fail('Missing permissions to do that');
}
}
export default LimitedInhibitor;

View File

@ -23,7 +23,6 @@ class Controller
constructor (options: ControllerOptions, version: string)
{
const respawn = process.env.NODE_ENV !== 'development';
const clientPath = path.join(options.rootDir, 'client/DiscordClient.js');
if (!fs.existsSync(clientPath))
throw new Error(`Client path does not seem to exist: ${clientPath}`);
@ -32,7 +31,7 @@ class Controller
this.#shards = new Collection();
// this.#options = options;
const { shardList, totalShards, execArgv } = Controller.parseShardOptions(options.shardOptions);
const { shardList, totalShards, execArgv, respawn, debug } = Controller.parseShardOptions(options.shardOptions);
options.discord.rootDir = options.rootDir;
options.discord.logger = options.logger;
@ -46,6 +45,7 @@ class Controller
execArgv,
token: process.env.DISCORD_TOKEN,
clientOptions: options.discord,
debug
};
this.#version = version;
@ -141,15 +141,16 @@ class Controller
#setListeners (shard: Shard)
{
shard.on('death', () => this.#logger.info(`Shard ${shard.id} has died`));
shard.on('fatal', ({ error }) => this.#logger.warn(`Shard ${shard.id} has died fatally: ${inspect(error) ?? ''}`));
shard.on('shutdown', () => this.#logger.info(`Shard ${shard.id} is shutting down gracefully`));
shard.on('ready', () => this.#logger.info(`Shard ${shard.id} is ready`));
shard.on('disconnect', () => this.#logger.warn(`Shard ${shard.id} has disconnected`));
shard.on('processDisconnect', () => this.#logger.warn(`Process for ${shard.id} has disconnected`));
shard.on('spawn', () => this.#logger.info(`Shard ${shard.id} spawned`));
shard.on('error', (err) => this.#logger.error(`Shard ${shard.id} ran into an error:\n${err.stack}`));
shard.on('warn', (msg) => this.#logger.warn(`Warning from shard ${shard.id}: ${msg}`, { broadcast: true }));
shard.on('death', () => this.#logger.info(`Shard ${shard.id} has died`))
.on('fatal', ({ error }) => this.#logger.warn(`Shard ${shard.id} has died fatally: ${inspect(error) ?? ''}`))
.on('shutdown', () => this.#logger.info(`Shard ${shard.id} is shutting down gracefully`))
.on('ready', () => this.#logger.info(`Shard ${shard.id} is ready`))
.on('disconnect', () => this.#logger.warn(`Shard ${shard.id} has disconnected`))
.on('processDisconnect', () => this.#logger.warn(`Process for ${shard.id} has disconnected`))
.on('spawn', () => this.#logger.info(`Shard ${shard.id} spawned`))
.on('error', (err) => this.#logger.error(`Shard ${shard.id} ran into an error:\n${err.stack}`))
.on('warn', (msg) => this.#logger.warn(`Warning from shard ${shard.id}: ${msg}`, { broadcast: true }))
.on('debug', msg => this.#logger.debug(msg));
// shard.on('message', (msg) => this.#handleMessage(shard, msg));
}
@ -231,7 +232,10 @@ class Controller
let { execArgv } = options;
if (!execArgv)
execArgv = [];
return { shardList, totalShards, execArgv };
const respawn = (process.env.NODE_ENV !== 'development' || options.respawn) ?? false;
return { shardList, totalShards, execArgv, respawn, debug: options.debug ?? false };
}
async shutdown (type: string)
@ -239,16 +243,15 @@ class Controller
if (this.#exiting)
return;
this.#exiting = true;
this.#logger.info('Received SIGINT or SIGTERM, shutting down');
this.#logger.info(`Received ${type}, shutting down`);
setTimeout(process.exit, 90_000);
const promises = this.#shards
.filter(shard => shard.ready)
.map(shard =>
{
if (type === 'SIGTERM')
return shard.kill();
return shard.awaitShutdown()
.then(() => shard.removeAllListeners());
return shard.kill();
// return shard.awaitShutdown()
// .then(() => shard.removeAllListeners());
});
if (promises.length)
await Promise.all(promises);

View File

@ -15,6 +15,7 @@ class Shard extends EventEmitter
{
[key: string]: unknown;
#debug: boolean;
#id: number;
#controller: Controller;
#env: EnvObject; // { [key: string]: string | boolean | number };
@ -43,6 +44,7 @@ class Shard extends EventEmitter
super();
this.#controller = controller;
this.#id = id;
this.#debug = options.debug ?? false;
this.#args = options.args ?? [];
this.#execArgv = options.execArgv ?? [];
@ -150,19 +152,19 @@ class Shard extends EventEmitter
const onDisconnect = () =>
{
cleanup();
reject(new Error(`[shard${this.id}] Shard disconnected while readying.`));
reject(new Error(`[SHARD-${this.id}] Shard disconnected while readying.`));
};
const onDeath = () =>
{
cleanup();
reject(new Error(`[shard${this.id}] Shard died while readying.`));
reject(new Error(`[SHARD-${this.id}] Shard died while readying.`));
};
const onTimeout = () =>
{
cleanup(true);
reject(new Error(`[shard${this.id}] Shard timed out while readying.`));
reject(new Error(`[SHARD-${this.id}] Shard timed out while readying.`));
};
const spawnTimeoutTimer = setTimeout(onTimeout, timeout);
@ -172,7 +174,7 @@ class Shard extends EventEmitter
});
}
// When killing the process, give it an opportonity to gracefully shut down (i.e. clean up DB connections etc)
// When killing the process, give it an opportunity to gracefully shut down (i.e. clean up DB connections etc)
// It simply has to respond with a shutdown message to the shutdown event
kill ()
{
@ -180,6 +182,7 @@ class Shard extends EventEmitter
{
return new Promise<void>((resolve) =>
{
this.debug('Shard.kill');
if (!this.#process)
return resolve();
@ -187,6 +190,7 @@ class Shard extends EventEmitter
const timeout = setTimeout(() =>
{
this.debug('Shard.kill timeout');
if (!this.#process)
return resolve();
this.#process.kill();
@ -195,6 +199,7 @@ class Shard extends EventEmitter
this.#process.once('exit', (code, signal) =>
{
this.debug('Shard.kill exit listener');
clearTimeout(timeout);
this.#handleExit(code, signal, false);
resolve();
@ -202,6 +207,7 @@ class Shard extends EventEmitter
this.once('shutdown', () =>
{
this.debug('Shard.kill shutdown listener');
clearTimeout(timeout);
});
@ -457,6 +463,12 @@ class Shard extends EventEmitter
if (respawn)
this.spawn().catch((error: Error) => this.emit('error', error));
}
private debug (msg: string)
{
if (this.#debug && this.listenerCount('debug'))
this.emit('debug', `[SHARD-${this.id}] ${msg}`);
}
}
export default Shard;

View File

@ -587,6 +587,21 @@ class Util
return Object.keys(obj).some(key => [ 'title', 'fields', 'description', 'image', 'video' ].includes(key));
}
/**
* Check if an array contains any values from the reference array
* @date 4/13/2024 - 10:31:57 AM
*
* @static
* @template [T=unknown]
* @param {T[]} target
* @param {T[]} reference
* @returns {boolean}
*/
static hasAny<T=unknown> (target: T[], reference: T[]): boolean
{
return target.some(entry => reference.includes(entry));
}
}
export default Util;