From 2a3a034e594466557d1951b8be8ca13e8d917b57 Mon Sep 17 00:00:00 2001 From: "Navy.gif" Date: Sat, 13 Apr 2024 10:37:10 +0300 Subject: [PATCH] Various fixes and improvements - Added debug statements - Added option to limit commands to specific roles - Handle process exiting explicitly --- @types/Controller.d.ts | 21 +++++------- @types/Shard.d.ts | 3 +- Dockerfile | 4 +-- src/client/DiscordClient.ts | 4 +-- src/client/components/MusicLibrary.ts | 3 ++ src/client/components/MusicPlayer.ts | 19 +++++++++-- src/client/components/commands/Queue.ts | 11 +++--- src/client/components/commands/Skip.ts | 5 +-- src/client/components/commands/Volume.ts | 3 +- src/client/components/inhibitors/Limited.ts | 34 +++++++++++++++++++ src/middleware/Controller.ts | 37 +++++++++++---------- src/middleware/Shard.ts | 20 ++++++++--- src/utilities/Util.ts | 15 +++++++++ 13 files changed, 130 insertions(+), 49 deletions(-) create mode 100644 src/client/components/inhibitors/Limited.ts diff --git a/@types/Controller.d.ts b/@types/Controller.d.ts index 7716af6..8613855 100644 --- a/@types/Controller.d.ts +++ b/@types/Controller.d.ts @@ -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 } \ No newline at end of file diff --git a/@types/Shard.d.ts b/@types/Shard.d.ts index 11482db..ef81522 100644 --- a/@types/Shard.d.ts +++ b/@types/Shard.d.ts @@ -5,7 +5,8 @@ export type ShardOptions = { args?: string[]; respawn?: boolean, clientOptions: ClientOptions - totalShards: number + totalShards: number, + debug?: boolean } export type ShardMethod = 'eval' | 'fetchClientValue' \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f3d88f9..579a0bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +CMD ["node", "--enable-source-maps", "build/index.js"] +# CMD ["/bin/ash"] \ No newline at end of file diff --git a/src/client/DiscordClient.ts b/src/client/DiscordClient.ts index 3c328ba..85fab0c 100644 --- a/src/client/DiscordClient.ts +++ b/src/client/DiscordClient.ts @@ -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; } diff --git a/src/client/components/MusicLibrary.ts b/src/client/components/MusicLibrary.ts index 2f1c29c..58c9c09 100644 --- a/src/client/components/MusicLibrary.ts +++ b/src/client/components/MusicLibrary.ts @@ -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\.)?)?(?([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; diff --git a/src/client/components/MusicPlayer.ts b/src/client/components/MusicPlayer.ts index e838187..f4491d7 100644 --- a/src/client/components/MusicPlayer.ts +++ b/src/client/components/MusicPlayer.ts @@ -82,7 +82,7 @@ class MusicPlayer implements Initialisable this.playNext(); } - stop (): void | Promise + async stop (): Promise { 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 { 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; \ No newline at end of file diff --git a/src/client/components/commands/Queue.ts b/src/client/components/commands/Queue.ts index 59bcd63..f504fbc 100644 --- a/src/client/components/commands/Queue.ts +++ b/src/client/components/commands/Queue.ts @@ -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) diff --git a/src/client/components/commands/Skip.ts b/src/client/components/commands/Skip.ts index 5cad59f..f337787 100644 --- a/src/client/components/commands/Skip.ts +++ b/src/client/components/commands/Skip.ts @@ -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' ] }); } diff --git a/src/client/components/commands/Volume.ts b/src/client/components/commands/Volume.ts index c038238..5f61c4b 100644 --- a/src/client/components/commands/Volume.ts +++ b/src/client/components/commands/Volume.ts @@ -21,8 +21,9 @@ class VolumeCommand extends Command } ], guildOnly: true, - restricted: true, + // restricted: true, sameVc: true, + limited: [ '1076274430520594514' ], }); } diff --git a/src/client/components/inhibitors/Limited.ts b/src/client/components/inhibitors/Limited.ts new file mode 100644 index 0000000..e8a945f --- /dev/null +++ b/src/client/components/inhibitors/Limited.ts @@ -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, command: Command): Promise + { + 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; \ No newline at end of file diff --git a/src/middleware/Controller.ts b/src/middleware/Controller.ts index 3c16675..84635ef 100644 --- a/src/middleware/Controller.ts +++ b/src/middleware/Controller.ts @@ -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); diff --git a/src/middleware/Shard.ts b/src/middleware/Shard.ts index 9a17a60..22d690d 100644 --- a/src/middleware/Shard.ts +++ b/src/middleware/Shard.ts @@ -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((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; \ No newline at end of file diff --git a/src/utilities/Util.ts b/src/utilities/Util.ts index 693f96c..703a740 100644 --- a/src/utilities/Util.ts +++ b/src/utilities/Util.ts @@ -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 (target: T[], reference: T[]): boolean + { + return target.some(entry => reference.includes(entry)); + } + } export default Util; \ No newline at end of file