Kord/src/commands/music.ts

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);
}
},
};