diff --git a/CHANGELOG.md b/CHANGELOG.md index a2ef0b121..c2fed92d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **play command Sentry noise reduction**: `/play` now treats `DiscordAPIError[10062]` (`Unknown interaction`) as an interaction-expired path before logging command failures, and safely exits when `deferReply` already expired. This prevents recurring false-positive production error reports for already-handled interaction expiry events. + ## [2.6.61] - 2026-04-03 ### Fixed diff --git a/packages/bot/src/functions/music/commands/play/index.spec.ts b/packages/bot/src/functions/music/commands/play/index.spec.ts index 3473b56bf..613d7ff9d 100644 --- a/packages/bot/src/functions/music/commands/play/index.spec.ts +++ b/packages/bot/src/functions/music/commands/play/index.spec.ts @@ -46,6 +46,7 @@ const moveUserTrackToPriorityMock = jest.fn<(queue: unknown, track: unknown) => void>() const blendAutoplayTracksMock = jest.fn<(queue: unknown, track: unknown) => Promise>() +const interactionReplyMock = jest.fn<(payload: unknown) => Promise>() jest.mock('discord-player', () => ({ QueueRepeatMode: { OFF: 0, AUTOPLAY: 3 }, @@ -103,6 +104,10 @@ jest.mock('../../../../utils/music/collaborativePlaylist', () => ({ }, })) +jest.mock('../../../../utils/general/interactionReply', () => ({ + interactionReply: (payload: unknown) => interactionReplyMock(payload), +})) + import playCommand from './index' function createInteraction(guildId: string | null) { @@ -185,8 +190,40 @@ describe('play command', () => { } as any) expect(interaction.deferReply).toHaveBeenCalled() - expect(interaction.editReply).toHaveBeenCalledWith( - expect.objectContaining({ embeds: expect.any(Array) }), + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + interaction, + content: expect.objectContaining({ embeds: expect.any(Array) }), + }), + ) + expect(interaction.editReply).not.toHaveBeenCalled() + }) + + it('uses interactionReply for collaborative-limit replies', async () => { + const interaction = createInteraction('guild-1') + canAddTracksMock.mockReturnValue({ + allowed: false, + limit: 1, + }) + + await playCommand.execute({ + client: createClient(async () => ({})), + interaction, + } as any) + + expect(errorLogMock).not.toHaveBeenCalled() + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + interaction, + content: expect.objectContaining({ + embeds: expect.any(Array), + }), + }), + ) + expect(debugLogMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Play command interaction expired before editReply', + }), ) }) @@ -212,7 +249,13 @@ describe('play command', () => { 'user-1', 1, ) - expect(interaction.editReply).toHaveBeenCalled() + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + interaction, + content: expect.objectContaining({ embeds: expect.any(Array) }), + }), + ) + expect(interaction.editReply).not.toHaveBeenCalled() expect(createSuccessEmbedMock).toHaveBeenCalled() expect(client.player.play).toHaveBeenCalledWith( expect.anything(), @@ -473,16 +516,57 @@ describe('play command', () => { data: expect.objectContaining({ guildId: 'guild-1' }), }), ) - expect(interaction.editReply).toHaveBeenCalled() + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + interaction, + content: expect.objectContaining({ + embeds: expect.any(Array), + }), + }), + ) + expect(interaction.editReply).not.toHaveBeenCalled() expect(createErrorEmbedMock).toHaveBeenCalledWith( 'Play Error', expect.stringContaining('Could not find'), ) }) - it('logs a warning when sending the play error reply also fails', async () => { + it('ignores unknown interaction errors thrown during deferReply', async () => { + const interaction = createInteraction('guild-1') + interaction.deferReply.mockRejectedValue( + Object.assign(new Error('Unknown interaction'), { code: 10062 }), + ) + + await playCommand.execute({ + client: createClient(async () => ({ + track: { title: 'Song A', author: 'Artist A' }, + searchResult: { playlist: null, tracks: [] }, + })), + interaction, + } as any) + + expect(errorLogMock).not.toHaveBeenCalled() + expect(interaction.editReply).not.toHaveBeenCalled() + }) + + it('ignores unknown interaction errors thrown during play flow', async () => { + const interaction = createInteraction('guild-1') + + await playCommand.execute({ + client: createClient(async () => { + throw Object.assign(new Error('Unknown interaction'), { + code: 10062, + }) + }), + interaction, + } as any) + + expect(errorLogMock).not.toHaveBeenCalled() + expect(interaction.editReply).not.toHaveBeenCalled() + }) + + it('uses interactionReply for play error replies', async () => { const interaction = createInteraction('guild-1') - interaction.editReply.mockRejectedValue(new Error('reply failed')) await playCommand.execute({ client: createClient(async () => { @@ -491,10 +575,15 @@ describe('play command', () => { interaction, } as any) - expect(warnLogMock).toHaveBeenCalledWith( + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + interaction, + content: expect.objectContaining({ embeds: expect.any(Array) }), + }), + ) + expect(warnLogMock).not.toHaveBeenCalledWith( expect.objectContaining({ message: 'Failed to send play command error reply', - data: expect.objectContaining({ guildId: 'guild-1' }), }), ) }) diff --git a/packages/bot/src/functions/music/commands/play/index.ts b/packages/bot/src/functions/music/commands/play/index.ts index dfca9e653..410954a8a 100644 --- a/packages/bot/src/functions/music/commands/play/index.ts +++ b/packages/bot/src/functions/music/commands/play/index.ts @@ -9,6 +9,7 @@ import { errorLog, debugLog, warnLog } from '@lucky/shared/utils' import { guildSettingsService } from '@lucky/shared/services' import { createErrorEmbed } from '../../../../utils/general/embeds' import { createSuccessEmbed } from '../../../../utils/general/embeds' +import { interactionReply } from '../../../../utils/general/interactionReply' import { collaborativePlaylistService } from '../../../../utils/music/collaborativePlaylist' import { QueueRepeatMode } from 'discord-player' import { resolveGuildQueue } from '../../../../utils/music/queueResolver' @@ -19,6 +20,15 @@ import { 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 isTrackAlreadyQueued( queue: { tracks: { toArray?: () => Array<{ id?: string; url?: string }> } }, track: { id?: string; url?: string }, @@ -69,7 +79,12 @@ export default new Command({ const voiceChannel = member.voice.channel! - await interaction.deferReply() + try { + await interaction.deferReply() + } catch (error) { + if (isUnknownInteractionError(error)) return + throw error + } const query = interaction.options.getString('query', true) const collaborativeCheck = collaborativePlaylistService.canAddTracks( @@ -78,13 +93,16 @@ export default new Command({ 1, ) if (!collaborativeCheck.allowed) { - await interaction.editReply({ - embeds: [ - createErrorEmbed( - 'Contribution limit reached', - `Collaborative mode limit reached (${collaborativeCheck.limit} track requests per user).`, - ), - ], + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Contribution limit reached', + `Collaborative mode limit reached (${collaborativeCheck.limit} track requests per user).`, + ), + ], + }, }) return } @@ -147,25 +165,36 @@ export default new Command({ 1, ) - await interaction.editReply({ embeds: [embed] }) + await interactionReply({ + interaction, + content: { embeds: [embed] }, + }) } catch (error) { + if (isUnknownInteractionError(error)) { + debugLog({ + message: 'Play command interaction expired before reply', + data: { query, guildId: interaction.guildId }, + }) + return + } + errorLog({ message: 'Play command error:', error, data: { query, guildId: interaction.guildId }, }) - const code = (error as { code?: number })?.code - if (code === DISCORD_UNKNOWN_INTERACTION_CODE) return - try { - await interaction.editReply({ - embeds: [ - createErrorEmbed( - 'Play Error', - 'Could not find or play the requested track', - ), - ], + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Play Error', + 'Could not find or play the requested track', + ), + ], + }, }) } catch (replyError) { warnLog({