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 9d056f370..3d126ebb7 100644 --- a/packages/bot/src/functions/music/commands/play/index.spec.ts +++ b/packages/bot/src/functions/music/commands/play/index.spec.ts @@ -77,7 +77,7 @@ jest.mock('../../../../utils/music/queueResolver', () => ({ jest.mock('../../../../utils/command/commandValidations', () => ({ requireVoiceChannel: (interaction: unknown) => requireVoiceChannelMock(interaction), - requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args) + requireDJRole: (...args: unknown[]) => requireDJRoleMock(...args), })) jest.mock('@lucky/shared/utils', () => ({ @@ -133,6 +133,12 @@ jest.mock('../../../../utils/general/errorSanitizer', () => ({ createUserFriendlyError: (error: unknown) => 'User friendly error', })) +const registerNowPlayingMessageMock = jest.fn() +jest.mock('../../../../handlers/player/trackNowPlaying', () => ({ + registerNowPlayingMessage: (...args: unknown[]) => + registerNowPlayingMessageMock(...args), +})) + import playCommand from './index' function createInteraction(guildId: string | null) { @@ -151,6 +157,9 @@ function createInteraction(guildId: string | null) { reply: jest.fn(), deferReply: jest.fn(), editReply: jest.fn(), + fetchReply: jest + .fn() + .mockResolvedValue({ id: 'msg-123', channelId: 'channel-1' }), } as any } @@ -327,7 +336,9 @@ describe('play command', () => { await playCommand.execute({ client, interaction } as any) expect(errorLogMock).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Failed to record contribution' }), + expect.objectContaining({ + message: 'Failed to record contribution', + }), ) expect(interactionReplyMock).toHaveBeenCalled() }) @@ -718,4 +729,53 @@ describe('play command', () => { }), ) }) + + it('registers interaction reply as now-playing message to prevent duplicate (queuePosition=0)', async () => { + // When the track starts immediately (no pre-existing queue), the /play + // interaction reply shows "Now Playing". Without registration, the + // playerStart handler sends a second "Now Playing" message. This test + // verifies the registration happens so the handler edits instead. + const interaction = createInteraction('guild-1') + const track = { + id: 'track-1', + url: 'https://youtube.com/watch?v=abc', + title: 'Test Song', + author: 'Test Artist', + duration: '3:30', + thumbnail: null, + requestedBy: { id: 'user-1' }, + metadata: {}, + } + + resolveGuildQueueMock.mockReturnValue({ queue: null }) // no pre-existing queue + const queue = { + tracks: { size: 0, toArray: jest.fn(() => []) }, + repeatMode: 0, + } + resolveGuildQueueMock + .mockReturnValueOnce({ queue: null }) + .mockReturnValue({ queue }) + + await playCommand.execute({ + client: createClient( + async () => ({ + track, + searchResult: { playlist: null, tracks: [track] }, + }), + { tracksSize: 0 }, + ), + interaction, + } as any) + + await flushPromises() + + // fetchReply must be called and registerNowPlayingMessage invoked with + // the reply's id and channelId — this prevents the duplicate embed. + expect(interaction.fetchReply).toHaveBeenCalled() + expect(registerNowPlayingMessageMock).toHaveBeenCalledWith( + 'guild-1', + 'msg-123', + 'channel-1', + ) + }) }) diff --git a/packages/bot/src/functions/music/commands/play/index.ts b/packages/bot/src/functions/music/commands/play/index.ts index ce4f825dd..25e5deb48 100644 --- a/packages/bot/src/functions/music/commands/play/index.ts +++ b/packages/bot/src/functions/music/commands/play/index.ts @@ -22,6 +22,7 @@ import { } from '../../../../utils/music/queueManipulation' import { buildPlayResponseEmbed } from '../../../../utils/music/nowPlayingEmbed' import { createMusicControlButtons } from '../../../../utils/music/buttonComponents' +import { registerNowPlayingMessage } from '../../../../handlers/player/trackNowPlaying' import { DISCORD_UNKNOWN_INTERACTION_CODE, isUnknownInteractionError, @@ -261,6 +262,23 @@ export default new Command({ content: { embeds: [embed], components }, }) + // When the track starts playing immediately (queuePosition === 0), + // register the interaction reply as the "now playing" message so + // the playerStart handler edits it (adding buttons) rather than + // sending a second "Now Playing" message in the channel. + if (queuePosition === 0 && interaction.guildId) { + try { + const reply = await interaction.fetchReply() + registerNowPlayingMessage( + interaction.guildId, + reply.id, + reply.channelId, + ) + } catch { + // non-critical — worst case playerStart sends a fresh message + } + } + // Start background ops (apply autoplay pref then blend) without awaiting. // This lets the response reach the user immediately. // The Promise is not awaited, allowing the command handler to return while diff --git a/packages/bot/src/handlers/player/trackNowPlaying.ts b/packages/bot/src/handlers/player/trackNowPlaying.ts index fcda9f3f7..af34e41b0 100644 --- a/packages/bot/src/handlers/player/trackNowPlaying.ts +++ b/packages/bot/src/handlers/player/trackNowPlaying.ts @@ -22,6 +22,20 @@ const songInfoMessages = new Map< string, { messageId: string; channelId: string } >() + +/** + * Register an existing message as the "now playing" display for a guild. + * Used by the /play command to pre-register its interaction reply so that + * the playerStart handler edits it (with buttons) instead of sending a + * duplicate "Now Playing" message. + */ +export function registerNowPlayingMessage( + guildId: string, + messageId: string, + channelId: string, +): void { + songInfoMessages.set(guildId, { messageId, channelId }) +} const lastFmTrackStartTime = new Map() function getLastFmRequesterId(