diff --git a/package.json b/package.json index d546abf..c37da7e 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,12 @@ "watch": "tsc --watch", "prepack": "npm run clean && npm run build", "start": "node dist/index.js", - "dev": "nodemon" + "dev": "nodemon", + "docker-publish": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t codetheweb/muse:latest --push ." }, "devDependencies": { "@types/bluebird": "^3.5.30", + "@types/debug": "^4.1.5", "@types/fluent-ffmpeg": "^2.1.14", "@types/fs-capacitor": "^2.0.0", "@types/node": "^13.9.1", @@ -65,6 +67,7 @@ "dependencies": { "@discordjs/opus": "^0.1.0", "array-shuffle": "^1.0.1", + "debug": "^4.1.1", "delay": "^4.3.0", "discord.js": "^12.0.2", "dotenv": "^8.2.0", diff --git a/src/bot.ts b/src/bot.ts index b54e665..0ca77e0 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -4,6 +4,7 @@ import {TYPES} from './types'; import {Settings, Shortcut} from './models'; import container from './inversify.config'; import Command from './commands'; +import debug from './utils/debug'; import handleGuildCreate from './events/guild-create'; import handleVoiceStateUpdate from './events/voice-state-update'; @@ -89,6 +90,7 @@ export default class { }); this.client.on('error', console.error); + this.client.on('debug', debug); // Register event handlers this.client.on('guildCreate', handleGuildCreate); diff --git a/src/commands/clear.ts b/src/commands/clear.ts index 18f6807..32e51e3 100644 --- a/src/commands/clear.ts +++ b/src/commands/clear.ts @@ -20,6 +20,6 @@ export default class implements Command { public async execute(msg: Message, _: string []): Promise { this.queueManager.get(msg.guild!.id).clear(); - await msg.channel.send('cleared'); + await msg.channel.send('clearer than a field after a fresh harvest'); } } diff --git a/src/commands/config.ts b/src/commands/config.ts index e584d1d..273bbb8 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,6 +1,7 @@ import {TextChannel, Message} from 'discord.js'; import {injectable} from 'inversify'; import {Settings} from '../models'; +import errorMsg from '../utils/error-msg'; import Command from '.'; @injectable() @@ -29,12 +30,12 @@ export default class implements Command { const setting = args[0]; if (args.length !== 2) { - await msg.channel.send('🚫 incorrect number of arguments'); + await msg.channel.send(errorMsg('incorrect number of arguments')); return; } if (msg.author.id !== msg.guild!.owner!.id) { - await msg.channel.send('not authorized'); + await msg.channel.send(errorMsg('not authorized')); return; } @@ -59,14 +60,14 @@ export default class implements Command { msg.react('👍') ]); } else { - await msg.channel.send('🚫 either that channel doesn\'t exist or you want me to become sentient and listen to a voice channel'); + await msg.channel.send(errorMsg('either that channel doesn\'t exist or you want me to become sentient and listen to a voice channel')); } break; } default: - await msg.channel.send('🚫 I\'ve never met this setting in my life'); + await msg.channel.send(errorMsg('I\'ve never met this setting in my life')); } } } diff --git a/src/commands/fseek.ts b/src/commands/fseek.ts index fb32adf..e20d025 100644 --- a/src/commands/fseek.ts +++ b/src/commands/fseek.ts @@ -4,6 +4,7 @@ import {inject, injectable} from 'inversify'; import PlayerManager from '../managers/player'; import QueueManager from '../managers/queue'; import LoadingMessage from '../utils/loading-message'; +import errorMsg from '../utils/error-msg'; import Command from '.'; @injectable() @@ -25,27 +26,27 @@ export default class implements Command { const queue = this.queueManager.get(msg.guild!.id); if (queue.get().length === 0) { - await msg.channel.send('nothing is playing'); + await msg.channel.send(errorMsg('nothing is playing')); return; } if (queue.get()[0].isLive) { - await msg.channel.send('can\'t seek in a livestream'); + await msg.channel.send(errorMsg('can\'t seek in a livestream')); return; } const seekTime = parseInt(args[0], 10); - const loading = new LoadingMessage(msg.channel as TextChannel, 'hold on a sec'); + const loading = new LoadingMessage(msg.channel as TextChannel); await loading.start(); try { await this.playerManager.get(msg.guild!.id).forwardSeek(seekTime); - await loading.stop('seeked'); - } catch (_) { - await loading.stop('error somewhere'); + await loading.stop(); + } catch (error) { + await loading.stop(errorMsg(error)); } } } diff --git a/src/commands/pause.ts b/src/commands/pause.ts index 48933f2..546b7a6 100644 --- a/src/commands/pause.ts +++ b/src/commands/pause.ts @@ -3,6 +3,7 @@ import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; import PlayerManager from '../managers/player'; import {STATUS} from '../services/player'; +import errorMsg from '../utils/error-msg'; import Command from '.'; @injectable() @@ -22,11 +23,11 @@ export default class implements Command { const player = this.playerManager.get(msg.guild!.id); if (player.status !== STATUS.PLAYING) { - await msg.channel.send('error: not currently playing'); + await msg.channel.send(errorMsg('not currently playing')); return; } player.pause(); - await msg.channel.send('paused'); + await msg.channel.send('the stop-and-go light is now red'); } } diff --git a/src/commands/play.ts b/src/commands/play.ts index cdfc9fe..9702b48 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -15,6 +15,7 @@ import QueueManager from '../managers/queue'; import PlayerManager from '../managers/player'; import {getMostPopularVoiceChannel} from '../utils/channels'; import LoadingMessage from '../utils/loading-message'; +import errorMsg from '../utils/error-msg'; import Command from '.'; @injectable() @@ -47,8 +48,11 @@ export default class implements Command { public async execute(msg: Message, args: string []): Promise { const [targetVoiceChannel, nInChannel] = getMostPopularVoiceChannel(msg.guild!); + const res = new LoadingMessage(msg.channel as TextChannel); + await res.start(); + if (nInChannel === 0) { - await msg.channel.send('error: all voice channels are empty'); + await res.stop(errorMsg('all voice channels are empty')); return; } @@ -56,28 +60,25 @@ export default class implements Command { if (args.length === 0) { if (this.playerManager.get(msg.guild!.id).status === STATUS.PLAYING) { - await msg.channel.send('error: already playing, give me a song name'); + await res.stop(errorMsg('already playing, give me a song name')); return; } // Must be resuming play if (queue.get().length === 0) { - await msg.channel.send('error: nothing to play'); + await res.stop(errorMsg('nothing to play')); return; } await this.playerManager.get(msg.guild!.id).connect(targetVoiceChannel); await this.playerManager.get(msg.guild!.id).play(); - await msg.channel.send('play resuming'); + await res.stop('play resuming'); return; } const newSongs: QueuedSong[] = []; - const res = new LoadingMessage(msg.channel as TextChannel, 'hold on a sec'); - await res.start(); - const addSingleSong = async (source: string): Promise => { const videoDetails = await this.youtube.videos.get(source); @@ -265,7 +266,7 @@ export default class implements Command { // TODO: better response await res.stop('song(s) queued'); - if (this.playerManager.get(msg.guild!.id).status === STATUS.DISCONNECTED) { + if (this.playerManager.get(msg.guild!.id).voiceConnection === null) { await this.playerManager.get(msg.guild!.id).connect(targetVoiceChannel); await this.playerManager.get(msg.guild!.id).play(); diff --git a/src/commands/seek.ts b/src/commands/seek.ts index d73eb88..7022168 100644 --- a/src/commands/seek.ts +++ b/src/commands/seek.ts @@ -4,6 +4,7 @@ import {inject, injectable} from 'inversify'; import PlayerManager from '../managers/player'; import QueueManager from '../managers/queue'; import LoadingMessage from '../utils/loading-message'; +import errorMsg from '../utils/error-msg'; import Command from '.'; @injectable() @@ -26,12 +27,12 @@ export default class implements Command { const queue = this.queueManager.get(msg.guild!.id); if (queue.get().length === 0) { - await msg.channel.send('nothing is playing'); + await msg.channel.send(errorMsg('nothing is playing')); return; } if (queue.get()[0].isLive) { - await msg.channel.send('can\'t seek in a livestream'); + await msg.channel.send(errorMsg('can\'t seek in a livestream')); return; } @@ -45,16 +46,16 @@ export default class implements Command { seekTime = parseInt(time, 10); } - const loading = new LoadingMessage(msg.channel as TextChannel, 'hold on a sec'); + const loading = new LoadingMessage(msg.channel as TextChannel); await loading.start(); try { await this.playerManager.get(msg.guild!.id).seek(seekTime); - await loading.stop('seeked'); - } catch (_) { - await loading.stop('error somewhere'); + await loading.stop(); + } catch (error) { + await loading.stop(errorMsg(error)); } } } diff --git a/src/commands/shortcuts.ts b/src/commands/shortcuts.ts index 240b10e..d8a885b 100644 --- a/src/commands/shortcuts.ts +++ b/src/commands/shortcuts.ts @@ -1,12 +1,14 @@ import {Message} from 'discord.js'; import {injectable} from 'inversify'; import {Shortcut, Settings} from '../models'; +import errorMsg from '../utils/error-msg'; import Command from '.'; @injectable() export default class implements Command { public name = 'shortcuts'; public examples = [ + ['shortcuts', 'show all shortcuts'], ['shortcuts set s skip', 'aliases `s` to `skip`'], ['shortcuts set party play https://www.youtube.com/watch?v=zK6oOJ1wz8k', 'aliases `party` to a specific play command'], ['shortcuts delete party', 'removes the `party` shortcut'] @@ -53,7 +55,7 @@ export default class implements Command { if (shortcut) { if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.owner!.id) { - await msg.channel.send('error: you do not have permission to do that'); + await msg.channel.send(errorMsg('you do\'nt have permission to do that')); return; } @@ -72,13 +74,13 @@ export default class implements Command { const shortcut = await Shortcut.findOne({where: {guildId: msg.guild!.id, shortcut: shortcutName}}); if (!shortcut) { - await msg.channel.send('error: shortcut does not exist'); + await msg.channel.send(errorMsg('shortcut doesn\'t exist')); return; } // Check permissions if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.owner!.id) { - await msg.channel.send('error: you do not have permission to do that'); + await msg.channel.send(errorMsg('you don\'t have permission to do that')); return; } @@ -90,7 +92,7 @@ export default class implements Command { } default: { - await msg.channel.send('error: unknown command'); + await msg.channel.send(errorMsg('unknown command')); } } } diff --git a/src/commands/shuffle.ts b/src/commands/shuffle.ts index ae5724a..42f22fa 100644 --- a/src/commands/shuffle.ts +++ b/src/commands/shuffle.ts @@ -2,6 +2,7 @@ import {Message} from 'discord.js'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; import QueueManager from '../managers/queue'; +import errorMsg from '../utils/error-msg'; import Command from '.'; @injectable() @@ -21,12 +22,13 @@ export default class implements Command { const queue = this.queueManager.get(msg.guild!.id).get(); if (queue.length <= 2) { - await msg.channel.send('error: not enough songs to shuffle'); + await msg.channel.send(errorMsg('not enough songs to shuffle')); return; } this.queueManager.get(msg.guild!.id).shuffle(); - await msg.channel.send('`' + JSON.stringify(this.queueManager.get(msg.guild!.id).get().slice(0, 10)) + '`'); + // TODO: better response + await msg.channel.send('shuffled'); } } diff --git a/src/commands/skip.ts b/src/commands/skip.ts index b06a3a9..481ea44 100644 --- a/src/commands/skip.ts +++ b/src/commands/skip.ts @@ -32,9 +32,8 @@ export default class implements Command { await this.playerManager.get(msg.guild!.id).play(); } - await msg.channel.send('keepin\' \'er movin\''); + await msg.channel.send('keep \'er movin\''); } catch (_) { - console.log(_); await msg.channel.send('no song to skip to'); } } diff --git a/src/commands/unskip.ts b/src/commands/unskip.ts index fd49e92..16831c7 100644 --- a/src/commands/unskip.ts +++ b/src/commands/unskip.ts @@ -3,6 +3,7 @@ import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; import PlayerManager from '../managers/player'; import QueueManager from '../managers/queue'; +import errorMsg from '../utils/error-msg'; import Command from '.'; @injectable() @@ -30,7 +31,7 @@ export default class implements Command { await msg.channel.send('back \'er up\''); } catch (_) { - await msg.channel.send('no song to go back to'); + await msg.channel.send(errorMsg('no song to go back to')); } } } diff --git a/src/services/player.ts b/src/services/player.ts index d578704..74803f8 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -10,12 +10,11 @@ import Queue, {QueuedSong} from './queue'; export enum STATUS { PLAYING, - PAUSED, - DISCONNECTED + PAUSED } export default class { - public status = STATUS.DISCONNECTED; + public status = STATUS.PAUSED; public voiceConnection: VoiceConnection | null = null; private readonly queue: Queue; private readonly cacheDir: string; @@ -35,17 +34,24 @@ export default class { this.voiceConnection = conn; } - disconnect(): void { + disconnect(breakConnection = true): void { if (this.voiceConnection) { if (this.status === STATUS.PLAYING) { this.pause(); } - this.voiceConnection.disconnect(); + if (breakConnection) { + this.voiceConnection.disconnect(); + } + + this.voiceConnection = null; + this.dispatcher = null; } } async seek(positionSeconds: number): Promise { + this.status = STATUS.PAUSED; + if (this.voiceConnection === null) { throw new Error('Not connected to a voice channel.'); } @@ -79,24 +85,28 @@ export default class { throw new Error('Not connected to a voice channel.'); } - // Resume from paused state - if (this.status === STATUS.PAUSED) { - if (this.dispatcher) { - this.dispatcher.resume(); - this.status = STATUS.PLAYING; - } else { - await this.seek(this.getPosition()); - } - - return; - } - const currentSong = this.getCurrentSong(); if (!currentSong) { throw new Error('Queue empty.'); } + // Resume from paused state + if (this.status === STATUS.PAUSED && this.getPosition() !== 0) { + if (this.dispatcher) { + this.dispatcher.resume(); + this.status = STATUS.PLAYING; + return; + } + + if (!currentSong.isLive) { + await this.seek(this.getPosition()); + return; + } + + // Must be livestream, continue + } + if (await this.isCached(currentSong.url)) { this.dispatcher = this.voiceConnection.play(this.getCachedPath(currentSong.url)); } else { @@ -153,7 +163,7 @@ export default class { } } - private async waitForCache(url: string, maxRetries = 50, retryDelay = 500): Promise { + private async waitForCache(url: string, maxRetries = 500, retryDelay = 200): Promise { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { if (await this.isCached(url)) { @@ -278,12 +288,7 @@ export default class { } this.voiceConnection.on('disconnect', () => { - // Automatically pause - if (this.status === STATUS.PLAYING) { - this.pause(); - } - - this.dispatcher = null; + this.disconnect(false); }); if (!this.dispatcher) { diff --git a/src/utils/debug.ts b/src/utils/debug.ts new file mode 100644 index 0000000..ee33384 --- /dev/null +++ b/src/utils/debug.ts @@ -0,0 +1,3 @@ +import debug from 'debug'; + +export default debug('muse'); diff --git a/src/utils/error-msg.ts b/src/utils/error-msg.ts new file mode 100644 index 0000000..7325776 --- /dev/null +++ b/src/utils/error-msg.ts @@ -0,0 +1,13 @@ +export default (error?: string | Error): string => { + let str = '🚫 unknown error'; + + if (error) { + if (typeof error === 'string') { + str = `🚫 ${error}`; + } else if (error instanceof Error) { + str = `🚫 error: ${error.name}`; + } + } + + return str; +}; diff --git a/src/utils/loading-message.ts b/src/utils/loading-message.ts index 3d24905..cf48d95 100644 --- a/src/utils/loading-message.ts +++ b/src/utils/loading-message.ts @@ -7,7 +7,7 @@ export default class { private msg!: Message; private isStopped = false; - constructor(channel: TextChannel, text: string) { + constructor(channel: TextChannel, text = 'cows! count \'em') { this.channel = channel; this.text = text; } @@ -53,7 +53,7 @@ export default class { })(); } - async stop(str?: string): Promise { + async stop(str = 'u betcha'): Promise { this.isStopped = true; if (str) { diff --git a/yarn.lock b/yarn.lock index 78564a1..0c74bda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -70,6 +70,11 @@ resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== +"@types/debug@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" + integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"