diff --git a/packages/bot/src/functions/music/commands/autoplay.spec.ts b/packages/bot/src/functions/music/commands/autoplay.spec.ts index 6f4bfabc5..17b009731 100644 --- a/packages/bot/src/functions/music/commands/autoplay.spec.ts +++ b/packages/bot/src/functions/music/commands/autoplay.spec.ts @@ -120,7 +120,6 @@ describe('autoplay command', () => { interaction, } as any) - expect(interaction.deferReply).toHaveBeenCalledTimes(1) expect(resolveGuildQueueMock).toHaveBeenCalledWith(client, 'guild-1') expect(requireQueueMock).toHaveBeenCalledWith(queue, interaction) expect(queue.setRepeatMode).toHaveBeenCalledWith( @@ -151,7 +150,6 @@ describe('autoplay command', () => { interaction, } as any) - expect(interaction.deferReply).toHaveBeenCalledTimes(1) expect(queue.setRepeatMode).toHaveBeenCalledWith(QueueRepeatMode.OFF) expect(replenishQueueMock).not.toHaveBeenCalled() expect(interactionReplyMock).toHaveBeenCalled() @@ -185,7 +183,6 @@ describe('autoplay command', () => { interaction, } as any) - expect(interaction.deferReply).toHaveBeenCalledTimes(1) expect(requireQueueMock).toHaveBeenCalledWith(null, interaction) expect(interactionReplyMock).not.toHaveBeenCalled() }) @@ -211,7 +208,6 @@ describe('autoplay command', () => { interaction, } as any) - expect(interaction.deferReply).toHaveBeenCalledTimes(1) expect(requireQueueMock).toHaveBeenCalledWith(null, interaction) expect(interactionReplyMock).not.toHaveBeenCalled() }) diff --git a/packages/bot/src/functions/music/commands/autoplay.ts b/packages/bot/src/functions/music/commands/autoplay.ts index 6233a6eb5..35047c7e8 100644 --- a/packages/bot/src/functions/music/commands/autoplay.ts +++ b/packages/bot/src/functions/music/commands/autoplay.ts @@ -144,10 +144,6 @@ export default new Command({ const guildId = interaction.guildId if (!guildId) return - if (!interaction.deferred && !interaction.replied) { - await interaction.deferReply() - } - const { queue, source, diagnostics } = resolveGuildQueue( client, guildId, diff --git a/packages/bot/src/handlers/interactionHandler.ts b/packages/bot/src/handlers/interactionHandler.ts index 4ef37d1bc..54f7df3cf 100644 --- a/packages/bot/src/handlers/interactionHandler.ts +++ b/packages/bot/src/handlers/interactionHandler.ts @@ -106,7 +106,20 @@ export async function handleInteraction( await reactionRolesService.handleButtonInteraction(interaction) } } catch (error) { - errorLog({ message: 'Error handling interaction:', error }) + const commandName = interaction.isChatInputCommand() + ? interaction.commandName + : interaction.isButton() + ? interaction.customId + : 'unknown' + errorLog({ + message: 'Error handling interaction:', + error, + data: { + commandName, + userId: interaction.user.id, + guildId: interaction.guild?.id, + }, + }) try { if ( @@ -123,8 +136,11 @@ export async function handleInteraction( }, }) } - } catch (error) { - errorLog({ message: 'Error sending error message:', error }) + } catch (replyError) { + errorLog({ + message: 'Error sending error message:', + error: replyError, + }) } } } diff --git a/packages/bot/src/utils/command/commandValidations.ts b/packages/bot/src/utils/command/commandValidations.ts index d4b412d21..40c92d9e7 100644 --- a/packages/bot/src/utils/command/commandValidations.ts +++ b/packages/bot/src/utils/command/commandValidations.ts @@ -2,7 +2,11 @@ import type { ChatInputCommandInteraction, GuildMember } from 'discord.js' import type { GuildQueue } from 'discord-player' import { errorEmbed } from '../general/embeds' import { interactionReply } from '../general/interactionReply' -import { handleError, createUserErrorMessage } from '@lucky/shared/utils' +import { + handleError, + createUserErrorMessage, + warnLog, +} from '@lucky/shared/utils' export async function requireGuild( interaction: ChatInputCommandInteraction, @@ -32,18 +36,22 @@ export async function requireVoiceChannel( ): Promise { const member = interaction.member as GuildMember if (!member?.voice?.channel) { - const error = handleError( - new Error('User must be in a voice channel'), - { - guildId: interaction.guildId ?? undefined, + warnLog({ + message: 'requireVoiceChannel: user not in voice channel', + data: { + commandName: interaction.commandName, userId: interaction.user.id, + guildId: interaction.guildId ?? undefined, }, - ) + }) await interactionReply({ interaction, content: { - embeds: [errorEmbed('Error', createUserErrorMessage(error))], + embeds: [ + errorEmbed('Not in Voice', 'Join a voice channel first.'), + ], + ephemeral: true, }, }) return false @@ -56,20 +64,24 @@ export async function requireQueue( interaction: ChatInputCommandInteraction, ): Promise { if (!queue) { - const error = handleError( - new Error( - 'No active music queue found. The player may have restarted. Use /play to start a new queue.', - ), - { - guildId: interaction.guildId ?? undefined, + warnLog({ + message: 'requireQueue: no active queue', + data: { + commandName: interaction.commandName, userId: interaction.user.id, + guildId: interaction.guildId ?? undefined, }, - ) + }) await interactionReply({ interaction, content: { - embeds: [errorEmbed('Error', createUserErrorMessage(error))], + embeds: [ + errorEmbed( + 'No Queue', + 'No music is playing. Use /play to start.', + ), + ], }, }) return false @@ -82,15 +94,22 @@ export async function requireCurrentTrack( interaction: ChatInputCommandInteraction, ): Promise { if (!queue?.currentTrack) { - const error = handleError(new Error('No track is currently playing'), { - guildId: interaction.guildId ?? undefined, - userId: interaction.user.id, + warnLog({ + message: 'requireCurrentTrack: no current track', + data: { + commandName: interaction.commandName, + userId: interaction.user.id, + guildId: interaction.guildId ?? undefined, + }, }) await interactionReply({ interaction, content: { - embeds: [errorEmbed('Error', createUserErrorMessage(error))], + embeds: [ + errorEmbed('Not Playing', 'No track is currently playing.'), + ], + ephemeral: true, }, }) return false @@ -103,15 +122,22 @@ export async function requireIsPlaying( interaction: ChatInputCommandInteraction, ): Promise { if (!queue?.isPlaying()) { - const error = handleError(new Error('No music is currently playing'), { - guildId: interaction.guildId ?? undefined, - userId: interaction.user.id, + warnLog({ + message: 'requireIsPlaying: not playing', + data: { + commandName: interaction.commandName, + userId: interaction.user.id, + guildId: interaction.guildId ?? undefined, + }, }) await interactionReply({ interaction, content: { - embeds: [errorEmbed('Error', createUserErrorMessage(error))], + embeds: [ + errorEmbed('Not Playing', 'No music is currently playing.'), + ], + ephemeral: true, }, }) return false diff --git a/packages/bot/tests/handlers/interactionHandler.test.ts b/packages/bot/tests/handlers/interactionHandler.test.ts new file mode 100644 index 000000000..2c671b70d --- /dev/null +++ b/packages/bot/tests/handlers/interactionHandler.test.ts @@ -0,0 +1,303 @@ +import { createMockInteraction } from '../__mocks__/discord' + +const executeCommandMock = jest.fn() +const handleMusicButtonInteractionMock = jest.fn() +const monitorInteractionHandlingMock = jest.fn() +const errorLogMock = jest.fn() +const debugLogMock = jest.fn() +const createUserFriendlyErrorMock = jest.fn((err: unknown) => + err instanceof Error ? err.message : 'error', +) +const interactionReplyMock = jest.fn().mockResolvedValue(undefined) +const errorEmbedMock = jest.fn((_title: string, desc: string) => ({ + description: desc, +})) +const handleButtonInteractionMock = jest.fn() + +jest.mock('../../src/handlers/commandsHandler', () => ({ + executeCommand: (...args: unknown[]) => executeCommandMock(...args), +})) + +jest.mock('../../src/handlers/musicButtonHandler', () => ({ + handleMusicButtonInteraction: (...args: unknown[]) => + handleMusicButtonInteractionMock(...args), +})) + +jest.mock('../../src/utils/monitoring', () => ({ + monitorInteractionHandling: (...args: unknown[]) => + monitorInteractionHandlingMock(...args), +})) + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: (...args: unknown[]) => errorLogMock(...args), + debugLog: (...args: unknown[]) => debugLogMock(...args), +})) + +jest.mock('../../src/utils/general/errorSanitizer', () => ({ + createUserFriendlyError: (...args: unknown[]) => + createUserFriendlyErrorMock(...args), +})) + +jest.mock('../../src/utils/general/interactionReply', () => ({ + interactionReply: (...args: unknown[]) => interactionReplyMock(...args), +})) + +jest.mock('../../src/utils/general/embeds', () => ({ + errorEmbed: (...args: unknown[]) => errorEmbedMock(...args), +})) + +jest.mock('@lucky/shared/services', () => ({ + reactionRolesService: { + handleButtonInteraction: (...args: unknown[]) => + handleButtonInteractionMock(...args), + }, +})) + +import { handleInteraction } from '../../src/handlers/interactionHandler' + +function createClient() { + return {} as any +} + +function createChatInteraction(overrides: Record = {}) { + const base = createMockInteraction(overrides) + return { + ...base, + type: 2, + commandName: (overrides.commandName as string) ?? 'test', + isButton: () => false, + } as any +} + +function createButtonInteraction(customId: string) { + return { + type: 3, + user: { id: 'user-1' }, + guild: { id: 'guild-1' }, + customId, + replied: false, + deferred: false, + isChatInputCommand: () => false, + isButton: () => true, + } as any +} + +function createUnknownInteraction() { + return { + type: 5, + user: { id: 'user-1' }, + guild: { id: 'guild-1' }, + replied: false, + deferred: false, + isChatInputCommand: () => false, + isButton: () => false, + } as any +} + +describe('handleInteraction', () => { + beforeEach(() => { + jest.clearAllMocks() + executeCommandMock.mockResolvedValue(undefined) + handleMusicButtonInteractionMock.mockResolvedValue(undefined) + handleButtonInteractionMock.mockResolvedValue(undefined) + }) + + it('calls executeCommand for chat input commands', async () => { + const interaction = createChatInteraction() + const client = createClient() + + await handleInteraction(interaction, client) + + expect(executeCommandMock).toHaveBeenCalledWith({ interaction, client }) + expect(monitorInteractionHandlingMock).toHaveBeenCalled() + }) + + it('calls handleMusicButtonInteraction for music_ prefixed buttons', async () => { + const interaction = createButtonInteraction('music_play') + const client = createClient() + + await handleInteraction(interaction, client) + + expect(handleMusicButtonInteractionMock).toHaveBeenCalledWith( + interaction, + ) + expect(handleButtonInteractionMock).not.toHaveBeenCalled() + }) + + it('calls handleMusicButtonInteraction for queue_page prefixed buttons', async () => { + const interaction = createButtonInteraction('queue_page_2') + const client = createClient() + + await handleInteraction(interaction, client) + + expect(handleMusicButtonInteractionMock).toHaveBeenCalledWith( + interaction, + ) + }) + + it('calls reactionRolesService for non-music buttons', async () => { + const interaction = createButtonInteraction('role_123') + const client = createClient() + + await handleInteraction(interaction, client) + + expect(handleButtonInteractionMock).toHaveBeenCalledWith(interaction) + expect(handleMusicButtonInteractionMock).not.toHaveBeenCalled() + }) + + it('does nothing special for non-command non-button interactions', async () => { + const interaction = createUnknownInteraction() + const client = createClient() + + await handleInteraction(interaction, client) + + expect(executeCommandMock).not.toHaveBeenCalled() + expect(handleMusicButtonInteractionMock).not.toHaveBeenCalled() + expect(handleButtonInteractionMock).not.toHaveBeenCalled() + }) + + it('logs error with commandName when chat command throws', async () => { + const interaction = createChatInteraction({ commandName: 'play' }) + const client = createClient() + const err = new Error('command failed') + executeCommandMock.mockRejectedValue(err) + + await handleInteraction(interaction, client) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error handling interaction:', + error: err, + data: expect.objectContaining({ commandName: 'play' }), + }), + ) + }) + + it('logs error with customId when button interaction throws', async () => { + const interaction = createButtonInteraction('role_abc') + const client = createClient() + const err = new Error('button failed') + handleButtonInteractionMock.mockRejectedValue(err) + + await handleInteraction(interaction, client) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ commandName: 'role_abc' }), + }), + ) + }) + + it('logs error with unknown commandName for other interaction types', async () => { + const interaction = createUnknownInteraction() + const client = createClient() + + Object.defineProperty(interaction, 'isChatInputCommand', { + value: () => { + throw new Error('unexpected') + }, + }) + + // Since isChatInputCommand throws, monitorInteractionHandling fires first + // then catch block runs with interaction methods that return false + const safeInteraction = { + ...interaction, + isChatInputCommand: () => false, + isButton: () => false, + } + + // Wrap to simulate error inside the try block + const throwingInteraction = { + ...safeInteraction, + isButton: jest.fn().mockImplementationOnce(() => { + throw new Error('unexpected') + }), + } + + await handleInteraction(throwingInteraction as any, client) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error handling interaction:', + }), + ) + }) + + it('sends error reply for unhandled chat command errors when not replied', async () => { + const interaction = createChatInteraction({ commandName: 'queue' }) + const client = createClient() + const err = new Error('queue failed') + executeCommandMock.mockRejectedValue(err) + createUserFriendlyErrorMock.mockReturnValue('Queue failed') + + await handleInteraction(interaction, client) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ ephemeral: true }), + }), + ) + }) + + it('does not send error reply when chat command already replied', async () => { + const interaction = { + ...createChatInteraction({ commandName: 'play' }), + replied: true, + deferred: false, + } + const client = createClient() + executeCommandMock.mockRejectedValue(new Error('fail')) + + await handleInteraction(interaction, client) + + expect(interactionReplyMock).not.toHaveBeenCalled() + }) + + it('does not send error reply when chat command already deferred', async () => { + const interaction = { + ...createChatInteraction({ commandName: 'play' }), + replied: false, + deferred: true, + } + const client = createClient() + executeCommandMock.mockRejectedValue(new Error('fail')) + + await handleInteraction(interaction, client) + + expect(interactionReplyMock).not.toHaveBeenCalled() + }) + + it('logs replyError when error reply itself throws', async () => { + const interaction = createChatInteraction({ commandName: 'play' }) + const client = createClient() + executeCommandMock.mockRejectedValue(new Error('cmd fail')) + const replyErr = new Error('reply also failed') + interactionReplyMock.mockRejectedValue(replyErr) + + await handleInteraction(interaction, client) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error sending error message:', + error: replyErr, + }), + ) + }) + + it('includes userId and guildId in error log', async () => { + const interaction = createChatInteraction({ commandName: 'skip' }) + const client = createClient() + executeCommandMock.mockRejectedValue(new Error('skip failed')) + + await handleInteraction(interaction, client) + + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: '123456789', + guildId: '987654321', + }), + }), + ) + }) +}) diff --git a/packages/bot/tests/utils/command/commandValidations.test.ts b/packages/bot/tests/utils/command/commandValidations.test.ts index 0243de888..553b08f4d 100644 --- a/packages/bot/tests/utils/command/commandValidations.test.ts +++ b/packages/bot/tests/utils/command/commandValidations.test.ts @@ -9,6 +9,7 @@ jest.mock('@lucky/shared/utils', () => ({ code: 'TEST_ERROR', })), createUserErrorMessage: jest.fn((err: { message: string }) => err.message), + warnLog: jest.fn(), errorEmbed: jest.fn((_title: string, desc: string) => ({ description: desc, })), @@ -85,13 +86,6 @@ describe('commandValidations', () => { const interaction = createMockInteraction() const result = await requireQueue(null, interaction) expect(result).toBe(false) - expect(handleErrorMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: - 'No active music queue found. The player may have restarted. Use /play to start a new queue.', - }), - expect.any(Object), - ) expect(interactionReplyMock).toHaveBeenCalled() }) }) diff --git a/packages/shared/src/utils/error/errorWrapper.ts b/packages/shared/src/utils/error/errorWrapper.ts index 79f3699b3..f5079226c 100644 --- a/packages/shared/src/utils/error/errorWrapper.ts +++ b/packages/shared/src/utils/error/errorWrapper.ts @@ -1,13 +1,9 @@ import { v4 as uuidv4 } from 'uuid' import { errorLog } from '../general/log' import { MusicError, type MusicErrorCode } from '../../types/errors/music' -import { VALIDATION_ERROR_CODES } from '../../types/errors/validation' import { captureException } from '../monitoring' import type { ErrorContext } from './types' -type ErrorCode = - (typeof VALIDATION_ERROR_CODES)[keyof typeof VALIDATION_ERROR_CODES] - /** * Creates a correlation ID for error tracking */ @@ -20,7 +16,7 @@ export function createCorrelationId(): string { */ export function wrapError( error: unknown, - code: ErrorCode = VALIDATION_ERROR_CODES.VALIDATION_INVALID_INPUT, + code?: MusicErrorCode, context?: ErrorContext, ): MusicError { const correlationId = context?.correlationId ?? createCorrelationId() @@ -31,7 +27,7 @@ export function wrapError( const message = error instanceof Error ? error.message : String(error) - return new MusicError(message, code as MusicErrorCode, { + return new MusicError(message, code, { correlationId, userId: context?.userId, guildId: context?.guildId,