import { ChatInputCommandInteraction, GuildMember, SlashCommandBuilder } from 'discord.js'; import { SupportedLocale, t } from '../i18n'; import { MusicService } from '../services/MusicService'; import { logger } from '../utils/logger'; function buildErrorMessage(locale: SupportedLocale, key: string) { const message = t(locale, `commands.music.${key}`); const resolution = t(locale, `commands.music.${key}Resolution`); return resolution && resolution !== `commands.music.${key}Resolution` ? `${message}\n${resolution}` : message; } async function respond( interaction: ChatInputCommandInteraction, content: string, ephemeral = false, ) { if (interaction.deferred) { await interaction.editReply({ content }); return; } if (interaction.replied) { await interaction.followUp({ content, ephemeral }); return; } await interaction.editReply({ content }); } export default { data: new SlashCommandBuilder() .setName('music') .setDescription('Play YouTube audio in voice channels.') .setDescriptionLocalizations({ ko: '음성 채널에서 YouTube 오디오를 재생합니다.', }) .addSubcommand((subcommand) => subcommand .setName('add') .setDescription('Search YouTube or add a video URL to the queue.') .setDescriptionLocalizations({ ko: 'YouTube를 검색하거나 영상 URL을 재생 목록에 추가합니다.', }) .addStringOption((option) => option .setName('query') .setDescription('Search query for YouTube') .setDescriptionLocalizations({ ko: 'YouTube 검색어', }), ) .addStringOption((option) => option .setName('url') .setDescription('YouTube video URL') .setDescriptionLocalizations({ ko: 'YouTube 영상 URL', }), ), ) .addSubcommand((subcommand) => subcommand .setName('queue') .setDescription('Show the current music queue.') .setDescriptionLocalizations({ ko: '현재 음악 재생 목록을 표시합니다.', }), ) .addSubcommand((subcommand) => subcommand .setName('remove') .setDescription('Remove a track from the upcoming queue.') .setDescriptionLocalizations({ ko: '대기열에서 원하는 곡을 삭제합니다.', }) .addIntegerOption((option) => option .setName('index') .setDescription('Queue index to remove') .setDescriptionLocalizations({ ko: '삭제할 대기열 인덱스', }) .setRequired(true) .setMinValue(1), ), ) .addSubcommand((subcommand) => subcommand .setName('pause') .setDescription('Pause the currently playing track.') .setDescriptionLocalizations({ ko: '현재 재생 중인 곡을 일시정지합니다.', }), ) .addSubcommand((subcommand) => subcommand .setName('resume') .setDescription('Resume the paused track.') .setDescriptionLocalizations({ ko: '일시정지된 곡의 재생을 다시 시작합니다.', }), ) .addSubcommand((subcommand) => subcommand .setName('skip') .setDescription('Skip the currently playing track.') .setDescriptionLocalizations({ ko: '현재 재생 중인 곡을 건너뜁니다.', }), ) .addSubcommand((subcommand) => subcommand .setName('stop') .setDescription('Stop playback and clear the queue.') .setDescriptionLocalizations({ ko: '재생을 중지하고 대기열을 비웁니다.', }), ) .addSubcommand((subcommand) => subcommand .setName('leave') .setDescription('Disconnect the bot from the voice channel.') .setDescriptionLocalizations({ ko: '봇을 음성 채널에서 내보냅니다.', }), ), async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { const subcommand = interaction.options.getSubcommand(); try { if (subcommand === 'add') { const query = interaction.options.getString('query'); const url = interaction.options.getString('url'); if ((!query && !url) || (query && url)) { await interaction.editReply({ content: buildErrorMessage(locale, 'addMutuallyExclusive'), }); return; } const member = interaction.member as GuildMember; if (!member.voice.channel) { await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), }); return; } const textChannel = interaction.channel as any; if (!textChannel?.send) { await interaction.editReply({ content: t(locale, 'errors.E3003.userMessage'), }); return; } const result = query ? await MusicService.addFromQuery(member, textChannel, query, locale) : await MusicService.addFromUrl(member, textChannel, url!, locale); await interaction.editReply({ content: result.tracksAdded > 1 ? (result.startedNow ? t(locale, 'commands.music.playlistAddedNowPlaying', { count: String(result.tracksAdded), channel: `<#${result.voiceChannelId}>`, }) : t(locale, 'commands.music.playlistAddedLater', { count: String(result.tracksAdded), })) : (result.startedNow ? t(locale, 'commands.music.queueAddedNowPlaying', { title: result.track.title, channel: `<#${result.voiceChannelId}>`, }) : t(locale, 'commands.music.queueAddedLater', { title: result.track.title, position: String(result.position), })), }); return; } if (subcommand === 'queue') { await interaction.editReply({ embeds: [MusicService.getQueueEmbed(interaction.guildId!, locale)], }); return; } if (subcommand === 'remove') { const member = interaction.member as GuildMember; if (!member.voice.channel) { await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), }); return; } const index = interaction.options.getInteger('index', true); const removed = await MusicService.remove(interaction.guildId!, index); if (!removed) { await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), }); return; } await interaction.editReply({ content: t(locale, 'commands.music.queueRemoved', { title: removed.title, }), }); return; } if (subcommand === 'pause') { const member = interaction.member as GuildMember; if (!member.voice.channel) { await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), }); return; } const paused = await MusicService.pause(interaction.guildId!, locale); if (!paused) { await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), }); return; } await interaction.editReply({ content: t(locale, 'commands.music.pauseSuccess') }); return; } if (subcommand === 'resume') { const member = interaction.member as GuildMember; if (!member.voice.channel) { await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), }); return; } const resumed = await MusicService.resume(interaction.guildId!, locale); if (!resumed) { await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), }); return; } await interaction.editReply({ content: t(locale, 'commands.music.resumeSuccess') }); return; } if (subcommand === 'skip') { const member = interaction.member as GuildMember; if (!member.voice.channel) { await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), }); return; } const skipped = await MusicService.skip(interaction.guildId!); if (!skipped) { await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), }); return; } await interaction.editReply({ content: t(locale, 'commands.music.skipSuccess') }); return; } if (subcommand === 'stop') { const member = interaction.member as GuildMember; if (!member.voice.channel) { await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), }); return; } const stopped = await MusicService.stop(interaction.guildId!, locale); if (!stopped) { await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), }); return; } await interaction.editReply({ content: t(locale, 'commands.music.stopSuccess') }); return; } if (subcommand === 'leave') { const member = interaction.member as GuildMember; if (!member.voice.channel) { await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), }); return; } const left = await MusicService.leave(interaction.guildId!, locale); if (!left) { await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), }); return; } await interaction.editReply({ content: t(locale, 'commands.music.leaveSuccess') }); } } catch (error) { logger.error('Error in music command:', error); const knownError = error instanceof Error ? error.message : ''; if (knownError === 'NOT_IN_VOICE') { await respond(interaction, buildErrorMessage(locale, 'notInVoice'), true); return; } if (knownError === 'DIFFERENT_VOICE') { await respond(interaction, buildErrorMessage(locale, 'differentVoiceChannel'), true); return; } if (knownError === 'NO_SEARCH_RESULTS') { await respond(interaction, buildErrorMessage(locale, 'noSearchResults'), true); return; } if (knownError === 'INVALID_URL') { await respond(interaction, buildErrorMessage(locale, 'invalidUrl'), true); return; } if (knownError === 'QUEUE_INDEX_OUT_OF_RANGE') { await respond(interaction, buildErrorMessage(locale, 'queueRemoveOutOfRange'), true); return; } await respond(interaction, t(locale, 'errors.E3003.userMessage'), true); } }, };