Kord/src/commands/event.ts

366 lines
12 KiB
TypeScript

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: `<t:${unix}:F>`,
relative: `<t:${unix}:R>`,
};
}
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'),
});
}
}
},
};