401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|