import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits, ChannelType, EmbedBuilder, Colors, TextChannel, } from 'discord.js'; import { prisma } from '../database'; import { SupportedLocale, t } from '../i18n'; import { EventService } from '../services/EventService'; const SEOUL_TIMEZONE = 'Asia/Seoul'; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const TIME_RE = /^\d{2}:\d{2}$/; function parseSeoulDateTime(date: string, time: string): Date | null { if (!DATE_RE.test(date) || !TIME_RE.test(time)) return null; const [year, month, day] = date.split('-').map(Number); const [hour, minute] = time.split(':').map(Number); if ( Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day) || Number.isNaN(hour) || Number.isNaN(minute) || month < 1 || month > 12 || day < 1 || day > 31 || hour < 0 || hour > 23 || minute < 0 || minute > 59 ) { return null; } const utcMillis = Date.UTC(year, month - 1, day, hour - 9, minute, 0, 0); const parsed = new Date(utcMillis); if ( parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day || parsed.getUTCHours() !== hour - 9 || parsed.getUTCMinutes() !== minute ) { return null; } return parsed; } function toDiscordTimestamps(date: Date): { full: string; relative: string } { const unix = Math.floor(date.getTime() / 1000); return { full: ``, relative: ``, }; } function parseReminderOffsets(raw: string | null): number[] | null { if (!raw || raw.trim() === '') return []; const values = raw .split(',') .map(part => part.trim()) .filter(Boolean); if (values.length === 0) return []; const offsets = values.map(value => Number(value)); if (offsets.some(offset => !Number.isInteger(offset) || offset < 0)) { return null; } return Array.from(new Set(offsets)).sort((a, b) => b - a); } function formatReminderOffsets(offsets: number[], locale: SupportedLocale): string { if (offsets.length === 0) { return t(locale, 'commands.event.reminderNone'); } return offsets.map(offset => `${offset}m`).join(', '); } function buildStatusLabel(status: 'SCHEDULED' | 'CANCELLED' | 'COMPLETED', locale: SupportedLocale): string { if (status === 'SCHEDULED') { return t(locale, 'commands.event.statusScheduled'); } if (status === 'COMPLETED') { return t(locale, 'commands.event.statusCompleted'); } return t(locale, 'commands.event.statusCancelled'); } export default { data: new SlashCommandBuilder() .setName('event') .setDescription('Manage scheduled server events.') .setDescriptionLocalizations({ ko: '서버 이벤트 일정을 관리합니다.', }) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand .setName('create') .setDescription('Create a new server event.') .setDescriptionLocalizations({ ko: '새 서버 이벤트를 생성합니다.' }) .addStringOption(option => option .setName('title') .setDescription('Event title') .setDescriptionLocalizations({ ko: '이벤트 제목' }) .setRequired(true) .setMaxLength(100) ) .addStringOption(option => option .setName('date') .setDescription('Date in YYYY-MM-DD format') .setDescriptionLocalizations({ ko: 'YYYY-MM-DD 형식의 날짜' }) .setRequired(true) ) .addStringOption(option => option .setName('time') .setDescription('Time in HH:mm format (24-hour, Asia/Seoul)') .setDescriptionLocalizations({ ko: 'HH:mm 형식의 시간 (24시간제, Asia/Seoul 기준)' }) .setRequired(true) ) .addStringOption(option => option .setName('description') .setDescription('Optional event description') .setDescriptionLocalizations({ ko: '선택 사항인 이벤트 설명' }) .setRequired(false) .setMaxLength(1000) ) .addChannelOption(option => option .setName('channel') .setDescription('Optional announcement channel') .setDescriptionLocalizations({ ko: '선택 사항인 공지 채널' }) .addChannelTypes(ChannelType.GuildText) .setRequired(false) ) .addStringOption(option => option .setName('reminders') .setDescription('Reminder offsets in minutes, for example 0,10,60') .setDescriptionLocalizations({ ko: '분 단위 리마인더 목록, 예: 0,10,60' }) .setRequired(false) ) ) .addSubcommand(subcommand => subcommand .setName('list') .setDescription('List upcoming server events.') .setDescriptionLocalizations({ ko: '예정된 서버 이벤트 목록을 조회합니다.' }) ) .addSubcommand(subcommand => subcommand .setName('cancel') .setDescription('Cancel a scheduled server event.') .setDescriptionLocalizations({ ko: '예약된 서버 이벤트를 취소합니다.' }) .addStringOption(option => option .setName('id') .setDescription('Event ID to cancel') .setDescriptionLocalizations({ ko: '취소할 이벤트 ID' }) .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName('announce') .setDescription('Post the event announcement embed again.') .setDescriptionLocalizations({ ko: '이벤트 공지 Embed를 다시 게시합니다.' }) .addStringOption(option => option .setName('id') .setDescription('Event ID to announce') .setDescriptionLocalizations({ ko: '공지할 이벤트 ID' }) .setRequired(true) ) ), async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { if (!interaction.guildId) return; const subcommand = interaction.options.getSubcommand(); if (subcommand === 'create') { const title = interaction.options.getString('title', true); const date = interaction.options.getString('date', true); const time = interaction.options.getString('time', true); const description = interaction.options.getString('description'); const channel = interaction.options.getChannel('channel') as TextChannel | null; const reminderRaw = interaction.options.getString('reminders'); const startsAt = parseSeoulDateTime(date, time); if (!startsAt) { await interaction.editReply({ content: `${t(locale, 'commands.event.invalidDateTime')}\n${t(locale, 'commands.event.invalidDateTimeResolution')}`, }); return; } if (startsAt.getTime() <= Date.now()) { await interaction.editReply({ content: `${t(locale, 'commands.event.invalidPastDateTime')}\n${t(locale, 'commands.event.invalidPastDateTimeResolution')}`, }); return; } const reminderOffsets = parseReminderOffsets(reminderRaw); if (!reminderOffsets) { await interaction.editReply({ content: `${t(locale, 'commands.event.invalidReminderOffsets')}\n${t(locale, 'commands.event.invalidReminderOffsetsResolution')}`, }); return; } const event = await prisma.guildEvent.create({ data: { guildId: interaction.guildId, title, description, startsAt, timezone: SEOUL_TIMEZONE, announcementChannelId: channel?.id ?? null, createdByUserId: interaction.user.id, reminderEnabled: reminderOffsets.length > 0, reminderOffsets, }, }); const timestamps = toDiscordTimestamps(event.startsAt); const embed = new EmbedBuilder() .setColor(Colors.Blurple) .setTitle(t(locale, 'commands.event.createSuccessTitle')) .setDescription(t(locale, 'commands.event.createSuccessBody', { title: event.title })) .addFields( { name: t(locale, 'commands.event.fields.eventId'), value: `\`${event.id}\``, inline: false }, { name: t(locale, 'commands.event.fields.startsAt'), value: timestamps.full, inline: true }, { name: t(locale, 'commands.event.fields.reminder'), value: formatReminderOffsets(reminderOffsets, locale), inline: true }, { name: t(locale, 'commands.event.fields.announcementChannel'), value: channel ? `${channel}` : t(locale, 'commands.event.announcementChannelNone'), inline: true }, ) .setTimestamp(); await interaction.editReply({ embeds: [embed] }); return; } if (subcommand === 'list') { const events = await prisma.guildEvent.findMany({ where: { guildId: interaction.guildId, status: 'SCHEDULED', startsAt: { gt: new Date() }, }, orderBy: { startsAt: 'asc' }, take: 10, }); if (events.length === 0) { await interaction.editReply({ content: t(locale, 'commands.event.listEmpty'), }); return; } const embed = new EmbedBuilder() .setColor(Colors.Green) .setTitle(t(locale, 'commands.event.listTitle')) .setTimestamp(); for (const event of events) { const timestamps = toDiscordTimestamps(event.startsAt); embed.addFields({ name: `${event.title} · \`${event.id}\``, value: t(locale, 'commands.event.listItemValue', { startsAt: timestamps.full, relative: timestamps.relative, status: buildStatusLabel(event.status, locale), reminder: formatReminderOffsets(event.reminderOffsets, locale), channel: event.announcementChannelId ? `<#${event.announcementChannelId}>` : t(locale, 'commands.event.announcementChannelNone'), }), inline: false, }); } await interaction.editReply({ embeds: [embed] }); return; } if (subcommand === 'cancel') { const id = interaction.options.getString('id', true); const event = await prisma.guildEvent.findFirst({ where: { id, guildId: interaction.guildId, status: 'SCHEDULED', }, }); if (!event) { await interaction.editReply({ content: t(locale, 'commands.event.cancelNotFound', { id }), }); return; } await prisma.guildEvent.update({ where: { id: event.id }, data: { status: 'CANCELLED' }, }); await interaction.editReply({ content: t(locale, 'commands.event.cancelSuccess', { id }), }); return; } if (subcommand === 'announce') { const id = interaction.options.getString('id', true); const event = await prisma.guildEvent.findFirst({ where: { id, guildId: interaction.guildId, }, }); if (!event) { await interaction.editReply({ content: t(locale, 'commands.event.cancelNotFound', { id }), }); return; } if (!event.announcementChannelId) { await interaction.editReply({ content: t(locale, 'commands.event.announceNotAvailable'), }); return; } try { await EventService.announceEvent(interaction.guild!, event.id); await interaction.editReply({ content: t(locale, 'commands.event.announceSuccess', { id, channel: `<#${event.announcementChannelId}>`, }), }); } catch { await interaction.editReply({ content: t(locale, 'commands.event.announceNotAvailable'), }); } } }, };