366 lines
12 KiB
TypeScript
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'),
|
|
});
|
|
}
|
|
}
|
|
},
|
|
};
|