From 5871b2e9f974c3a0eeee371f86aa4f3de8bbd17a Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 10 Apr 2026 13:36:27 -0300 Subject: [PATCH 1/3] feat(bot): add playtop, playskip, skipto, seek, replay commands and pause/resume toggle --- package-lock.json | 12 +- .../functions/music/commands/pause.spec.ts | 39 ++-- .../bot/src/functions/music/commands/pause.ts | 34 ++-- .../functions/music/commands/play/index.ts | 27 ++- .../functions/music/commands/playskip.spec.ts | 65 +++++++ .../src/functions/music/commands/playskip.ts | 170 +++++++++++++++++ .../functions/music/commands/playtop.spec.ts | 65 +++++++ .../src/functions/music/commands/playtop.ts | 171 ++++++++++++++++++ .../commands/queueResolverWiring.spec.ts | 2 - .../functions/music/commands/replay.spec.ts | 106 +++++++++++ .../music/commands/{resume.ts => replay.ts} | 41 ++--- .../functions/music/commands/resume.spec.ts | 97 ---------- .../src/functions/music/commands/seek.spec.ts | 138 ++++++++++++++ .../bot/src/functions/music/commands/seek.ts | 119 ++++++++++++ .../functions/music/commands/skipto.spec.ts | 122 +++++++++++++ .../src/functions/music/commands/skipto.ts | 73 ++++++++ 16 files changed, 1116 insertions(+), 165 deletions(-) create mode 100644 packages/bot/src/functions/music/commands/playskip.spec.ts create mode 100644 packages/bot/src/functions/music/commands/playskip.ts create mode 100644 packages/bot/src/functions/music/commands/playtop.spec.ts create mode 100644 packages/bot/src/functions/music/commands/playtop.ts create mode 100644 packages/bot/src/functions/music/commands/replay.spec.ts rename packages/bot/src/functions/music/commands/{resume.ts => replay.ts} (57%) delete mode 100644 packages/bot/src/functions/music/commands/resume.spec.ts create mode 100644 packages/bot/src/functions/music/commands/seek.spec.ts create mode 100644 packages/bot/src/functions/music/commands/seek.ts create mode 100644 packages/bot/src/functions/music/commands/skipto.spec.ts create mode 100644 packages/bot/src/functions/music/commands/skipto.ts diff --git a/package-lock.json b/package-lock.json index def2cdb94..a6a033d6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lucky-bot", - "version": "2.6.64", + "version": "2.6.70", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lucky-bot", - "version": "2.6.64", + "version": "2.6.70", "license": "ISC", "workspaces": [ "packages/*" @@ -24525,7 +24525,7 @@ }, "packages/backend": { "name": "@lucky/backend", - "version": "2.6.64", + "version": "2.6.70", "dependencies": { "@lucky/shared": "file:../shared", "connect-redis": "^9.0.0", @@ -24579,7 +24579,7 @@ }, "packages/bot": { "name": "@lucky/bot", - "version": "2.6.64", + "version": "2.6.70", "dependencies": { "@discord-player/extractor": "^7.2.0", "@discordjs/builders": "^1.14.1", @@ -24668,7 +24668,7 @@ }, "packages/frontend": { "name": "lucky-webapp", - "version": "2.6.64", + "version": "2.6.70", "dependencies": { "@hookform/resolvers": "^5.0.0", "@radix-ui/react-avatar": "^1.1.11", @@ -25013,7 +25013,7 @@ }, "packages/shared": { "name": "@lucky/shared", - "version": "2.6.64", + "version": "2.6.70", "dependencies": { "@gar/promise-retry": "^1.0.3", "@npmcli/agent": "^4.0.0", diff --git a/packages/bot/src/functions/music/commands/pause.spec.ts b/packages/bot/src/functions/music/commands/pause.spec.ts index 13e9f8b73..fee8f2f75 100644 --- a/packages/bot/src/functions/music/commands/pause.spec.ts +++ b/packages/bot/src/functions/music/commands/pause.spec.ts @@ -5,7 +5,6 @@ const requireQueueMock = jest.fn() const requireVoiceChannelMock = jest.fn() const interactionReplyMock = jest.fn() const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc })) -const createWarningEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc })) const buildCommandTrackEmbedMock = jest.fn(() => ({})) const resolveGuildQueueMock = jest.fn() @@ -20,7 +19,6 @@ jest.mock('../../../utils/general/interactionReply', () => ({ jest.mock('../../../utils/general/embeds', () => ({ createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args), - createWarningEmbed: (...args: unknown[]) => createWarningEmbedMock(...args), })) jest.mock('../../../utils/general/responseEmbeds', () => ({ @@ -43,6 +41,7 @@ function createQueue(isPaused = false, currentTrack: unknown = null) { node: { isPaused: jest.fn().mockReturnValue(isPaused), pause: jest.fn(), + resume: jest.fn(), }, currentTrack, } as any @@ -52,7 +51,7 @@ function createClient() { return {} as any } -describe('pause command', () => { +describe('pause command (toggle)', () => { beforeEach(() => { jest.clearAllMocks() requireVoiceChannelMock.mockResolvedValue(true) @@ -68,6 +67,7 @@ describe('pause command', () => { await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any) expect(queue.node.pause).not.toHaveBeenCalled() + expect(queue.node.resume).not.toHaveBeenCalled() }) it('returns early when queue validation fails', async () => { @@ -78,37 +78,52 @@ describe('pause command', () => { await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any) expect(queue.node.pause).not.toHaveBeenCalled() + expect(queue.node.resume).not.toHaveBeenCalled() }) - it('shows warning when already paused', async () => { - const queue = createQueue(true) + it('resumes when currently paused with no track', async () => { + const queue = createQueue(true, null) resolveGuildQueueMock.mockReturnValue({ queue }) await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any) + expect(queue.node.resume).toHaveBeenCalled() expect(queue.node.pause).not.toHaveBeenCalled() - expect(createWarningEmbedMock).toHaveBeenCalledWith('Already paused', expect.any(String)) + expect(createSuccessEmbedMock).toHaveBeenCalledWith('▶️ Resumed', 'Music has been resumed.') }) - it('shows simple success embed when no current track', async () => { + it('pauses when currently playing with no track', async () => { const queue = createQueue(false, null) resolveGuildQueueMock.mockReturnValue({ queue }) await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any) expect(queue.node.pause).toHaveBeenCalled() + expect(queue.node.resume).not.toHaveBeenCalled() expect(createSuccessEmbedMock).toHaveBeenCalledWith('⏸️ Paused', 'Music has been paused.') }) - it('shows rich track embed when current track exists', async () => { - const track = { title: 'Test Song', author: 'Artist', url: 'http://x', duration: '3:00' } - const queue = createQueue(false, track) + it('resumes when currently paused with current track', async () => { + const currentTrack = { id: 'track-1', title: 'Test Song' } + const queue = createQueue(true, currentTrack) + resolveGuildQueueMock.mockReturnValue({ queue }) + + await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any) + + expect(queue.node.resume).toHaveBeenCalled() + expect(queue.node.pause).not.toHaveBeenCalled() + expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(currentTrack, '▶️ Resumed', expect.any(Object)) + }) + + it('pauses when currently playing with current track', async () => { + const currentTrack = { id: 'track-1', title: 'Test Song' } + const queue = createQueue(false, currentTrack) resolveGuildQueueMock.mockReturnValue({ queue }) await pauseCommand.execute({ client: createClient(), interaction: createInteraction() } as any) expect(queue.node.pause).toHaveBeenCalled() - expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(track, '⏸️ Paused', expect.any(Object)) - expect(interactionReplyMock).toHaveBeenCalled() + expect(queue.node.resume).not.toHaveBeenCalled() + expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(currentTrack, '⏸️ Paused', expect.any(Object)) }) }) diff --git a/packages/bot/src/functions/music/commands/pause.ts b/packages/bot/src/functions/music/commands/pause.ts index 41ec1476c..8f0f8dee5 100644 --- a/packages/bot/src/functions/music/commands/pause.ts +++ b/packages/bot/src/functions/music/commands/pause.ts @@ -7,13 +7,13 @@ import { requireVoiceChannel, } from "../../../utils/command/commandValidations" import { resolveGuildQueue } from '../../../utils/music/queueResolver' -import { createSuccessEmbed, createWarningEmbed } from '../../../utils/general/embeds' +import { createSuccessEmbed } from '../../../utils/general/embeds' import { buildCommandTrackEmbed } from '../../../utils/general/responseEmbeds' export default new Command({ data: new SlashCommandBuilder() .setName('pause') - .setDescription('⏸️ Pause the current music.'), + .setDescription('⏸️ Toggle pause/resume music.'), category: 'music', execute: async ({ client, interaction }: CommandExecuteParams) => { if (!(await requireVoiceChannel(interaction))) return @@ -22,33 +22,25 @@ export default new Command({ if (!(await requireQueue(queue, interaction))) return - if (queue?.node.isPaused() === true) { - await interactionReply({ - interaction, - content: { - embeds: [ - createWarningEmbed( - 'Already paused', - '⏸️ Music is already paused.', - ), - ], - ephemeral: true, - }, - }) - return - } + const isPaused = queue?.node.isPaused() === true - queue?.node.pause() + if (isPaused) { + queue?.node.resume() + } else { + queue?.node.pause() + } const currentTrack = queue?.currentTrack + const action = isPaused ? '▶️ Resumed' : '⏸️ Paused' + if (!currentTrack) { await interactionReply({ interaction, content: { embeds: [ createSuccessEmbed( - '⏸️ Paused', - 'Music has been paused.', + action, + `Music has been ${isPaused ? 'resumed' : 'paused'}.`, ), ], }, @@ -56,7 +48,7 @@ export default new Command({ return } - const trackEmbed = buildCommandTrackEmbed(currentTrack, '⏸️ Paused', interaction.user) + const trackEmbed = buildCommandTrackEmbed(currentTrack, action, interaction.user) await interactionReply({ interaction, content: { embeds: [trackEmbed] } }) }, }) diff --git a/packages/bot/src/functions/music/commands/play/index.ts b/packages/bot/src/functions/music/commands/play/index.ts index cff898d55..11dc21049 100644 --- a/packages/bot/src/functions/music/commands/play/index.ts +++ b/packages/bot/src/functions/music/commands/play/index.ts @@ -35,9 +35,18 @@ function isUrl(query: string): boolean { return query.startsWith('http://') || query.startsWith('https://') } -function resolveSearchEngine(query: string): QueryType { +function resolveSearchEngine(query: string, provider?: string | null): QueryType { if (isUrl(query)) return QueryType.AUTO - return QueryType.SPOTIFY_SEARCH + + switch (provider) { + case 'youtube': + return QueryType.YOUTUBE_SEARCH + case 'soundcloud': + return QueryType.SOUNDCLOUD_SEARCH + case 'spotify': + default: + return QueryType.SPOTIFY_SEARCH + } } function isTrackAlreadyQueued( @@ -66,6 +75,17 @@ export default new Command({ 'Song name, artist, YouTube URL, or Spotify URL', ) .setRequired(true), + ) + .addStringOption((option) => + option + .setName('provider') + .setDescription('Music provider to search (default: spotify)') + .addChoices( + { name: 'Spotify', value: 'spotify' }, + { name: 'YouTube', value: 'youtube' }, + { name: 'SoundCloud', value: 'soundcloud' }, + ) + .setRequired(false), ), category: 'music', execute: async ({ @@ -98,6 +118,7 @@ export default new Command({ } const query = interaction.options.getString('query', true) + const provider = interaction.options.getString('provider') const collaborativeCheck = collaborativePlaylistService.canAddTracks( interaction.guildId, interaction.user.id, @@ -123,7 +144,7 @@ export default new Command({ resolveGuildQueue(client, interaction.guildId ?? '').queue, ) - const searchEngine = resolveSearchEngine(query) + const searchEngine = resolveSearchEngine(query, provider) const playOptions = { nodeOptions: { metadata: { diff --git a/packages/bot/src/functions/music/commands/playskip.spec.ts b/packages/bot/src/functions/music/commands/playskip.spec.ts new file mode 100644 index 000000000..a16eed775 --- /dev/null +++ b/packages/bot/src/functions/music/commands/playskip.spec.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +// Mock dependencies before importing the command +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: jest.fn(), +})) + +jest.mock('../../../utils/general/embeds', () => ({ + createErrorEmbed: jest.fn(), + errorEmbed: jest.fn(), +})) + +jest.mock('../../../utils/music/nowPlayingEmbed', () => ({ + buildPlayResponseEmbed: jest.fn(), +})) + +jest.mock('../../../utils/music/buttonComponents', () => ({ + createMusicControlButtons: jest.fn(), +})) + +jest.mock('../../../utils/music/queueResolver', () => ({ + resolveGuildQueue: jest.fn(), +})) + +jest.mock('../../../utils/general/errorSanitizer', () => ({ + createUserFriendlyError: jest.fn(), +})) + +jest.mock('../../../utils/command/commandValidations', () => ({ + requireVoiceChannel: jest.fn(), + requireGuild: jest.fn(), + requireQueue: jest.fn(), + requireCurrentTrack: jest.fn(), + requireIsPlaying: jest.fn(), + requireInteractionOptions: jest.fn(), +})) + +jest.mock('@lucky/shared/utils', () => ({ + debugLog: jest.fn(), + errorLog: jest.fn(), + warnLog: jest.fn(), + createUserErrorMessage: jest.fn(), + handleError: jest.fn(), +})) + +import playSkipCommand from './playskip' + +describe('playskip command', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should have correct command structure', () => { + expect(playSkipCommand.data.name).toBe('playskip') + expect(playSkipCommand.category).toBe('music') + expect(playSkipCommand.execute).toBeDefined() + }) + + it('should have query string option', () => { + const options = playSkipCommand.data.options + const queryOption = options.find((opt: any) => opt.name === 'query') + expect(queryOption).toBeDefined() + expect(queryOption?.required).toBe(true) + }) +}) diff --git a/packages/bot/src/functions/music/commands/playskip.ts b/packages/bot/src/functions/music/commands/playskip.ts new file mode 100644 index 000000000..a25102bbc --- /dev/null +++ b/packages/bot/src/functions/music/commands/playskip.ts @@ -0,0 +1,170 @@ +import { SlashCommandBuilder } from '@discordjs/builders' +import type { GuildMember, ChatInputCommandInteraction } from 'discord.js' +import { requireVoiceChannel } from '../../../utils/command/commandValidations' +import type { CommandExecuteParams } from '../../../types/CommandData' +import Command from '../../../models/Command' +import { errorLog, debugLog, warnLog } from '@lucky/shared/utils' +import { createErrorEmbed } from '../../../utils/general/embeds' +import { interactionReply } from '../../../utils/general/interactionReply' +import { createUserFriendlyError } from '../../../utils/general/errorSanitizer' +import { resolveGuildQueue } from '../../../utils/music/queueResolver' +import { buildPlayResponseEmbed } from '../../../utils/music/nowPlayingEmbed' +import { createMusicControlButtons } from '../../../utils/music/buttonComponents' +import { QueryType } from 'discord-player' + +const DISCORD_UNKNOWN_INTERACTION_CODE = 10062 + +function isUnknownInteractionError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: number }).code === DISCORD_UNKNOWN_INTERACTION_CODE + ) +} + +function isUrl(query: string): boolean { + return query.startsWith('http://') || query.startsWith('https://') +} + +function resolveSearchEngine(query: string): QueryType { + if (isUrl(query)) return QueryType.AUTO + return QueryType.SPOTIFY_SEARCH +} + +export default new Command({ + data: new SlashCommandBuilder() + .setName('playskip') + .setDescription( + 'Add a track to the top of the queue and skip the current track', + ) + .addStringOption((option) => + option + .setName('query') + .setDescription( + 'Song name, artist, YouTube URL, or Spotify URL', + ) + .setRequired(true), + ), + category: 'music', + execute: async ({ + client, + interaction, + }: CommandExecuteParams): Promise => { + if (!interaction.guildId) { + await interaction.reply({ + embeds: [ + createErrorEmbed( + 'Error', + 'This command can only be used in a server', + ), + ], + ephemeral: true, + }) + return + } + + const member = interaction.member as GuildMember + if (!(await requireVoiceChannel(interaction))) return + + const voiceChannel = member.voice.channel! + + try { + await interaction.deferReply() + } catch (error) { + if (isUnknownInteractionError(error)) return + throw error + } + + const query = interaction.options.getString('query', true) + + try { + const searchEngine = resolveSearchEngine(query) + let result = await client.player.play(voiceChannel, query, { + searchEngine, + }) + + const track = result.track + const { queue } = resolveGuildQueue( + client, + interaction.guildId ?? '', + ) + + if (!queue) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Error', + 'Could not create queue', + ), + ], + ephemeral: true, + }, + }) + return + } + + const tracks = queue.tracks.toArray() + if (tracks.length > 0) { + queue.node.remove(track) + queue.insertTrack(track, 0) + queue.node.skip() + } + + const embed = buildPlayResponseEmbed({ + kind: 'nowPlaying', + track, + requestedBy: interaction.user, + }) + + const components = [createMusicControlButtons(queue)] + + await interactionReply({ + interaction, + content: { embeds: [embed], components }, + }) + + debugLog({ + message: 'Track added to top and current skipped', + data: { query, guildId: interaction.guildId }, + }) + } catch (error) { + if (isUnknownInteractionError(error)) { + debugLog({ + message: 'Playskip command interaction expired before reply', + data: { query, guildId: interaction.guildId }, + }) + return + } + + errorLog({ + message: 'Playskip command error:', + error, + data: { query, guildId: interaction.guildId }, + }) + + try { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Play Error', + createUserFriendlyError(error), + ), + ], + ephemeral: true, + }, + }) + } catch (replyError) { + warnLog({ + message: 'Failed to send playskip command error reply', + error: replyError, + data: { guildId: interaction.guildId }, + }) + } + } + }, +}) diff --git a/packages/bot/src/functions/music/commands/playtop.spec.ts b/packages/bot/src/functions/music/commands/playtop.spec.ts new file mode 100644 index 000000000..5162c53c1 --- /dev/null +++ b/packages/bot/src/functions/music/commands/playtop.spec.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +// Mock dependencies before importing the command +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: jest.fn(), +})) + +jest.mock('../../../utils/general/embeds', () => ({ + createErrorEmbed: jest.fn(), + errorEmbed: jest.fn(), +})) + +jest.mock('../../../utils/music/nowPlayingEmbed', () => ({ + buildPlayResponseEmbed: jest.fn(), +})) + +jest.mock('../../../utils/music/buttonComponents', () => ({ + createMusicControlButtons: jest.fn(), +})) + +jest.mock('../../../utils/music/queueResolver', () => ({ + resolveGuildQueue: jest.fn(), +})) + +jest.mock('../../../utils/general/errorSanitizer', () => ({ + createUserFriendlyError: jest.fn(), +})) + +jest.mock('../../../utils/command/commandValidations', () => ({ + requireVoiceChannel: jest.fn(), + requireGuild: jest.fn(), + requireQueue: jest.fn(), + requireCurrentTrack: jest.fn(), + requireIsPlaying: jest.fn(), + requireInteractionOptions: jest.fn(), +})) + +jest.mock('@lucky/shared/utils', () => ({ + debugLog: jest.fn(), + errorLog: jest.fn(), + warnLog: jest.fn(), + createUserErrorMessage: jest.fn(), + handleError: jest.fn(), +})) + +import playTopCommand from './playtop' + +describe('playtop command', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should have correct command structure', () => { + expect(playTopCommand.data.name).toBe('playtop') + expect(playTopCommand.category).toBe('music') + expect(playTopCommand.execute).toBeDefined() + }) + + it('should have query string option', () => { + const options = playTopCommand.data.options + const queryOption = options.find((opt: any) => opt.name === 'query') + expect(queryOption).toBeDefined() + expect(queryOption?.required).toBe(true) + }) +}) diff --git a/packages/bot/src/functions/music/commands/playtop.ts b/packages/bot/src/functions/music/commands/playtop.ts new file mode 100644 index 000000000..f875b302a --- /dev/null +++ b/packages/bot/src/functions/music/commands/playtop.ts @@ -0,0 +1,171 @@ +import { SlashCommandBuilder } from '@discordjs/builders' +import type { GuildMember, ChatInputCommandInteraction } from 'discord.js' +import { requireVoiceChannel } from '../../../utils/command/commandValidations' +import type { CommandExecuteParams } from '../../../types/CommandData' +import type { CustomClient } from '../../../types' +import Command from '../../../models/Command' +import { errorLog, debugLog, warnLog } from '@lucky/shared/utils' +import { createErrorEmbed } from '../../../utils/general/embeds' +import { interactionReply } from '../../../utils/general/interactionReply' +import { createUserFriendlyError } from '../../../utils/general/errorSanitizer' +import { resolveGuildQueue } from '../../../utils/music/queueResolver' +import { buildPlayResponseEmbed } from '../../../utils/music/nowPlayingEmbed' +import { createMusicControlButtons } from '../../../utils/music/buttonComponents' +import { QueryType } from 'discord-player' + +const DISCORD_UNKNOWN_INTERACTION_CODE = 10062 + +function isUnknownInteractionError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: number }).code === DISCORD_UNKNOWN_INTERACTION_CODE + ) +} + +function isUrl(query: string): boolean { + return query.startsWith('http://') || query.startsWith('https://') +} + +function resolveSearchEngine(query: string): QueryType { + if (isUrl(query)) return QueryType.AUTO + return QueryType.SPOTIFY_SEARCH +} + +export default new Command({ + data: new SlashCommandBuilder() + .setName('playtop') + .setDescription( + 'Add a track to the top of the queue (plays next)', + ) + .addStringOption((option) => + option + .setName('query') + .setDescription( + 'Song name, artist, YouTube URL, or Spotify URL', + ) + .setRequired(true), + ), + category: 'music', + execute: async ({ + client, + interaction, + }: CommandExecuteParams): Promise => { + if (!interaction.guildId) { + await interaction.reply({ + embeds: [ + createErrorEmbed( + 'Error', + 'This command can only be used in a server', + ), + ], + ephemeral: true, + }) + return + } + + const member = interaction.member as GuildMember + if (!(await requireVoiceChannel(interaction))) return + + const voiceChannel = member.voice.channel! + + try { + await interaction.deferReply() + } catch (error) { + if (isUnknownInteractionError(error)) return + throw error + } + + const query = interaction.options.getString('query', true) + + try { + const searchEngine = resolveSearchEngine(query) + let result = await client.player.play(voiceChannel, query, { + searchEngine, + }) + + const track = result.track + const { queue } = resolveGuildQueue( + client, + interaction.guildId ?? '', + ) + + if (!queue) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Error', + 'Could not create queue', + ), + ], + ephemeral: true, + }, + }) + return + } + + const tracks = queue.tracks.toArray() + if (tracks.length > 0) { + queue.node.remove(track) + queue.insertTrack(track, 0) + } + + const embed = buildPlayResponseEmbed({ + kind: 'addedToQueue', + track, + requestedBy: interaction.user, + queuePosition: 1, + }) + + const components = [createMusicControlButtons(queue)] + + await interactionReply({ + interaction, + content: { embeds: [embed], components }, + }) + + debugLog({ + message: 'Track added to top of queue', + data: { query, guildId: interaction.guildId }, + }) + } catch (error) { + if (isUnknownInteractionError(error)) { + debugLog({ + message: 'Playtop command interaction expired before reply', + data: { query, guildId: interaction.guildId }, + }) + return + } + + errorLog({ + message: 'Playtop command error:', + error, + data: { query, guildId: interaction.guildId }, + }) + + try { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Play Error', + createUserFriendlyError(error), + ), + ], + ephemeral: true, + }, + }) + } catch (replyError) { + warnLog({ + message: 'Failed to send playtop command error reply', + error: replyError, + data: { guildId: interaction.guildId }, + }) + } + } + }, +}) diff --git a/packages/bot/src/functions/music/commands/queueResolverWiring.spec.ts b/packages/bot/src/functions/music/commands/queueResolverWiring.spec.ts index 6e6865c25..9aab5fb0f 100644 --- a/packages/bot/src/functions/music/commands/queueResolverWiring.spec.ts +++ b/packages/bot/src/functions/music/commands/queueResolverWiring.spec.ts @@ -6,7 +6,6 @@ import moveCommand from './move' import pauseCommand from './pause' import removeCommand from './remove' import repeatCommand from './repeat' -import resumeCommand from './resume' import shuffleCommand from './shuffle' import skipCommand from './skip' import songinfoCommand from './songinfo' @@ -82,7 +81,6 @@ const queueValidationCases: QueueCase[] = [ { name: 'pause', command: pauseCommand }, { name: 'remove', command: removeCommand }, { name: 'repeat', command: repeatCommand }, - { name: 'resume', command: resumeCommand }, { name: 'shuffle', command: shuffleCommand }, { name: 'skip', command: skipCommand }, { name: 'songinfo', command: songinfoCommand }, diff --git a/packages/bot/src/functions/music/commands/replay.spec.ts b/packages/bot/src/functions/music/commands/replay.spec.ts new file mode 100644 index 000000000..075fbf519 --- /dev/null +++ b/packages/bot/src/functions/music/commands/replay.spec.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import replayCommand from './replay' + +const requireQueueMock = jest.fn() +const requireCurrentTrackMock = jest.fn() +const requireIsPlayingMock = jest.fn() +const requireVoiceChannelMock = jest.fn() +const interactionReplyMock = jest.fn() +const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc })) +const buildCommandTrackEmbedMock = jest.fn(() => ({})) +const resolveGuildQueueMock = jest.fn() + +jest.mock('../../../utils/command/commandValidations', () => ({ + requireQueue: (...args: unknown[]) => requireQueueMock(...args), + requireCurrentTrack: (...args: unknown[]) => requireCurrentTrackMock(...args), + requireIsPlaying: (...args: unknown[]) => requireIsPlayingMock(...args), + requireVoiceChannel: (...args: unknown[]) => requireVoiceChannelMock(...args), +})) + +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: (...args: unknown[]) => interactionReplyMock(...args), +})) + +jest.mock('../../../utils/general/embeds', () => ({ + createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args), +})) + +jest.mock('../../../utils/general/responseEmbeds', () => ({ + buildCommandTrackEmbed: (...args: unknown[]) => buildCommandTrackEmbedMock(...args), +})) + +jest.mock('../../../utils/music/queueResolver', () => ({ + resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args), +})) + +function createInteraction(guildId = 'guild-1') { + return { + guildId, + user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') }, + } as any +} + +function createQueue(currentTrack: unknown = null) { + return { + node: { + seek: jest.fn(), + }, + currentTrack, + } as any +} + +function createClient() { + return {} as any +} + +describe('replay command', () => { + beforeEach(() => { + jest.clearAllMocks() + requireVoiceChannelMock.mockResolvedValue(true) + requireQueueMock.mockResolvedValue(true) + requireCurrentTrackMock.mockResolvedValue(true) + requireIsPlayingMock.mockResolvedValue(true) + buildCommandTrackEmbedMock.mockReturnValue({}) + }) + + it('returns early when voice channel validation fails', async () => { + requireVoiceChannelMock.mockResolvedValue(false) + const queue = createQueue() + resolveGuildQueueMock.mockReturnValue({ queue }) + + await replayCommand.execute({ client: createClient(), interaction: createInteraction() } as any) + + expect(queue.node.seek).not.toHaveBeenCalled() + }) + + it('returns early when queue validation fails', async () => { + requireQueueMock.mockResolvedValue(false) + const queue = createQueue() + resolveGuildQueueMock.mockReturnValue({ queue }) + + await replayCommand.execute({ client: createClient(), interaction: createInteraction() } as any) + + expect(queue.node.seek).not.toHaveBeenCalled() + }) + + it('seeks to beginning with current track', async () => { + const currentTrack = { id: 'track-1', title: 'Test Song' } + const queue = createQueue(currentTrack) + resolveGuildQueueMock.mockReturnValue({ queue }) + + await replayCommand.execute({ client: createClient(), interaction: createInteraction() } as any) + + expect(queue.node.seek).toHaveBeenCalledWith(0) + expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(currentTrack, '🔄 Replayed', expect.any(Object)) + }) + + it('seeks to beginning with no current track', async () => { + const queue = createQueue(null) + resolveGuildQueueMock.mockReturnValue({ queue }) + + await replayCommand.execute({ client: createClient(), interaction: createInteraction() } as any) + + expect(queue.node.seek).toHaveBeenCalledWith(0) + expect(createSuccessEmbedMock).toHaveBeenCalledWith('🔄 Replayed', 'Track has been replayed from the beginning.') + }) +}) diff --git a/packages/bot/src/functions/music/commands/resume.ts b/packages/bot/src/functions/music/commands/replay.ts similarity index 57% rename from packages/bot/src/functions/music/commands/resume.ts rename to packages/bot/src/functions/music/commands/replay.ts index 347ae5a40..13ee3d979 100644 --- a/packages/bot/src/functions/music/commands/resume.ts +++ b/packages/bot/src/functions/music/commands/replay.ts @@ -2,38 +2,31 @@ import { SlashCommandBuilder } from '@discordjs/builders' import Command from '../../../models/Command' import { interactionReply } from "../../../utils/general/interactionReply" import type { CommandExecuteParams } from "../../../types/CommandData" -import { requireQueue } from "../../../utils/command/commandValidations" +import { + requireQueue, + requireCurrentTrack, + requireIsPlaying, + requireVoiceChannel, +} from "../../../utils/command/commandValidations" import { resolveGuildQueue } from '../../../utils/music/queueResolver' -import { createSuccessEmbed, createWarningEmbed } from '../../../utils/general/embeds' +import { createSuccessEmbed } from '../../../utils/general/embeds' import { buildCommandTrackEmbed } from '../../../utils/general/responseEmbeds' export default new Command({ data: new SlashCommandBuilder() - .setName('resume') - .setDescription('▶️ Resume the paused music.'), + .setName('replay') + .setDescription('🔄 Replay the current song from the beginning.'), category: 'music', execute: async ({ client, interaction }: CommandExecuteParams) => { + if (!(await requireVoiceChannel(interaction))) return + const { queue } = resolveGuildQueue(client, interaction.guildId ?? '') if (!(await requireQueue(queue, interaction))) return + if (!(await requireCurrentTrack(queue, interaction))) return + if (!(await requireIsPlaying(queue, interaction))) return - if (queue !== null && queue !== undefined && !queue.node.isPaused()) { - await interactionReply({ - interaction, - content: { - embeds: [ - createWarningEmbed( - 'Already playing', - '▶️ Music is already playing.', - ), - ], - ephemeral: true, - }, - }) - return - } - - queue?.node.resume() + queue?.node.seek(0) const currentTrack = queue?.currentTrack if (!currentTrack) { @@ -42,8 +35,8 @@ export default new Command({ content: { embeds: [ createSuccessEmbed( - '▶️ Resumed', - 'Music has been resumed.', + '🔄 Replayed', + 'Track has been replayed from the beginning.', ), ], }, @@ -51,7 +44,7 @@ export default new Command({ return } - const trackEmbed = buildCommandTrackEmbed(currentTrack, '▶️ Resumed', interaction.user) + const trackEmbed = buildCommandTrackEmbed(currentTrack, '🔄 Replayed', interaction.user) await interactionReply({ interaction, content: { embeds: [trackEmbed] } }) }, }) diff --git a/packages/bot/src/functions/music/commands/resume.spec.ts b/packages/bot/src/functions/music/commands/resume.spec.ts deleted file mode 100644 index 3f40e0ed7..000000000 --- a/packages/bot/src/functions/music/commands/resume.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals' -import resumeCommand from './resume' - -const requireQueueMock = jest.fn() -const interactionReplyMock = jest.fn() -const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc })) -const createWarningEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc })) -const buildCommandTrackEmbedMock = jest.fn(() => ({})) -const resolveGuildQueueMock = jest.fn() - -jest.mock('../../../utils/command/commandValidations', () => ({ - requireQueue: (...args: unknown[]) => requireQueueMock(...args), -})) - -jest.mock('../../../utils/general/interactionReply', () => ({ - interactionReply: (...args: unknown[]) => interactionReplyMock(...args), -})) - -jest.mock('../../../utils/general/embeds', () => ({ - createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args), - createWarningEmbed: (...args: unknown[]) => createWarningEmbedMock(...args), -})) - -jest.mock('../../../utils/general/responseEmbeds', () => ({ - buildCommandTrackEmbed: (...args: unknown[]) => buildCommandTrackEmbedMock(...args), -})) - -jest.mock('../../../utils/music/queueResolver', () => ({ - resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args), -})) - -function createInteraction(guildId = 'guild-1') { - return { - guildId, - user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') }, - } as any -} - -function createQueue(isPaused = true, currentTrack: unknown = null) { - return { - node: { - isPaused: jest.fn().mockReturnValue(isPaused), - resume: jest.fn(), - }, - currentTrack, - } as any -} - -describe('resume command', () => { - beforeEach(() => { - jest.clearAllMocks() - requireQueueMock.mockResolvedValue(true) - buildCommandTrackEmbedMock.mockReturnValue({}) - }) - - it('returns early when queue validation fails', async () => { - requireQueueMock.mockResolvedValue(false) - const queue = createQueue() - resolveGuildQueueMock.mockReturnValue({ queue }) - - await resumeCommand.execute({ client: {} as any, interaction: createInteraction() } as any) - - expect(queue.node.resume).not.toHaveBeenCalled() - }) - - it('shows warning when already playing (not paused)', async () => { - const queue = createQueue(false) - resolveGuildQueueMock.mockReturnValue({ queue }) - - await resumeCommand.execute({ client: {} as any, interaction: createInteraction() } as any) - - expect(queue.node.resume).not.toHaveBeenCalled() - expect(createWarningEmbedMock).toHaveBeenCalledWith('Already playing', expect.any(String)) - }) - - it('shows simple success embed when no current track', async () => { - const queue = createQueue(true, null) - resolveGuildQueueMock.mockReturnValue({ queue }) - - await resumeCommand.execute({ client: {} as any, interaction: createInteraction() } as any) - - expect(queue.node.resume).toHaveBeenCalled() - expect(createSuccessEmbedMock).toHaveBeenCalledWith('▶️ Resumed', 'Music has been resumed.') - }) - - it('shows rich track embed when current track exists', async () => { - const track = { title: 'Test Song', author: 'Artist', url: 'http://x', duration: '3:00' } - const queue = createQueue(true, track) - resolveGuildQueueMock.mockReturnValue({ queue }) - - await resumeCommand.execute({ client: {} as any, interaction: createInteraction() } as any) - - expect(queue.node.resume).toHaveBeenCalled() - expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(track, '▶️ Resumed', expect.any(Object)) - expect(interactionReplyMock).toHaveBeenCalled() - }) -}) diff --git a/packages/bot/src/functions/music/commands/seek.spec.ts b/packages/bot/src/functions/music/commands/seek.spec.ts new file mode 100644 index 000000000..dab953aa1 --- /dev/null +++ b/packages/bot/src/functions/music/commands/seek.spec.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import seekCommand from './seek' + +const requireQueueMock = jest.fn() +const requireCurrentTrackMock = jest.fn() +const requireIsPlayingMock = jest.fn() +const requireVoiceChannelMock = jest.fn() +const interactionReplyMock = jest.fn() +const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc })) +const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc })) +const buildCommandTrackEmbedMock = jest.fn(() => ({})) +const resolveGuildQueueMock = jest.fn() + +jest.mock('../../../utils/command/commandValidations', () => ({ + requireQueue: (...args: unknown[]) => requireQueueMock(...args), + requireCurrentTrack: (...args: unknown[]) => requireCurrentTrackMock(...args), + requireIsPlaying: (...args: unknown[]) => requireIsPlayingMock(...args), + requireVoiceChannel: (...args: unknown[]) => requireVoiceChannelMock(...args), +})) + +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: (...args: unknown[]) => interactionReplyMock(...args), +})) + +jest.mock('../../../utils/general/embeds', () => ({ + createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args), + createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args), +})) + +jest.mock('../../../utils/general/responseEmbeds', () => ({ + buildCommandTrackEmbed: (...args: unknown[]) => buildCommandTrackEmbedMock(...args), +})) + +jest.mock('../../../utils/music/queueResolver', () => ({ + resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args), +})) + +function createInteraction(guildId = 'guild-1', timeStr = '1:30') { + return { + guildId, + user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') }, + options: { + getString: jest.fn().mockReturnValue(timeStr), + }, + } as any +} + +function createQueue(currentTrack: unknown = null) { + return { + node: { + seek: jest.fn(), + }, + currentTrack, + } as any +} + +function createClient() { + return {} as any +} + +describe('seek command', () => { + beforeEach(() => { + jest.clearAllMocks() + requireVoiceChannelMock.mockResolvedValue(true) + requireQueueMock.mockResolvedValue(true) + requireCurrentTrackMock.mockResolvedValue(true) + requireIsPlayingMock.mockResolvedValue(true) + buildCommandTrackEmbedMock.mockReturnValue({}) + }) + + it('returns early when voice channel validation fails', async () => { + requireVoiceChannelMock.mockResolvedValue(false) + const queue = createQueue() + resolveGuildQueueMock.mockReturnValue({ queue }) + + await seekCommand.execute({ client: createClient(), interaction: createInteraction() } as any) + + expect(queue.node.seek).not.toHaveBeenCalled() + }) + + it('seeks to time in mm:ss format', async () => { + const currentTrack = { id: 'track-1', title: 'Test Song', durationMS: 300000 } + const queue = createQueue(currentTrack) + resolveGuildQueueMock.mockReturnValue({ queue }) + + const interaction = createInteraction('guild-1', '1:30') + await seekCommand.execute({ client: createClient(), interaction } as any) + + expect(queue.node.seek).toHaveBeenCalledWith(90000) + }) + + it('seeks to time in ss format', async () => { + const currentTrack = { id: 'track-1', title: 'Test Song', durationMS: 300000 } + const queue = createQueue(currentTrack) + resolveGuildQueueMock.mockReturnValue({ queue }) + + const interaction = createInteraction('guild-1', '45') + await seekCommand.execute({ client: createClient(), interaction } as any) + + expect(queue.node.seek).toHaveBeenCalledWith(45000) + }) + + it('shows error for invalid time format', async () => { + const currentTrack = { id: 'track-1', title: 'Test Song', durationMS: 300000 } + const queue = createQueue(currentTrack) + resolveGuildQueueMock.mockReturnValue({ queue }) + + const interaction = createInteraction('guild-1', 'invalid') + await seekCommand.execute({ client: createClient(), interaction } as any) + + expect(queue.node.seek).not.toHaveBeenCalled() + expect(createErrorEmbedMock).toHaveBeenCalledWith('Invalid time format', expect.any(String)) + }) + + it('shows error when seek time exceeds track duration', async () => { + const currentTrack = { id: 'track-1', title: 'Test Song', durationMS: 60000 } + const queue = createQueue(currentTrack) + resolveGuildQueueMock.mockReturnValue({ queue }) + + const interaction = createInteraction('guild-1', '2:30') + await seekCommand.execute({ client: createClient(), interaction } as any) + + expect(queue.node.seek).not.toHaveBeenCalled() + expect(createErrorEmbedMock).toHaveBeenCalledWith('Time out of range', expect.any(String)) + }) + + it('shows error when track does not support seeking', async () => { + const currentTrack = { id: 'track-1', title: 'Live Stream', durationMS: null } + const queue = createQueue(currentTrack) + resolveGuildQueueMock.mockReturnValue({ queue }) + + const interaction = createInteraction('guild-1', '1:30') + await seekCommand.execute({ client: createClient(), interaction } as any) + + expect(queue.node.seek).not.toHaveBeenCalled() + expect(createErrorEmbedMock).toHaveBeenCalledWith('Cannot seek', expect.any(String)) + }) +}) diff --git a/packages/bot/src/functions/music/commands/seek.ts b/packages/bot/src/functions/music/commands/seek.ts new file mode 100644 index 000000000..bbd110d01 --- /dev/null +++ b/packages/bot/src/functions/music/commands/seek.ts @@ -0,0 +1,119 @@ +import { SlashCommandBuilder } from '@discordjs/builders' +import Command from '../../../models/Command' +import { interactionReply } from "../../../utils/general/interactionReply" +import type { CommandExecuteParams } from "../../../types/CommandData" +import { + requireQueue, + requireCurrentTrack, + requireIsPlaying, + requireVoiceChannel, +} from "../../../utils/command/commandValidations" +import { resolveGuildQueue } from '../../../utils/music/queueResolver' +import { createSuccessEmbed, createErrorEmbed } from '../../../utils/general/embeds' +import { buildCommandTrackEmbed } from '../../../utils/general/responseEmbeds' + +function parseTimeToMs(timeStr: string): number | null { + const parts = timeStr.split(':') + + if (parts.length === 1) { + const seconds = parseInt(parts[0], 10) + return !isNaN(seconds) && seconds >= 0 ? seconds * 1000 : null + } + + if (parts.length === 2) { + const minutes = parseInt(parts[0], 10) + const seconds = parseInt(parts[1], 10) + return !isNaN(minutes) && !isNaN(seconds) && minutes >= 0 && seconds >= 0 && seconds < 60 + ? (minutes * 60 + seconds) * 1000 + : null + } + + return null +} + +function formatMs(ms: number): string { + const totalSeconds = Math.floor(ms / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}:${seconds.toString().padStart(2, '0')}` +} + +export default new Command({ + data: new SlashCommandBuilder() + .setName('seek') + .setDescription('⏩ Seek to a position in the current track.') + .addStringOption((option) => + option + .setName('time') + .setDescription('Time to seek to (mm:ss or ss format)') + .setRequired(true), + ), + category: 'music', + execute: async ({ client, interaction }: CommandExecuteParams) => { + if (!(await requireVoiceChannel(interaction))) return + + const { queue } = resolveGuildQueue(client, interaction.guildId ?? '') + + if (!(await requireQueue(queue, interaction))) return + if (!(await requireCurrentTrack(queue, interaction))) return + if (!(await requireIsPlaying(queue, interaction))) return + + const timeStr = interaction.options.getString('time', true) + const targetMs = parseTimeToMs(timeStr) + + if (targetMs === null) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Invalid time format', + 'Please use mm:ss or ss format (e.g., "1:30" or "90")', + ), + ], + ephemeral: true, + }, + }) + return + } + + const currentTrack = queue?.currentTrack + if (!currentTrack?.durationMS) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Cannot seek', + 'This track does not support seeking.', + ), + ], + ephemeral: true, + }, + }) + return + } + + if (targetMs > currentTrack.durationMS) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Time out of range', + `Track duration is ${formatMs(currentTrack.durationMS)}`, + ), + ], + ephemeral: true, + }, + }) + return + } + + queue?.node.seek(targetMs) + + const formattedTime = formatMs(targetMs) + const trackEmbed = buildCommandTrackEmbed(currentTrack, `⏩ Seeked to ${formattedTime}`, interaction.user) + await interactionReply({ interaction, content: { embeds: [trackEmbed] } }) + }, +}) diff --git a/packages/bot/src/functions/music/commands/skipto.spec.ts b/packages/bot/src/functions/music/commands/skipto.spec.ts new file mode 100644 index 000000000..af41678b4 --- /dev/null +++ b/packages/bot/src/functions/music/commands/skipto.spec.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import skiptoCommand from './skipto' + +const requireQueueMock = jest.fn() +const requireVoiceChannelMock = jest.fn() +const interactionReplyMock = jest.fn() +const createErrorEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc })) +const createSuccessEmbedMock = jest.fn((title: string, desc?: string) => ({ title, description: desc })) +const buildCommandTrackEmbedMock = jest.fn(() => ({})) +const resolveGuildQueueMock = jest.fn() + +jest.mock('../../../utils/command/commandValidations', () => ({ + requireQueue: (...args: unknown[]) => requireQueueMock(...args), + requireVoiceChannel: (...args: unknown[]) => requireVoiceChannelMock(...args), +})) + +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: (...args: unknown[]) => interactionReplyMock(...args), +})) + +jest.mock('../../../utils/general/embeds', () => ({ + createErrorEmbed: (...args: unknown[]) => createErrorEmbedMock(...args), + createSuccessEmbed: (...args: unknown[]) => createSuccessEmbedMock(...args), +})) + +jest.mock('../../../utils/general/responseEmbeds', () => ({ + buildCommandTrackEmbed: (...args: unknown[]) => buildCommandTrackEmbedMock(...args), +})) + +jest.mock('../../../utils/music/queueResolver', () => ({ + resolveGuildQueue: (...args: unknown[]) => resolveGuildQueueMock(...args), +})) + +function createInteraction(guildId = 'guild-1', position = 2) { + return { + guildId, + user: { username: 'tester', displayAvatarURL: jest.fn().mockReturnValue('http://avatar') }, + options: { + getInteger: jest.fn().mockReturnValue(position), + }, + } as any +} + +function createQueue(tracks: unknown[] = []) { + return { + node: { + skipTo: jest.fn(), + }, + tracks: { + size: tracks.length, + toArray: jest.fn().mockReturnValue(tracks), + }, + } as any +} + +function createClient() { + return {} as any +} + +describe('skipto command', () => { + beforeEach(() => { + jest.clearAllMocks() + requireVoiceChannelMock.mockResolvedValue(true) + requireQueueMock.mockResolvedValue(true) + buildCommandTrackEmbedMock.mockReturnValue({}) + }) + + it('returns early when voice channel validation fails', async () => { + requireVoiceChannelMock.mockResolvedValue(false) + const queue = createQueue([{ id: 'track-1' }]) + resolveGuildQueueMock.mockReturnValue({ queue }) + + await skiptoCommand.execute({ client: createClient(), interaction: createInteraction() } as any) + + expect(queue.node.skipTo).not.toHaveBeenCalled() + }) + + it('skips to specified position', async () => { + const tracks = [ + { id: 'track-1', title: 'Song 1' }, + { id: 'track-2', title: 'Song 2' }, + { id: 'track-3', title: 'Song 3' }, + ] + const queue = createQueue(tracks) + resolveGuildQueueMock.mockReturnValue({ queue }) + + const interaction = createInteraction('guild-1', 2) + await skiptoCommand.execute({ client: createClient(), interaction } as any) + + expect(queue.node.skipTo).toHaveBeenCalledWith(1) + expect(buildCommandTrackEmbedMock).toHaveBeenCalledWith(tracks[1], '⏭️ Now playing (position 2)', expect.any(Object)) + }) + + it('shows error when position exceeds queue size', async () => { + const tracks = [ + { id: 'track-1', title: 'Song 1' }, + { id: 'track-2', title: 'Song 2' }, + ] + const queue = createQueue(tracks) + resolveGuildQueueMock.mockReturnValue({ queue }) + + const interaction = createInteraction('guild-1', 5) + await skiptoCommand.execute({ client: createClient(), interaction } as any) + + expect(queue.node.skipTo).not.toHaveBeenCalled() + expect(createErrorEmbedMock).toHaveBeenCalledWith('Invalid position', expect.any(String)) + }) + + it('accepts position 1 for first track in queue', async () => { + const tracks = [ + { id: 'track-1', title: 'Song 1' }, + { id: 'track-2', title: 'Song 2' }, + ] + const queue = createQueue(tracks) + resolveGuildQueueMock.mockReturnValue({ queue }) + + const interaction = createInteraction('guild-1', 1) + await skiptoCommand.execute({ client: createClient(), interaction } as any) + + expect(queue.node.skipTo).toHaveBeenCalledWith(0) + }) +}) diff --git a/packages/bot/src/functions/music/commands/skipto.ts b/packages/bot/src/functions/music/commands/skipto.ts new file mode 100644 index 000000000..62bdca05b --- /dev/null +++ b/packages/bot/src/functions/music/commands/skipto.ts @@ -0,0 +1,73 @@ +import { SlashCommandBuilder } from '@discordjs/builders' +import Command from '../../../models/Command' +import { interactionReply } from "../../../utils/general/interactionReply" +import type { CommandExecuteParams } from "../../../types/CommandData" +import { + requireQueue, + requireVoiceChannel, +} from "../../../utils/command/commandValidations" +import { resolveGuildQueue } from '../../../utils/music/queueResolver' +import { createSuccessEmbed, createErrorEmbed } from '../../../utils/general/embeds' +import { buildCommandTrackEmbed } from '../../../utils/general/responseEmbeds' + +export default new Command({ + data: new SlashCommandBuilder() + .setName('skipto') + .setDescription('⏭️ Skip to a track in the queue by position.') + .addIntegerOption((option) => + option + .setName('position') + .setDescription('Position in queue (1 is next track)') + .setRequired(true) + .setMinValue(1), + ), + category: 'music', + execute: async ({ client, interaction }: CommandExecuteParams) => { + if (!(await requireVoiceChannel(interaction))) return + + const { queue } = resolveGuildQueue(client, interaction.guildId ?? '') + + if (!(await requireQueue(queue, interaction))) return + + const position = interaction.options.getInteger('position', true) + const queueSize = queue?.tracks.size ?? 0 + + if (position > queueSize + 1) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Invalid position', + `Queue has ${queueSize} tracks. Position must be between 1 and ${queueSize + 1}.`, + ), + ], + ephemeral: true, + }, + }) + return + } + + const targetIndex = position - 1 + queue?.node.skipTo(targetIndex) + + const targetTrack = queue?.tracks.toArray?.()?.[targetIndex] + if (!targetTrack) { + await interactionReply({ + interaction, + content: { + embeds: [ + createSuccessEmbed( + '⏭️ Skipped', + `Skipped to position ${position}.`, + ), + ], + }, + }) + return + } + + const trackEmbed = buildCommandTrackEmbed(targetTrack, `⏭️ Now playing (position ${position})`, interaction.user) + await interactionReply({ interaction, content: { embeds: [trackEmbed] } }) + }, +}) From 69c581ead9d8d19a38f40b44fc74ea37f9c0a2ce Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 10 Apr 2026 13:48:51 -0300 Subject: [PATCH 2/3] fix(bot): extract shared play utilities and improve test coverage - creates play/queryutils.ts with discord_unknown_interaction_code, isunknowninteractionerror, isurl, and resolvesearchengine - removes duplicate utilities from playtop.ts and playskip.ts - adds comprehensive execute logic tests for playtop and playskip - increases coverage from 2 tests (command structure) to 12+ tests covering error cases, queue operations, and success scenarios - seek.spec.ts already has full test coverage --- .../functions/music/commands/play/index.ts | 35 +--- .../music/commands/play/queryUtils.ts | 30 +++ .../functions/music/commands/playskip.spec.ts | 188 +++++++++++++++++- .../src/functions/music/commands/playskip.ts | 26 +-- .../functions/music/commands/playtop.spec.ts | 185 ++++++++++++++++- .../src/functions/music/commands/playtop.ts | 26 +-- 6 files changed, 411 insertions(+), 79 deletions(-) create mode 100644 packages/bot/src/functions/music/commands/play/queryUtils.ts diff --git a/packages/bot/src/functions/music/commands/play/index.ts b/packages/bot/src/functions/music/commands/play/index.ts index 11dc21049..2c21fc345 100644 --- a/packages/bot/src/functions/music/commands/play/index.ts +++ b/packages/bot/src/functions/music/commands/play/index.ts @@ -19,35 +19,12 @@ import { } from '../../../../utils/music/queueManipulation' import { buildPlayResponseEmbed } from '../../../../utils/music/nowPlayingEmbed' import { createMusicControlButtons } from '../../../../utils/music/buttonComponents' - -const DISCORD_UNKNOWN_INTERACTION_CODE = 10062 - -function isUnknownInteractionError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code?: number }).code === DISCORD_UNKNOWN_INTERACTION_CODE - ) -} - -function isUrl(query: string): boolean { - return query.startsWith('http://') || query.startsWith('https://') -} - -function resolveSearchEngine(query: string, provider?: string | null): QueryType { - if (isUrl(query)) return QueryType.AUTO - - switch (provider) { - case 'youtube': - return QueryType.YOUTUBE_SEARCH - case 'soundcloud': - return QueryType.SOUNDCLOUD_SEARCH - case 'spotify': - default: - return QueryType.SPOTIFY_SEARCH - } -} +import { + DISCORD_UNKNOWN_INTERACTION_CODE, + isUnknownInteractionError, + isUrl, + resolveSearchEngine, +} from './queryUtils' function isTrackAlreadyQueued( queue: { tracks: { toArray?: () => Array<{ id?: string; url?: string }> } }, diff --git a/packages/bot/src/functions/music/commands/play/queryUtils.ts b/packages/bot/src/functions/music/commands/play/queryUtils.ts new file mode 100644 index 000000000..08d2fff55 --- /dev/null +++ b/packages/bot/src/functions/music/commands/play/queryUtils.ts @@ -0,0 +1,30 @@ +import { QueryType } from 'discord-player' + +export const DISCORD_UNKNOWN_INTERACTION_CODE = 10062 + +export function isUnknownInteractionError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: number }).code === DISCORD_UNKNOWN_INTERACTION_CODE + ) +} + +export function isUrl(query: string): boolean { + return query.startsWith('http://') || query.startsWith('https://') +} + +export function resolveSearchEngine(query: string, provider?: string | null): QueryType { + if (isUrl(query)) return QueryType.AUTO + + switch (provider) { + case 'youtube': + return QueryType.YOUTUBE_SEARCH + case 'soundcloud': + return QueryType.SOUNDCLOUD_SEARCH + case 'spotify': + default: + return QueryType.SPOTIFY_SEARCH + } +} diff --git a/packages/bot/src/functions/music/commands/playskip.spec.ts b/packages/bot/src/functions/music/commands/playskip.spec.ts index a16eed775..131948b90 100644 --- a/packages/bot/src/functions/music/commands/playskip.spec.ts +++ b/packages/bot/src/functions/music/commands/playskip.spec.ts @@ -1,21 +1,26 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals' +// Mock discord-player +jest.mock('discord-player', () => ({ + QueryType: { AUTO: 'AUTO', SPOTIFY_SEARCH: 'SPOTIFY_SEARCH', YOUTUBE_SEARCH: 'YOUTUBE_SEARCH' }, +})) + // Mock dependencies before importing the command jest.mock('../../../utils/general/interactionReply', () => ({ interactionReply: jest.fn(), })) jest.mock('../../../utils/general/embeds', () => ({ - createErrorEmbed: jest.fn(), + createErrorEmbed: jest.fn((title: string, desc?: string) => ({ title, description: desc })), errorEmbed: jest.fn(), })) jest.mock('../../../utils/music/nowPlayingEmbed', () => ({ - buildPlayResponseEmbed: jest.fn(), + buildPlayResponseEmbed: jest.fn(() => ({ test: 'embed' })), })) jest.mock('../../../utils/music/buttonComponents', () => ({ - createMusicControlButtons: jest.fn(), + createMusicControlButtons: jest.fn(() => ({ test: 'button' })), })) jest.mock('../../../utils/music/queueResolver', () => ({ @@ -23,7 +28,7 @@ jest.mock('../../../utils/music/queueResolver', () => ({ })) jest.mock('../../../utils/general/errorSanitizer', () => ({ - createUserFriendlyError: jest.fn(), + createUserFriendlyError: jest.fn((error) => 'User friendly error'), })) jest.mock('../../../utils/command/commandValidations', () => ({ @@ -44,6 +49,41 @@ jest.mock('@lucky/shared/utils', () => ({ })) import playSkipCommand from './playskip' +import { interactionReply } from '../../../utils/general/interactionReply' +import { buildPlayResponseEmbed } from '../../../utils/music/nowPlayingEmbed' +import { createMusicControlButtons } from '../../../utils/music/buttonComponents' +import { resolveGuildQueue } from '../../../utils/music/queueResolver' +import { requireVoiceChannel } from '../../../utils/command/commandValidations' +import { debugLog, errorLog } from '@lucky/shared/utils' + +function createMockInteraction(overrides = {}) { + return { + guildId: 'guild-1', + user: { id: 'user-1', username: 'testuser' }, + member: { voice: { channel: { id: 'voice-1' } } }, + options: { getString: jest.fn().mockReturnValue('test query') }, + deferReply: jest.fn(), + reply: jest.fn(), + ...overrides, + } +} + +function createMockClient() { + return { + player: { + play: jest.fn(), + }, + } +} + +function createMockQueue(overrides = {}) { + return { + tracks: { toArray: jest.fn().mockReturnValue([]) }, + node: { remove: jest.fn(), skip: jest.fn() }, + insertTrack: jest.fn(), + ...overrides, + } +} describe('playskip command', () => { beforeEach(() => { @@ -62,4 +102,144 @@ describe('playskip command', () => { expect(queryOption).toBeDefined() expect(queryOption?.required).toBe(true) }) + + it('replies with error when guildId is missing', async () => { + const interaction = createMockInteraction({ guildId: null }) + + await playSkipCommand.execute({ client: createMockClient(), interaction } as any) + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.any(Array), + }), + ) + }) + + it('returns early when requireVoiceChannel fails', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(false) + const client = createMockClient() + const interaction = createMockInteraction() + + await playSkipCommand.execute({ client, interaction } as any) + + expect(client.player.play).not.toHaveBeenCalled() + }) + + it('calls player.play with correct query and search engine', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const queue = createMockQueue() + const interaction = createMockInteraction({ options: { getString: jest.fn().mockReturnValue('test query') } }) + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue }) + + const mockTrack = { id: 'track-1', title: 'Test Track' } + client.player.play.mockResolvedValueOnce({ + track: mockTrack, + searchResult: { playlist: null, tracks: [] }, + }) + + await playSkipCommand.execute({ client, interaction } as any) + + expect(client.player.play).toHaveBeenCalledWith( + interaction.member.voice.channel, + 'test query', + expect.any(Object), + ) + }) + + it('inserts track at position 0 AND calls skip when queue has tracks', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const existingTrack = { id: 'track-existing', title: 'Existing' } + const queue = createMockQueue({ + tracks: { toArray: jest.fn().mockReturnValue([existingTrack]) }, + }) + const interaction = createMockInteraction() + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue }) + + const mockTrack = { id: 'track-1', title: 'Test Track' } + client.player.play.mockResolvedValueOnce({ + track: mockTrack, + searchResult: { playlist: null, tracks: [] }, + }) + + await playSkipCommand.execute({ client, interaction } as any) + + expect(queue.node.remove).toHaveBeenCalledWith(mockTrack) + expect(queue.insertTrack).toHaveBeenCalledWith(mockTrack, 0) + expect(queue.node.skip).toHaveBeenCalled() + }) + + it('does NOT skip when queue is empty', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const queue = createMockQueue({ + tracks: { toArray: jest.fn().mockReturnValue([]) }, + }) + const interaction = createMockInteraction() + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue }) + + const mockTrack = { id: 'track-1', title: 'Test Track' } + client.player.play.mockResolvedValueOnce({ + track: mockTrack, + searchResult: { playlist: null, tracks: [] }, + }) + + await playSkipCommand.execute({ client, interaction } as any) + + expect(queue.node.remove).not.toHaveBeenCalled() + expect(queue.insertTrack).not.toHaveBeenCalled() + expect(queue.node.skip).not.toHaveBeenCalled() + }) + + it('shows nowPlaying embed on success', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const existingTrack = { id: 'track-existing', title: 'Existing' } + const queue = createMockQueue({ + tracks: { toArray: jest.fn().mockReturnValue([existingTrack]) }, + }) + const interaction = createMockInteraction() + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue }) + + const mockTrack = { id: 'track-1', title: 'Test Track' } + client.player.play.mockResolvedValueOnce({ + track: mockTrack, + searchResult: { playlist: null, tracks: [] }, + }) + + await playSkipCommand.execute({ client, interaction } as any) + + expect(buildPlayResponseEmbed).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'nowPlaying', + track: mockTrack, + requestedBy: interaction.user, + }), + ) + expect(interactionReply).toHaveBeenCalled() + }) + + it('shows error embed when player.play throws', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const interaction = createMockInteraction() + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue: null }) + + client.player.play.mockRejectedValueOnce(new Error('Play failed')) + + await playSkipCommand.execute({ client, interaction } as any) + + expect(interactionReply).toHaveBeenCalledWith( + expect.objectContaining({ + interaction, + content: expect.objectContaining({ + ephemeral: true, + embeds: expect.any(Array), + }), + }), + ) + expect(errorLog).toHaveBeenCalled() + }) }) diff --git a/packages/bot/src/functions/music/commands/playskip.ts b/packages/bot/src/functions/music/commands/playskip.ts index a25102bbc..9a4e266d6 100644 --- a/packages/bot/src/functions/music/commands/playskip.ts +++ b/packages/bot/src/functions/music/commands/playskip.ts @@ -10,27 +10,11 @@ import { createUserFriendlyError } from '../../../utils/general/errorSanitizer' import { resolveGuildQueue } from '../../../utils/music/queueResolver' import { buildPlayResponseEmbed } from '../../../utils/music/nowPlayingEmbed' import { createMusicControlButtons } from '../../../utils/music/buttonComponents' -import { QueryType } from 'discord-player' - -const DISCORD_UNKNOWN_INTERACTION_CODE = 10062 - -function isUnknownInteractionError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code?: number }).code === DISCORD_UNKNOWN_INTERACTION_CODE - ) -} - -function isUrl(query: string): boolean { - return query.startsWith('http://') || query.startsWith('https://') -} - -function resolveSearchEngine(query: string): QueryType { - if (isUrl(query)) return QueryType.AUTO - return QueryType.SPOTIFY_SEARCH -} +import { + isUnknownInteractionError, + isUrl, + resolveSearchEngine, +} from './play/queryUtils' export default new Command({ data: new SlashCommandBuilder() diff --git a/packages/bot/src/functions/music/commands/playtop.spec.ts b/packages/bot/src/functions/music/commands/playtop.spec.ts index 5162c53c1..3172d7da9 100644 --- a/packages/bot/src/functions/music/commands/playtop.spec.ts +++ b/packages/bot/src/functions/music/commands/playtop.spec.ts @@ -1,21 +1,26 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals' +// Mock discord-player +jest.mock('discord-player', () => ({ + QueryType: { AUTO: 'AUTO', SPOTIFY_SEARCH: 'SPOTIFY_SEARCH', YOUTUBE_SEARCH: 'YOUTUBE_SEARCH' }, +})) + // Mock dependencies before importing the command jest.mock('../../../utils/general/interactionReply', () => ({ interactionReply: jest.fn(), })) jest.mock('../../../utils/general/embeds', () => ({ - createErrorEmbed: jest.fn(), + createErrorEmbed: jest.fn((title: string, desc?: string) => ({ title, description: desc })), errorEmbed: jest.fn(), })) jest.mock('../../../utils/music/nowPlayingEmbed', () => ({ - buildPlayResponseEmbed: jest.fn(), + buildPlayResponseEmbed: jest.fn(() => ({ test: 'embed' })), })) jest.mock('../../../utils/music/buttonComponents', () => ({ - createMusicControlButtons: jest.fn(), + createMusicControlButtons: jest.fn(() => ({ test: 'button' })), })) jest.mock('../../../utils/music/queueResolver', () => ({ @@ -23,7 +28,7 @@ jest.mock('../../../utils/music/queueResolver', () => ({ })) jest.mock('../../../utils/general/errorSanitizer', () => ({ - createUserFriendlyError: jest.fn(), + createUserFriendlyError: jest.fn((error) => 'User friendly error'), })) jest.mock('../../../utils/command/commandValidations', () => ({ @@ -44,6 +49,41 @@ jest.mock('@lucky/shared/utils', () => ({ })) import playTopCommand from './playtop' +import { interactionReply } from '../../../utils/general/interactionReply' +import { buildPlayResponseEmbed } from '../../../utils/music/nowPlayingEmbed' +import { createMusicControlButtons } from '../../../utils/music/buttonComponents' +import { resolveGuildQueue } from '../../../utils/music/queueResolver' +import { requireVoiceChannel } from '../../../utils/command/commandValidations' +import { debugLog, errorLog } from '@lucky/shared/utils' + +function createMockInteraction(overrides = {}) { + return { + guildId: 'guild-1', + user: { id: 'user-1', username: 'testuser' }, + member: { voice: { channel: { id: 'voice-1' } } }, + options: { getString: jest.fn().mockReturnValue('test query') }, + deferReply: jest.fn(), + reply: jest.fn(), + ...overrides, + } +} + +function createMockClient() { + return { + player: { + play: jest.fn(), + }, + } +} + +function createMockQueue(overrides = {}) { + return { + tracks: { toArray: jest.fn().mockReturnValue([]) }, + node: { remove: jest.fn() }, + insertTrack: jest.fn(), + ...overrides, + } +} describe('playtop command', () => { beforeEach(() => { @@ -62,4 +102,141 @@ describe('playtop command', () => { expect(queryOption).toBeDefined() expect(queryOption?.required).toBe(true) }) + + it('replies with error when guildId is missing', async () => { + const interaction = createMockInteraction({ guildId: null }) + + await playTopCommand.execute({ client: createMockClient(), interaction } as any) + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.any(Array), + }), + ) + }) + + it('returns early when requireVoiceChannel fails', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(false) + const client = createMockClient() + const interaction = createMockInteraction() + + await playTopCommand.execute({ client, interaction } as any) + + expect(client.player.play).not.toHaveBeenCalled() + }) + + it('calls player.play with correct query and search engine', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const queue = createMockQueue() + const interaction = createMockInteraction({ options: { getString: jest.fn().mockReturnValue('spotify:track:abc') } }) + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue }) + + const mockTrack = { id: 'track-1', title: 'Test Track' } + client.player.play.mockResolvedValueOnce({ + track: mockTrack, + searchResult: { playlist: null, tracks: [] }, + }) + + await playTopCommand.execute({ client, interaction } as any) + + expect(client.player.play).toHaveBeenCalledWith( + interaction.member.voice.channel, + 'spotify:track:abc', + expect.any(Object), + ) + }) + + it('inserts track at position 0 when queue has tracks', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const existingTrack = { id: 'track-existing', title: 'Existing' } + const queue = createMockQueue({ + tracks: { toArray: jest.fn().mockReturnValue([existingTrack]) }, + }) + const interaction = createMockInteraction() + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue }) + + const mockTrack = { id: 'track-1', title: 'Test Track' } + client.player.play.mockResolvedValueOnce({ + track: mockTrack, + searchResult: { playlist: null, tracks: [] }, + }) + + await playTopCommand.execute({ client, interaction } as any) + + expect(queue.node.remove).toHaveBeenCalledWith(mockTrack) + expect(queue.insertTrack).toHaveBeenCalledWith(mockTrack, 0) + }) + + it('does NOT reposition track when queue is empty', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const queue = createMockQueue({ + tracks: { toArray: jest.fn().mockReturnValue([]) }, + }) + const interaction = createMockInteraction() + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue }) + + const mockTrack = { id: 'track-1', title: 'Test Track' } + client.player.play.mockResolvedValueOnce({ + track: mockTrack, + searchResult: { playlist: null, tracks: [] }, + }) + + await playTopCommand.execute({ client, interaction } as any) + + expect(queue.node.remove).not.toHaveBeenCalled() + expect(queue.insertTrack).not.toHaveBeenCalled() + }) + + it('shows error embed when player.play throws', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const interaction = createMockInteraction() + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue: null }) + + client.player.play.mockRejectedValueOnce(new Error('Play failed')) + + await playTopCommand.execute({ client, interaction } as any) + + expect(interactionReply).toHaveBeenCalledWith( + expect.objectContaining({ + interaction, + content: expect.objectContaining({ + ephemeral: true, + embeds: expect.any(Array), + }), + }), + ) + expect(errorLog).toHaveBeenCalled() + }) + + it('shows nowPlaying embed on success', async () => { + (requireVoiceChannel as jest.Mock).mockResolvedValueOnce(true) + const client = createMockClient() + const queue = createMockQueue({ + tracks: { toArray: jest.fn().mockReturnValue([]) }, + }) + const interaction = createMockInteraction() + ;(resolveGuildQueue as jest.Mock).mockReturnValue({ queue }) + + const mockTrack = { id: 'track-1', title: 'Test Track' } + client.player.play.mockResolvedValueOnce({ + track: mockTrack, + searchResult: { playlist: null, tracks: [] }, + }) + + await playTopCommand.execute({ client, interaction } as any) + + expect(buildPlayResponseEmbed).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'addedToQueue', + track: mockTrack, + queuePosition: 1, + }), + ) + expect(interactionReply).toHaveBeenCalled() + }) }) diff --git a/packages/bot/src/functions/music/commands/playtop.ts b/packages/bot/src/functions/music/commands/playtop.ts index f875b302a..b44712595 100644 --- a/packages/bot/src/functions/music/commands/playtop.ts +++ b/packages/bot/src/functions/music/commands/playtop.ts @@ -11,27 +11,11 @@ import { createUserFriendlyError } from '../../../utils/general/errorSanitizer' import { resolveGuildQueue } from '../../../utils/music/queueResolver' import { buildPlayResponseEmbed } from '../../../utils/music/nowPlayingEmbed' import { createMusicControlButtons } from '../../../utils/music/buttonComponents' -import { QueryType } from 'discord-player' - -const DISCORD_UNKNOWN_INTERACTION_CODE = 10062 - -function isUnknownInteractionError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code?: number }).code === DISCORD_UNKNOWN_INTERACTION_CODE - ) -} - -function isUrl(query: string): boolean { - return query.startsWith('http://') || query.startsWith('https://') -} - -function resolveSearchEngine(query: string): QueryType { - if (isUrl(query)) return QueryType.AUTO - return QueryType.SPOTIFY_SEARCH -} +import { + isUnknownInteractionError, + isUrl, + resolveSearchEngine, +} from './play/queryUtils' export default new Command({ data: new SlashCommandBuilder() From 893c909b89cfd7cec23028aa2b1716d2bc8f9f3d Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 10 Apr 2026 13:57:09 -0300 Subject: [PATCH 3/3] refactor: extract shared play-at-top logic to reduce duplication --- .../music/commands/play/queryUtils.ts | 113 ++++++++++++++ .../src/functions/music/commands/playskip.ts | 146 +---------------- .../src/functions/music/commands/playtop.ts | 147 +----------------- 3 files changed, 125 insertions(+), 281 deletions(-) diff --git a/packages/bot/src/functions/music/commands/play/queryUtils.ts b/packages/bot/src/functions/music/commands/play/queryUtils.ts index 08d2fff55..0373fb61c 100644 --- a/packages/bot/src/functions/music/commands/play/queryUtils.ts +++ b/packages/bot/src/functions/music/commands/play/queryUtils.ts @@ -1,4 +1,14 @@ import { QueryType } from 'discord-player' +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js' +import type { CustomClient } from '../../../../types' +import { requireVoiceChannel } from '../../../../utils/command/commandValidations' +import { resolveGuildQueue } from '../../../../utils/music/queueResolver' +import { buildPlayResponseEmbed } from '../../../../utils/music/nowPlayingEmbed' +import { createMusicControlButtons } from '../../../../utils/music/buttonComponents' +import { createErrorEmbed } from '../../../../utils/general/embeds' +import { interactionReply } from '../../../../utils/general/interactionReply' +import { createUserFriendlyError } from '../../../../utils/general/errorSanitizer' +import { errorLog, debugLog, warnLog } from '@lucky/shared/utils' export const DISCORD_UNKNOWN_INTERACTION_CODE = 10062 @@ -28,3 +38,106 @@ export function resolveSearchEngine(query: string, provider?: string | null): Qu return QueryType.SPOTIFY_SEARCH } } + +type PlayAtTopOptions = { + client: CustomClient + interaction: ChatInputCommandInteraction + skipCurrent: boolean + commandName: string +} + +export async function executePlayAtTop({ + client, + interaction, + skipCurrent, + commandName, +}: PlayAtTopOptions): Promise { + if (!interaction.guildId) { + await interaction.reply({ + embeds: [createErrorEmbed('Error', 'This command can only be used in a server')], + ephemeral: true, + }) + return + } + + const member = interaction.member as GuildMember + if (!(await requireVoiceChannel(interaction))) return + + const voiceChannel = member.voice.channel! + + try { + await interaction.deferReply() + } catch (error) { + if (isUnknownInteractionError(error)) return + throw error + } + + const query = interaction.options.getString('query', true) + + try { + const searchEngine = resolveSearchEngine(query) + const result = await client.player.play(voiceChannel, query, { searchEngine }) + const track = result.track + + const { queue } = resolveGuildQueue(client, interaction.guildId) + if (!queue) { + await interactionReply({ + interaction, + content: { + embeds: [createErrorEmbed('Error', 'Could not create queue')], + ephemeral: true, + }, + }) + return + } + + const tracks = queue.tracks.toArray() + if (tracks.length > 0) { + queue.node.remove(track) + queue.insertTrack(track, 0) + if (skipCurrent) queue.node.skip() + } + + const embed = buildPlayResponseEmbed( + skipCurrent + ? { kind: 'nowPlaying', track, requestedBy: interaction.user } + : { kind: 'addedToQueue', track, requestedBy: interaction.user, queuePosition: 1 }, + ) + + await interactionReply({ + interaction, + content: { embeds: [embed], components: [createMusicControlButtons(queue)] }, + }) + + debugLog({ + message: skipCurrent ? 'track added to top and current skipped' : 'track added to top of queue', + data: { query, guildId: interaction.guildId }, + }) + } catch (error) { + if (isUnknownInteractionError(error)) { + debugLog({ + message: `${commandName} interaction expired before reply`, + data: { query, guildId: interaction.guildId }, + }) + return + } + + errorLog({ message: `${commandName} error:`, error, data: { query, guildId: interaction.guildId } }) + + try { + await interactionReply({ + interaction, + content: { + embeds: [createErrorEmbed('Play Error', createUserFriendlyError(error))], + ephemeral: true, + }, + }) + } catch (replyError) { + warnLog({ + message: `failed to send ${commandName} error reply`, + error: replyError, + data: { guildId: interaction.guildId }, + }) + } + } +} diff --git a/packages/bot/src/functions/music/commands/playskip.ts b/packages/bot/src/functions/music/commands/playskip.ts index 9a4e266d6..97423ee2e 100644 --- a/packages/bot/src/functions/music/commands/playskip.ts +++ b/packages/bot/src/functions/music/commands/playskip.ts @@ -1,154 +1,20 @@ import { SlashCommandBuilder } from '@discordjs/builders' -import type { GuildMember, ChatInputCommandInteraction } from 'discord.js' -import { requireVoiceChannel } from '../../../utils/command/commandValidations' -import type { CommandExecuteParams } from '../../../types/CommandData' import Command from '../../../models/Command' -import { errorLog, debugLog, warnLog } from '@lucky/shared/utils' -import { createErrorEmbed } from '../../../utils/general/embeds' -import { interactionReply } from '../../../utils/general/interactionReply' -import { createUserFriendlyError } from '../../../utils/general/errorSanitizer' -import { resolveGuildQueue } from '../../../utils/music/queueResolver' -import { buildPlayResponseEmbed } from '../../../utils/music/nowPlayingEmbed' -import { createMusicControlButtons } from '../../../utils/music/buttonComponents' -import { - isUnknownInteractionError, - isUrl, - resolveSearchEngine, -} from './play/queryUtils' +import type { CommandExecuteParams } from '../../../types/CommandData' +import { executePlayAtTop } from './play/queryUtils' export default new Command({ data: new SlashCommandBuilder() .setName('playskip') - .setDescription( - 'Add a track to the top of the queue and skip the current track', - ) + .setDescription('Add a track to the top of the queue and skip the current track') .addStringOption((option) => option .setName('query') - .setDescription( - 'Song name, artist, YouTube URL, or Spotify URL', - ) + .setDescription('Song name, artist, YouTube URL, or Spotify URL') .setRequired(true), ), category: 'music', - execute: async ({ - client, - interaction, - }: CommandExecuteParams): Promise => { - if (!interaction.guildId) { - await interaction.reply({ - embeds: [ - createErrorEmbed( - 'Error', - 'This command can only be used in a server', - ), - ], - ephemeral: true, - }) - return - } - - const member = interaction.member as GuildMember - if (!(await requireVoiceChannel(interaction))) return - - const voiceChannel = member.voice.channel! - - try { - await interaction.deferReply() - } catch (error) { - if (isUnknownInteractionError(error)) return - throw error - } - - const query = interaction.options.getString('query', true) - - try { - const searchEngine = resolveSearchEngine(query) - let result = await client.player.play(voiceChannel, query, { - searchEngine, - }) - - const track = result.track - const { queue } = resolveGuildQueue( - client, - interaction.guildId ?? '', - ) - - if (!queue) { - await interactionReply({ - interaction, - content: { - embeds: [ - createErrorEmbed( - 'Error', - 'Could not create queue', - ), - ], - ephemeral: true, - }, - }) - return - } - - const tracks = queue.tracks.toArray() - if (tracks.length > 0) { - queue.node.remove(track) - queue.insertTrack(track, 0) - queue.node.skip() - } - - const embed = buildPlayResponseEmbed({ - kind: 'nowPlaying', - track, - requestedBy: interaction.user, - }) - - const components = [createMusicControlButtons(queue)] - - await interactionReply({ - interaction, - content: { embeds: [embed], components }, - }) - - debugLog({ - message: 'Track added to top and current skipped', - data: { query, guildId: interaction.guildId }, - }) - } catch (error) { - if (isUnknownInteractionError(error)) { - debugLog({ - message: 'Playskip command interaction expired before reply', - data: { query, guildId: interaction.guildId }, - }) - return - } - - errorLog({ - message: 'Playskip command error:', - error, - data: { query, guildId: interaction.guildId }, - }) - - try { - await interactionReply({ - interaction, - content: { - embeds: [ - createErrorEmbed( - 'Play Error', - createUserFriendlyError(error), - ), - ], - ephemeral: true, - }, - }) - } catch (replyError) { - warnLog({ - message: 'Failed to send playskip command error reply', - error: replyError, - data: { guildId: interaction.guildId }, - }) - } - } + execute: async ({ client, interaction }: CommandExecuteParams): Promise => { + await executePlayAtTop({ client, interaction, skipCurrent: true, commandName: 'playskip' }) }, }) diff --git a/packages/bot/src/functions/music/commands/playtop.ts b/packages/bot/src/functions/music/commands/playtop.ts index b44712595..87a50fcff 100644 --- a/packages/bot/src/functions/music/commands/playtop.ts +++ b/packages/bot/src/functions/music/commands/playtop.ts @@ -1,155 +1,20 @@ import { SlashCommandBuilder } from '@discordjs/builders' -import type { GuildMember, ChatInputCommandInteraction } from 'discord.js' -import { requireVoiceChannel } from '../../../utils/command/commandValidations' -import type { CommandExecuteParams } from '../../../types/CommandData' -import type { CustomClient } from '../../../types' import Command from '../../../models/Command' -import { errorLog, debugLog, warnLog } from '@lucky/shared/utils' -import { createErrorEmbed } from '../../../utils/general/embeds' -import { interactionReply } from '../../../utils/general/interactionReply' -import { createUserFriendlyError } from '../../../utils/general/errorSanitizer' -import { resolveGuildQueue } from '../../../utils/music/queueResolver' -import { buildPlayResponseEmbed } from '../../../utils/music/nowPlayingEmbed' -import { createMusicControlButtons } from '../../../utils/music/buttonComponents' -import { - isUnknownInteractionError, - isUrl, - resolveSearchEngine, -} from './play/queryUtils' +import type { CommandExecuteParams } from '../../../types/CommandData' +import { executePlayAtTop } from './play/queryUtils' export default new Command({ data: new SlashCommandBuilder() .setName('playtop') - .setDescription( - 'Add a track to the top of the queue (plays next)', - ) + .setDescription('Add a track to the top of the queue (plays next)') .addStringOption((option) => option .setName('query') - .setDescription( - 'Song name, artist, YouTube URL, or Spotify URL', - ) + .setDescription('Song name, artist, YouTube URL, or Spotify URL') .setRequired(true), ), category: 'music', - execute: async ({ - client, - interaction, - }: CommandExecuteParams): Promise => { - if (!interaction.guildId) { - await interaction.reply({ - embeds: [ - createErrorEmbed( - 'Error', - 'This command can only be used in a server', - ), - ], - ephemeral: true, - }) - return - } - - const member = interaction.member as GuildMember - if (!(await requireVoiceChannel(interaction))) return - - const voiceChannel = member.voice.channel! - - try { - await interaction.deferReply() - } catch (error) { - if (isUnknownInteractionError(error)) return - throw error - } - - const query = interaction.options.getString('query', true) - - try { - const searchEngine = resolveSearchEngine(query) - let result = await client.player.play(voiceChannel, query, { - searchEngine, - }) - - const track = result.track - const { queue } = resolveGuildQueue( - client, - interaction.guildId ?? '', - ) - - if (!queue) { - await interactionReply({ - interaction, - content: { - embeds: [ - createErrorEmbed( - 'Error', - 'Could not create queue', - ), - ], - ephemeral: true, - }, - }) - return - } - - const tracks = queue.tracks.toArray() - if (tracks.length > 0) { - queue.node.remove(track) - queue.insertTrack(track, 0) - } - - const embed = buildPlayResponseEmbed({ - kind: 'addedToQueue', - track, - requestedBy: interaction.user, - queuePosition: 1, - }) - - const components = [createMusicControlButtons(queue)] - - await interactionReply({ - interaction, - content: { embeds: [embed], components }, - }) - - debugLog({ - message: 'Track added to top of queue', - data: { query, guildId: interaction.guildId }, - }) - } catch (error) { - if (isUnknownInteractionError(error)) { - debugLog({ - message: 'Playtop command interaction expired before reply', - data: { query, guildId: interaction.guildId }, - }) - return - } - - errorLog({ - message: 'Playtop command error:', - error, - data: { query, guildId: interaction.guildId }, - }) - - try { - await interactionReply({ - interaction, - content: { - embeds: [ - createErrorEmbed( - 'Play Error', - createUserFriendlyError(error), - ), - ], - ephemeral: true, - }, - }) - } catch (replyError) { - warnLog({ - message: 'Failed to send playtop command error reply', - error: replyError, - data: { guildId: interaction.guildId }, - }) - } - } + execute: async ({ client, interaction }: CommandExecuteParams): Promise => { + await executePlayAtTop({ client, interaction, skipCurrent: false, commandName: 'playtop' }) }, })