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 { LoggerMasterOptions } from '@navy.gif/logger';
import { ClientOptions } from './DiscordClient.js'; 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 = { export type ShardingOptions = {
shardList?: 'auto' | number[], shardList?: 'auto' | number[],
totalShards?: 'auto' | number, totalShards?: 'auto' | number,
@ -21,5 +10,13 @@ export type ShardingOptions = {
execArgv?: string[], execArgv?: string[],
token?: string, token?: string,
path?: 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[]; args?: string[];
respawn?: boolean, respawn?: boolean,
clientOptions: ClientOptions clientOptions: ClientOptions
totalShards: number totalShards: number,
debug?: boolean
} }
export type ShardMethod = 'eval' | 'fetchClientValue' export type ShardMethod = 'eval' | 'fetchClientValue'

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import similarity from 'similarity';
import MusicDownloader from './MusicDownloader.js'; import MusicDownloader from './MusicDownloader.js';
import MusicPlayerError from '../../errors/MusicPlayerError.js'; import MusicPlayerError from '../../errors/MusicPlayerError.js';
import { DownloaderResult } from '../../../@types/Downloader.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 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 = { const defaultStats: MusicStatsEntry = {
@ -91,12 +92,14 @@ class MusicLibrary implements Initialisable
if (!domain) if (!domain)
throw new MusicPlayerError('Invalid link'); throw new MusicPlayerError('Invalid link');
this.#logger.debug(`Requesting download for ${keyword}`);
let result: DownloaderResult | null = null; let result: DownloaderResult | null = null;
if (domain.includes('spotify.com')) if (domain.includes('spotify.com'))
result = await this.#downloader.download('spotify', keyword); result = await this.#downloader.download('spotify', keyword);
else else
throw new MusicPlayerError('Unsupported domain'); throw new MusicPlayerError('Unsupported domain');
this.#logger.debug(`Result ${inspect(result)} for keyword ${keyword}`);
if (!result) if (!result)
return null; return null;

View File

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

View File

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

View File

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

View File

@ -21,8 +21,9 @@ class VolumeCommand extends Command
} }
], ],
guildOnly: true, guildOnly: true,
restricted: true, // restricted: true,
sameVc: 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) constructor (options: ControllerOptions, version: string)
{ {
const respawn = process.env.NODE_ENV !== 'development';
const clientPath = path.join(options.rootDir, 'client/DiscordClient.js'); const clientPath = path.join(options.rootDir, 'client/DiscordClient.js');
if (!fs.existsSync(clientPath)) if (!fs.existsSync(clientPath))
throw new Error(`Client path does not seem to exist: ${clientPath}`); throw new Error(`Client path does not seem to exist: ${clientPath}`);
@ -32,7 +31,7 @@ class Controller
this.#shards = new Collection(); this.#shards = new Collection();
// this.#options = options; // 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.rootDir = options.rootDir;
options.discord.logger = options.logger; options.discord.logger = options.logger;
@ -46,6 +45,7 @@ class Controller
execArgv, execArgv,
token: process.env.DISCORD_TOKEN, token: process.env.DISCORD_TOKEN,
clientOptions: options.discord, clientOptions: options.discord,
debug
}; };
this.#version = version; this.#version = version;
@ -141,15 +141,16 @@ class Controller
#setListeners (shard: Shard) #setListeners (shard: Shard)
{ {
shard.on('death', () => this.#logger.info(`Shard ${shard.id} has died`)); 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) ?? ''}`)); .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`)); .on('shutdown', () => this.#logger.info(`Shard ${shard.id} is shutting down gracefully`))
shard.on('ready', () => this.#logger.info(`Shard ${shard.id} is ready`)); .on('ready', () => this.#logger.info(`Shard ${shard.id} is ready`))
shard.on('disconnect', () => this.#logger.warn(`Shard ${shard.id} has disconnected`)); .on('disconnect', () => this.#logger.warn(`Shard ${shard.id} has disconnected`))
shard.on('processDisconnect', () => this.#logger.warn(`Process for ${shard.id} has disconnected`)); .on('processDisconnect', () => this.#logger.warn(`Process for ${shard.id} has disconnected`))
shard.on('spawn', () => this.#logger.info(`Shard ${shard.id} spawned`)); .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}`)); .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 })); .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)); // shard.on('message', (msg) => this.#handleMessage(shard, msg));
} }
@ -231,7 +232,10 @@ class Controller
let { execArgv } = options; let { execArgv } = options;
if (!execArgv) if (!execArgv)
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) async shutdown (type: string)
@ -239,16 +243,15 @@ class Controller
if (this.#exiting) if (this.#exiting)
return; return;
this.#exiting = true; this.#exiting = true;
this.#logger.info('Received SIGINT or SIGTERM, shutting down'); this.#logger.info(`Received ${type}, shutting down`);
setTimeout(process.exit, 90_000); setTimeout(process.exit, 90_000);
const promises = this.#shards const promises = this.#shards
.filter(shard => shard.ready) .filter(shard => shard.ready)
.map(shard => .map(shard =>
{ {
if (type === 'SIGTERM')
return shard.kill(); return shard.kill();
return shard.awaitShutdown() // return shard.awaitShutdown()
.then(() => shard.removeAllListeners()); // .then(() => shard.removeAllListeners());
}); });
if (promises.length) if (promises.length)
await Promise.all(promises); await Promise.all(promises);

View File

@ -15,6 +15,7 @@ class Shard extends EventEmitter
{ {
[key: string]: unknown; [key: string]: unknown;
#debug: boolean;
#id: number; #id: number;
#controller: Controller; #controller: Controller;
#env: EnvObject; // { [key: string]: string | boolean | number }; #env: EnvObject; // { [key: string]: string | boolean | number };
@ -43,6 +44,7 @@ class Shard extends EventEmitter
super(); super();
this.#controller = controller; this.#controller = controller;
this.#id = id; this.#id = id;
this.#debug = options.debug ?? false;
this.#args = options.args ?? []; this.#args = options.args ?? [];
this.#execArgv = options.execArgv ?? []; this.#execArgv = options.execArgv ?? [];
@ -150,19 +152,19 @@ class Shard extends EventEmitter
const onDisconnect = () => const onDisconnect = () =>
{ {
cleanup(); cleanup();
reject(new Error(`[shard${this.id}] Shard disconnected while readying.`)); reject(new Error(`[SHARD-${this.id}] Shard disconnected while readying.`));
}; };
const onDeath = () => const onDeath = () =>
{ {
cleanup(); cleanup();
reject(new Error(`[shard${this.id}] Shard died while readying.`)); reject(new Error(`[SHARD-${this.id}] Shard died while readying.`));
}; };
const onTimeout = () => const onTimeout = () =>
{ {
cleanup(true); 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); 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 // It simply has to respond with a shutdown message to the shutdown event
kill () kill ()
{ {
@ -180,6 +182,7 @@ class Shard extends EventEmitter
{ {
return new Promise<void>((resolve) => return new Promise<void>((resolve) =>
{ {
this.debug('Shard.kill');
if (!this.#process) if (!this.#process)
return resolve(); return resolve();
@ -187,6 +190,7 @@ class Shard extends EventEmitter
const timeout = setTimeout(() => const timeout = setTimeout(() =>
{ {
this.debug('Shard.kill timeout');
if (!this.#process) if (!this.#process)
return resolve(); return resolve();
this.#process.kill(); this.#process.kill();
@ -195,6 +199,7 @@ class Shard extends EventEmitter
this.#process.once('exit', (code, signal) => this.#process.once('exit', (code, signal) =>
{ {
this.debug('Shard.kill exit listener');
clearTimeout(timeout); clearTimeout(timeout);
this.#handleExit(code, signal, false); this.#handleExit(code, signal, false);
resolve(); resolve();
@ -202,6 +207,7 @@ class Shard extends EventEmitter
this.once('shutdown', () => this.once('shutdown', () =>
{ {
this.debug('Shard.kill shutdown listener');
clearTimeout(timeout); clearTimeout(timeout);
}); });
@ -457,6 +463,12 @@ class Shard extends EventEmitter
if (respawn) if (respawn)
this.spawn().catch((error: Error) => this.emit('error', error)); 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; export default Shard;

View File

@ -587,6 +587,21 @@ class Util
return Object.keys(obj).some(key => [ 'title', 'fields', 'description', 'image', 'video' ].includes(key)); 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; export default Util;