myong_dev #1
|
|
@ -0,0 +1,32 @@
|
||||||
|
# 2026-03-30: 이벤트 리마인더 분 단위 옵션 구현 (Event Reminder Offsets Implementation)
|
||||||
|
|
||||||
|
이벤트 리마인더를 고정된 1시간 전 / 10분 전 방식에서,
|
||||||
|
분 단위 목록을 직접 입력하는 방식으로 확장했습니다.
|
||||||
|
|
||||||
|
## 주요 변경 사항
|
||||||
|
|
||||||
|
### 1. 리마인더 설정 구조 변경
|
||||||
|
- `GuildEvent` 모델에 `reminderOffsets`와 `sentReminderOffsets` 배열 필드를 추가했습니다.
|
||||||
|
- 이제 이벤트마다 `0,10,60` 같은 분 단위 리마인더 목록을 저장할 수 있습니다.
|
||||||
|
- `0`은 이벤트 시작 시점 즉시 공지를 의미합니다.
|
||||||
|
|
||||||
|
### 2. `/event create` 입력 방식 변경
|
||||||
|
- `reminders` 문자열 옵션을 추가했습니다.
|
||||||
|
- 예:
|
||||||
|
- `0`
|
||||||
|
- `10`
|
||||||
|
- `0,10,60`
|
||||||
|
- 입력하지 않으면 자동 공지를 전혀 보내지 않습니다.
|
||||||
|
|
||||||
|
### 3. 이벤트 처리 루프 변경
|
||||||
|
- 기존의 고정 불리언 플래그 대신, 아직 전송하지 않은 분 단위 오프셋만 확인해 알림을 보냅니다.
|
||||||
|
- `startsAt <= now` 시점에 `0`이 포함되어 있으면 즉시 시작 공지를 전송합니다.
|
||||||
|
- `sentReminderOffsets`를 사용해 같은 오프셋 알림이 중복 전송되지 않도록 했습니다.
|
||||||
|
|
||||||
|
### 4. 목록 표시 개선
|
||||||
|
- `/event list`에서 리마인더 상태를 단순 on/off 대신 `0m, 10m, 60m` 형식으로 표시합니다.
|
||||||
|
- 리마인더가 없으면 "자동 공지 없음"으로 표시합니다.
|
||||||
|
|
||||||
|
### 5. 검증 결과
|
||||||
|
- `yarn build`: 성공
|
||||||
|
- `yarn test --runInBand`: 6개 테스트 스위트, 40개 테스트 통과
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
# 2026-03-30: 서버 이벤트 일정 관리 Phase 1 구현 (Event Schedule Phase 1 Implementation)
|
||||||
|
|
||||||
|
서버 이벤트 일정 관리 기능의 Phase 1 범위를 구현했습니다.
|
||||||
|
이번 단계에서는 이벤트 데이터를 저장할 수 있는 DB 모델을 추가하고,
|
||||||
|
기본 슬래시 명령인 생성, 목록 조회, 취소 기능을 제공하도록 구성했습니다.
|
||||||
|
|
||||||
|
## 주요 변경 사항
|
||||||
|
|
||||||
|
### 1. Prisma `GuildEvent` 모델 및 마이그레이션 추가
|
||||||
|
- `GuildEvent` 모델을 추가해 서버 단위 이벤트 데이터를 저장할 수 있도록 했습니다.
|
||||||
|
- `EventStatus` enum을 추가해 `SCHEDULED`, `CANCELLED`, `COMPLETED` 상태를 관리할 수 있게 했습니다.
|
||||||
|
- `startsAt`, `announcementChannelId`, `createdByUserId`, 리마인더 관련 플래그를 포함해 이후 Phase 확장에 대비했습니다.
|
||||||
|
- `20260330073722_add_guild_events` 마이그레이션을 생성 및 적용했습니다.
|
||||||
|
|
||||||
|
### 2. `/event` 명령 기본 기능 구현
|
||||||
|
- 새 명령 파일 `src/commands/event.ts`를 추가했습니다.
|
||||||
|
- 아래 3개 서브커맨드를 구현했습니다.
|
||||||
|
- `/event create`
|
||||||
|
- `/event list`
|
||||||
|
- `/event cancel`
|
||||||
|
- `Manage Guild` 권한이 필요한 관리자용 명령으로 제한했습니다.
|
||||||
|
|
||||||
|
### 3. 이벤트 생성 로직 구현
|
||||||
|
- `title`, `date`, `time`, `description`, `channel`, `reminder` 옵션을 지원합니다.
|
||||||
|
- 날짜는 `YYYY-MM-DD`, 시간은 `HH:mm` 형식으로 입력받습니다.
|
||||||
|
- 현재 Phase 1 설계에 맞춰 `Asia/Seoul` 기준으로 시각을 해석합니다.
|
||||||
|
- 과거 시각 또는 잘못된 형식 입력 시 에러 메시지를 반환합니다.
|
||||||
|
|
||||||
|
### 4. 이벤트 목록 조회 로직 구현
|
||||||
|
- 아직 시작하지 않은 `SCHEDULED` 이벤트를 시작 시각 순으로 최대 10개까지 표시합니다.
|
||||||
|
- Discord timestamp 포맷을 사용해 절대 시각과 상대 시각을 함께 보여줍니다.
|
||||||
|
- 이벤트 ID, 상태, 리마인더 사용 여부, 공지 채널 여부를 함께 표시합니다.
|
||||||
|
|
||||||
|
### 5. 이벤트 취소 로직 구현
|
||||||
|
- `/event cancel id:<event-id>` 형태로 예약 이벤트를 취소할 수 있게 했습니다.
|
||||||
|
- 실제 삭제 대신 상태를 `CANCELLED`로 변경하는 방식으로 처리했습니다.
|
||||||
|
- 서버 ID와 이벤트 상태를 함께 검사해 다른 서버 이벤트 또는 이미 취소된 이벤트를 잘못 취소하지 않도록 했습니다.
|
||||||
|
|
||||||
|
### 6. i18n 문구 추가
|
||||||
|
- `en`, `ko` 번역 파일에 이벤트 명령 관련 문구를 추가했습니다.
|
||||||
|
- `TranslationSchema`에 `commands.event` 구조를 반영해 타입 안정성을 유지했습니다.
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
- `yarn build`: 성공
|
||||||
|
- `yarn test --runInBand`: 6개 테스트 스위트, 40개 테스트 통과
|
||||||
|
|
||||||
|
## 다음 단계 메모
|
||||||
|
- Phase 2에서 `/event announce`와 공지 Embed 전송 기능을 추가할 수 있습니다.
|
||||||
|
- 리마인더 주기 작업 및 감사 로그 연동은 이후 단계에서 확장 예정입니다.
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# 2026-03-30: 서버 이벤트 일정 관리 Phase 2 구현 (Event Schedule Phase 2 Implementation)
|
||||||
|
|
||||||
|
서버 이벤트 일정 관리 기능의 Phase 2 범위를 구현했습니다.
|
||||||
|
이번 단계에서는 이벤트 공지 Embed 전송, 수동 공지 명령, 자동 리마인더, 시작 시 자동 완료 처리를 추가했습니다.
|
||||||
|
|
||||||
|
## 주요 변경 사항
|
||||||
|
|
||||||
|
### 1. `EventService` 추가
|
||||||
|
- `src/services/EventService.ts`를 추가했습니다.
|
||||||
|
- 이벤트 공지 Embed 생성, 리마인더 전송, 시작 시 완료 처리 로직을 서비스로 분리했습니다.
|
||||||
|
- 이벤트 시작 1시간 전 / 10분 전에 자동 리마인더를 보낼 수 있도록 구현했습니다.
|
||||||
|
|
||||||
|
### 2. 봇 시작 시 이벤트 루프 자동 시작
|
||||||
|
- `ready` 이벤트에서 `EventService.startReminderLoop(client)`를 호출하도록 연결했습니다.
|
||||||
|
- 봇이 로그인되면 1분 간격으로 예정 이벤트를 확인합니다.
|
||||||
|
|
||||||
|
### 3. 이벤트 공지 기능 추가
|
||||||
|
- 이벤트 생성 시 공지 채널이 설정되어 있으면 공지 Embed를 즉시 전송합니다.
|
||||||
|
- `/event announce` 서브커맨드를 추가해 기존 이벤트를 수동으로 다시 공지할 수 있게 했습니다.
|
||||||
|
- 공지 성공 시 `announcedAt` 시각을 업데이트합니다.
|
||||||
|
|
||||||
|
### 4. 자동 리마인더 및 상태 전환
|
||||||
|
- `GuildEvent.reminderEnabled`가 활성화된 이벤트를 주기적으로 검사합니다.
|
||||||
|
- 시작 1시간 전에는 `remindedOneHour`
|
||||||
|
- 시작 10분 전에는 `remindedTenMinutes`
|
||||||
|
플래그를 사용해 중복 전송을 방지합니다.
|
||||||
|
- 이벤트 시작 시각이 지나면 상태를 `COMPLETED`로 자동 전환합니다.
|
||||||
|
|
||||||
|
### 5. i18n 및 명령 확장
|
||||||
|
- `commands.event` 번역 키에 `announce`, `statusCompleted`, 공지 성공/실패 문구를 추가했습니다.
|
||||||
|
- `/event` 명령은 이제 다음 서브커맨드를 지원합니다.
|
||||||
|
- `/event create`
|
||||||
|
- `/event list`
|
||||||
|
- `/event cancel`
|
||||||
|
- `/event announce`
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
- `yarn build`: 성공
|
||||||
|
- `yarn test --runInBand`: 6개 테스트 스위트, 40개 테스트 통과
|
||||||
|
|
||||||
|
## 다음 단계 메모
|
||||||
|
- 이후 단계에서 감사 로그에 이벤트 생성/취소/공지 성공까지 더 세밀하게 기록할 수 있습니다.
|
||||||
|
- RSVP 버튼, 반복 일정, Discord Scheduled Event 연동은 후속 확장 과제로 남아 있습니다.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
# 2026-03-30: 서버 이벤트 시작 시점 공지 구현 (Event Schedule Start Announcement Implementation)
|
||||||
|
|
||||||
|
서버 이벤트 일정 관리 기능에 이벤트 시작 시점 즉시 공지 동작을 추가했습니다.
|
||||||
|
|
||||||
|
## 주요 변경 사항
|
||||||
|
|
||||||
|
### 1. 시작 공지 중복 방지 필드 추가
|
||||||
|
- `GuildEvent` 모델에 `startedAnnounced` 필드를 추가했습니다.
|
||||||
|
- 이벤트 시작 공지가 한 번만 전송되도록 제어합니다.
|
||||||
|
|
||||||
|
### 2. 시작 시점 공지 로직 추가
|
||||||
|
- `EventService`의 주기 처리 루프에서 `startsAt <= now` 인 `SCHEDULED` 이벤트를 검사합니다.
|
||||||
|
- 공지 채널이 유효한 경우, 이벤트 시작 Embed를 즉시 전송합니다.
|
||||||
|
- 이후 이벤트 상태를 `COMPLETED`로 전환합니다.
|
||||||
|
|
||||||
|
### 3. 공지 채널 미설정 케이스 처리
|
||||||
|
- 공지 채널이 없거나 사용할 수 없는 경우에도 루프가 멈추지 않도록 했습니다.
|
||||||
|
- 이 경우 시작 공지 없이 상태만 `COMPLETED`로 정리됩니다.
|
||||||
|
|
||||||
|
### 4. i18n 문구 추가
|
||||||
|
- 이벤트 시작 공지용 제목/리드 문구를 `en`, `ko` 번역 파일에 추가했습니다.
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
- `yarn build`: 성공
|
||||||
|
- `yarn test --runInBand`: 6개 테스트 스위트, 40개 테스트 통과
|
||||||
|
|
@ -48,3 +48,7 @@
|
||||||
- [2026-03-27: 권한 진단 기능 구현 (Permission Audit Implementation)](WorkDone/2026-03-27_Permission_Audit_Implementation.md)
|
- [2026-03-27: 권한 진단 기능 구현 (Permission Audit Implementation)](WorkDone/2026-03-27_Permission_Audit_Implementation.md)
|
||||||
- [2026-03-27: 에러 안내 UX 개선 및 통합 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md)
|
- [2026-03-27: 에러 안내 UX 개선 및 통합 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md)
|
||||||
- [2026-03-27: Kord 프로젝트 초기 설정 (Project Initial Setup)](WorkDone/2026-03-27_Project_Initial_Setup.md)
|
- [2026-03-27: Kord 프로젝트 초기 설정 (Project Initial Setup)](WorkDone/2026-03-27_Project_Initial_Setup.md)
|
||||||
|
- [2026-03-30: 서버 이벤트 일정 관리 Phase 1 구현 (Event Schedule Phase 1 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase1_Implementation.md)
|
||||||
|
- [2026-03-30: 서버 이벤트 일정 관리 Phase 2 구현 (Event Schedule Phase 2 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase2_Implementation.md)
|
||||||
|
- [2026-03-30: 서버 이벤트 시작 시점 공지 구현 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md)
|
||||||
|
- [2026-03-30: 이벤트 리마인더 분 단위 옵션 구현 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EventStatus" AS ENUM ('SCHEDULED', 'CANCELLED', 'COMPLETED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GuildEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"startsAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"timezone" TEXT NOT NULL DEFAULT 'Asia/Seoul',
|
||||||
|
"status" "EventStatus" NOT NULL DEFAULT 'SCHEDULED',
|
||||||
|
"announcementChannelId" TEXT,
|
||||||
|
"createdByUserId" TEXT NOT NULL,
|
||||||
|
"reminderEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"remindedOneHour" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"remindedTenMinutes" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"announcedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GuildEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GuildEvent_guildId_startsAt_idx" ON "GuildEvent"("guildId", "startsAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GuildEvent_guildId_status_idx" ON "GuildEvent"("guildId", "status");
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GuildEvent" ADD COLUMN "startedAnnounced" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GuildEvent" ADD COLUMN "reminderOffsets" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||||
|
ADD COLUMN "sentReminderOffsets" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
|
||||||
|
|
@ -110,3 +110,33 @@ model VoiceGuildConfig {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model GuildEvent {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
guildId String
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
startsAt DateTime
|
||||||
|
timezone String @default("Asia/Seoul")
|
||||||
|
status EventStatus @default(SCHEDULED)
|
||||||
|
announcementChannelId String?
|
||||||
|
createdByUserId String
|
||||||
|
reminderEnabled Boolean @default(true)
|
||||||
|
reminderOffsets Int[] @default([])
|
||||||
|
sentReminderOffsets Int[] @default([])
|
||||||
|
remindedOneHour Boolean @default(false)
|
||||||
|
remindedTenMinutes Boolean @default(false)
|
||||||
|
startedAnnounced Boolean @default(false)
|
||||||
|
announcedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([guildId, startsAt])
|
||||||
|
@@index([guildId, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EventStatus {
|
||||||
|
SCHEDULED
|
||||||
|
CANCELLED
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,375 @@
|
||||||
|
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.reply({
|
||||||
|
content: `${t(locale, 'commands.event.invalidDateTime')}\n${t(locale, 'commands.event.invalidDateTimeResolution')}`,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startsAt.getTime() <= Date.now()) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${t(locale, 'commands.event.invalidPastDateTime')}\n${t(locale, 'commands.event.invalidPastDateTimeResolution')}`,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reminderOffsets = parseReminderOffsets(reminderRaw);
|
||||||
|
if (!reminderOffsets) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${t(locale, 'commands.event.invalidReminderOffsets')}\n${t(locale, 'commands.event.invalidReminderOffsetsResolution')}`,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
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.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
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.reply({
|
||||||
|
content: t(locale, 'commands.event.listEmpty'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
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.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
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.reply({
|
||||||
|
content: t(locale, 'commands.event.cancelNotFound', { id }),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.guildEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'commands.event.cancelSuccess', { id }),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
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.reply({
|
||||||
|
content: t(locale, 'commands.event.cancelNotFound', { id }),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.announcementChannelId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'commands.event.announceNotAvailable'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await EventService.announceEvent(interaction.guild!, event.id);
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'commands.event.announceSuccess', {
|
||||||
|
id,
|
||||||
|
channel: `<#${event.announcementChannelId}>`,
|
||||||
|
}),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'commands.event.announceNotAvailable'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -4,6 +4,7 @@ import { logger } from '../utils/logger';
|
||||||
import { InviteService } from '../services/InviteService';
|
import { InviteService } from '../services/InviteService';
|
||||||
import { VoiceService } from '../services/VoiceService';
|
import { VoiceService } from '../services/VoiceService';
|
||||||
import { PresenceService } from '../services/PresenceService';
|
import { PresenceService } from '../services/PresenceService';
|
||||||
|
import { EventService } from '../services/EventService';
|
||||||
import { auditLogService } from '../services/AuditLogService';
|
import { auditLogService } from '../services/AuditLogService';
|
||||||
import { redis } from '../cache';
|
import { redis } from '../cache';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
|
@ -16,6 +17,7 @@ export default {
|
||||||
await InviteService.cacheAllInvites(client);
|
await InviteService.cacheAllInvites(client);
|
||||||
await VoiceService.syncChannels(client);
|
await VoiceService.syncChannels(client);
|
||||||
PresenceService.startActivePresence(client);
|
PresenceService.startActivePresence(client);
|
||||||
|
EventService.startReminderLoop(client);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lockKey = 'commands:sync:lock';
|
const lockKey = 'commands:sync:lock';
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,52 @@ export const en: TranslationSchema = {
|
||||||
serverSet: 'Server language has been set to **{{locale}}**.',
|
serverSet: 'Server language has been set to **{{locale}}**.',
|
||||||
serverPermissionDenied: 'Only server administrators can change the server language.',
|
serverPermissionDenied: 'Only server administrators can change the server language.',
|
||||||
},
|
},
|
||||||
|
event: {
|
||||||
|
description: 'Manage scheduled server events.',
|
||||||
|
createDescription: 'Create a new server event.',
|
||||||
|
listDescription: 'List upcoming server events.',
|
||||||
|
cancelDescription: 'Cancel a scheduled server event.',
|
||||||
|
announceDescription: 'Post the event announcement embed again.',
|
||||||
|
titleDescription: 'Event title',
|
||||||
|
dateDescription: 'Date in YYYY-MM-DD format',
|
||||||
|
timeDescription: 'Time in HH:mm format (24-hour, Asia/Seoul)',
|
||||||
|
descriptionOptionDescription: 'Optional event description',
|
||||||
|
channelDescription: 'Optional announcement channel',
|
||||||
|
reminderDescription: 'Enable reminder messages',
|
||||||
|
remindersDescription: 'Reminder offsets in minutes, for example 0,10,60',
|
||||||
|
idDescription: 'Event ID to cancel',
|
||||||
|
createSuccessTitle: 'Event Created',
|
||||||
|
createSuccessBody: 'The event **{{title}}** has been scheduled.',
|
||||||
|
listTitle: 'Upcoming Events',
|
||||||
|
listEmpty: 'There are no upcoming scheduled events.',
|
||||||
|
listItemValue: '**Starts:** {{startsAt}}\n**Relative:** {{relative}}\n**Status:** {{status}}\n**Reminder:** {{reminder}}\n**Channel:** {{channel}}',
|
||||||
|
cancelSuccess: 'The event `{{id}}` has been cancelled.',
|
||||||
|
cancelNotFound: 'Could not find a scheduled event with ID `{{id}}`.',
|
||||||
|
announceSuccess: 'The event `{{id}}` has been announced in {{channel}}.',
|
||||||
|
announceNotAvailable: 'This event does not have a usable announcement channel configured.',
|
||||||
|
startAnnouncementTitle: 'Event Started',
|
||||||
|
startAnnouncementLead: 'This event is starting now.',
|
||||||
|
invalidDateTime: 'The event date or time format is invalid.',
|
||||||
|
invalidDateTimeResolution: 'Use `YYYY-MM-DD` for the date and `HH:mm` (24-hour) for the time.',
|
||||||
|
invalidReminderOffsets: 'The reminder offset format is invalid.',
|
||||||
|
invalidReminderOffsetsResolution: 'Use comma-separated non-negative minutes like `0,10,60`. Leave it empty for no automatic announcements.',
|
||||||
|
invalidPastDateTime: 'You cannot schedule an event in the past.',
|
||||||
|
invalidPastDateTimeResolution: 'Choose a future date and time, then try again.',
|
||||||
|
statusScheduled: 'Scheduled',
|
||||||
|
statusCancelled: 'Cancelled',
|
||||||
|
statusCompleted: 'Completed',
|
||||||
|
reminderOn: 'Enabled',
|
||||||
|
reminderOff: 'Disabled',
|
||||||
|
reminderNone: 'No automatic announcements',
|
||||||
|
announcementChannelNone: 'Not set',
|
||||||
|
fields: {
|
||||||
|
eventId: 'Event ID',
|
||||||
|
startsAt: 'Starts At',
|
||||||
|
reminder: 'Reminder',
|
||||||
|
announcementChannel: 'Announcement Channel',
|
||||||
|
status: 'Status',
|
||||||
|
},
|
||||||
|
},
|
||||||
permissionAudit: {
|
permissionAudit: {
|
||||||
title: 'Bot Permission Audit Report',
|
title: 'Bot Permission Audit Report',
|
||||||
channel: 'Channel',
|
channel: 'Channel',
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,52 @@ export const ko: TranslationSchema = {
|
||||||
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
||||||
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 가능합니다.',
|
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 가능합니다.',
|
||||||
},
|
},
|
||||||
|
event: {
|
||||||
|
description: '서버 이벤트 일정을 관리합니다.',
|
||||||
|
createDescription: '새 서버 이벤트를 생성합니다.',
|
||||||
|
listDescription: '예정된 서버 이벤트 목록을 조회합니다.',
|
||||||
|
cancelDescription: '예약된 서버 이벤트를 취소합니다.',
|
||||||
|
announceDescription: '이벤트 공지 Embed를 다시 게시합니다.',
|
||||||
|
titleDescription: '이벤트 제목',
|
||||||
|
dateDescription: 'YYYY-MM-DD 형식의 날짜',
|
||||||
|
timeDescription: 'HH:mm 형식의 시간 (24시간제, Asia/Seoul 기준)',
|
||||||
|
descriptionOptionDescription: '선택 사항인 이벤트 설명',
|
||||||
|
channelDescription: '선택 사항인 공지 채널',
|
||||||
|
reminderDescription: '리마인더 메시지 사용 여부',
|
||||||
|
remindersDescription: '분 단위 리마인더 목록, 예: 0,10,60',
|
||||||
|
idDescription: '취소할 이벤트 ID',
|
||||||
|
createSuccessTitle: '이벤트 생성 완료',
|
||||||
|
createSuccessBody: '**{{title}}** 이벤트가 예약되었습니다.',
|
||||||
|
listTitle: '예정된 이벤트 목록',
|
||||||
|
listEmpty: '예정된 이벤트가 없습니다.',
|
||||||
|
listItemValue: '**시작 시각:** {{startsAt}}\n**남은 시간:** {{relative}}\n**상태:** {{status}}\n**리마인더:** {{reminder}}\n**채널:** {{channel}}',
|
||||||
|
cancelSuccess: '`{{id}}` 이벤트를 취소했습니다.',
|
||||||
|
cancelNotFound: 'ID가 `{{id}}`인 예약 이벤트를 찾지 못했습니다.',
|
||||||
|
announceSuccess: '`{{id}}` 이벤트를 {{channel}} 채널에 공지했습니다.',
|
||||||
|
announceNotAvailable: '이 이벤트에는 사용할 수 있는 공지 채널이 설정되어 있지 않습니다.',
|
||||||
|
startAnnouncementTitle: '이벤트 시작',
|
||||||
|
startAnnouncementLead: '이 이벤트가 지금 시작됩니다.',
|
||||||
|
invalidDateTime: '이벤트 날짜 또는 시간 형식이 올바르지 않습니다.',
|
||||||
|
invalidDateTimeResolution: '날짜는 `YYYY-MM-DD`, 시간은 `HH:mm` 24시간 형식으로 입력해주세요.',
|
||||||
|
invalidReminderOffsets: '리마인더 분 입력 형식이 올바르지 않습니다.',
|
||||||
|
invalidReminderOffsetsResolution: '`0,10,60`처럼 0 이상의 분을 쉼표로 구분해 입력해주세요. 비워두면 자동 공지는 하지 않습니다.',
|
||||||
|
invalidPastDateTime: '과거 시각으로 이벤트를 예약할 수 없습니다.',
|
||||||
|
invalidPastDateTimeResolution: '미래 시각을 선택한 뒤 다시 시도해주세요.',
|
||||||
|
statusScheduled: '예약됨',
|
||||||
|
statusCancelled: '취소됨',
|
||||||
|
statusCompleted: '완료됨',
|
||||||
|
reminderOn: '사용',
|
||||||
|
reminderOff: '사용 안 함',
|
||||||
|
reminderNone: '자동 공지 없음',
|
||||||
|
announcementChannelNone: '미설정',
|
||||||
|
fields: {
|
||||||
|
eventId: '이벤트 ID',
|
||||||
|
startsAt: '시작 시각',
|
||||||
|
reminder: '리마인더',
|
||||||
|
announcementChannel: '공지 채널',
|
||||||
|
status: '상태',
|
||||||
|
},
|
||||||
|
},
|
||||||
permissionAudit: {
|
permissionAudit: {
|
||||||
title: '봇 권한 진단 보고서',
|
title: '봇 권한 진단 보고서',
|
||||||
channel: '채널',
|
channel: '채널',
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,52 @@ export interface TranslationSchema {
|
||||||
serverSet: string;
|
serverSet: string;
|
||||||
serverPermissionDenied: string;
|
serverPermissionDenied: string;
|
||||||
};
|
};
|
||||||
|
event: {
|
||||||
|
description: string;
|
||||||
|
createDescription: string;
|
||||||
|
listDescription: string;
|
||||||
|
cancelDescription: string;
|
||||||
|
announceDescription: string;
|
||||||
|
titleDescription: string;
|
||||||
|
dateDescription: string;
|
||||||
|
timeDescription: string;
|
||||||
|
descriptionOptionDescription: string;
|
||||||
|
channelDescription: string;
|
||||||
|
reminderDescription: string;
|
||||||
|
remindersDescription: string;
|
||||||
|
idDescription: string;
|
||||||
|
createSuccessTitle: string;
|
||||||
|
createSuccessBody: string;
|
||||||
|
listTitle: string;
|
||||||
|
listEmpty: string;
|
||||||
|
listItemValue: string;
|
||||||
|
cancelSuccess: string;
|
||||||
|
cancelNotFound: string;
|
||||||
|
announceSuccess: string;
|
||||||
|
announceNotAvailable: string;
|
||||||
|
startAnnouncementTitle: string;
|
||||||
|
startAnnouncementLead: string;
|
||||||
|
invalidDateTime: string;
|
||||||
|
invalidDateTimeResolution: string;
|
||||||
|
invalidReminderOffsets: string;
|
||||||
|
invalidReminderOffsetsResolution: string;
|
||||||
|
invalidPastDateTime: string;
|
||||||
|
invalidPastDateTimeResolution: string;
|
||||||
|
statusScheduled: string;
|
||||||
|
statusCancelled: string;
|
||||||
|
statusCompleted: string;
|
||||||
|
reminderOn: string;
|
||||||
|
reminderOff: string;
|
||||||
|
reminderNone: string;
|
||||||
|
announcementChannelNone: string;
|
||||||
|
fields: {
|
||||||
|
eventId: string;
|
||||||
|
startsAt: string;
|
||||||
|
reminder: string;
|
||||||
|
announcementChannel: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
permissionAudit: {
|
permissionAudit: {
|
||||||
title: string;
|
title: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { Client, Colors, EmbedBuilder, Guild, TextChannel } from 'discord.js';
|
||||||
|
import { prisma } from '../database';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { auditLogService } from './AuditLogService';
|
||||||
|
|
||||||
|
const REMINDER_INTERVAL_MS = 60 * 1000;
|
||||||
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||||
|
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
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 buildEventEmbed(
|
||||||
|
mode: 'announcement' | 'oneHourReminder' | 'tenMinuteReminder' | 'started',
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
startsAt: Date,
|
||||||
|
createdByUserId: string,
|
||||||
|
) {
|
||||||
|
const timestamps = toDiscordTimestamps(startsAt);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
announcement: {
|
||||||
|
color: Colors.Blurple,
|
||||||
|
titlePrefix: 'Event Scheduled',
|
||||||
|
lead: 'A new server event has been scheduled.',
|
||||||
|
},
|
||||||
|
oneHourReminder: {
|
||||||
|
color: Colors.Gold,
|
||||||
|
titlePrefix: 'Event Reminder',
|
||||||
|
lead: 'This event starts in about 1 hour.',
|
||||||
|
},
|
||||||
|
tenMinuteReminder: {
|
||||||
|
color: Colors.Orange,
|
||||||
|
titlePrefix: 'Event Reminder',
|
||||||
|
lead: 'This event starts in about 10 minutes.',
|
||||||
|
},
|
||||||
|
started: {
|
||||||
|
color: Colors.Green,
|
||||||
|
titlePrefix: 'Event Started',
|
||||||
|
lead: 'This event is starting now.',
|
||||||
|
},
|
||||||
|
}[mode];
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(config.color)
|
||||||
|
.setTitle(`${config.titlePrefix}: ${title}`)
|
||||||
|
.setDescription(description || config.lead)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Starts At', value: timestamps.full, inline: true },
|
||||||
|
{ name: 'Relative', value: timestamps.relative, inline: true },
|
||||||
|
{ name: 'Created By', value: `<@${createdByUserId}>`, inline: true },
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
embed.addFields({ name: 'Summary', value: config.lead, inline: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAnnouncementChannel(guild: Guild, channelId: string | null): Promise<TextChannel | null> {
|
||||||
|
if (!channelId) return null;
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel || !channel.isTextBased() || !(channel instanceof TextChannel)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventService {
|
||||||
|
private static reminderInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
public static async announceEvent(guild: Guild, eventId: string) {
|
||||||
|
const event = await prisma.guildEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
id: eventId,
|
||||||
|
guildId: guild.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new Error(`GuildEvent ${eventId} not found for guild ${guild.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await resolveAnnouncementChannel(guild, event.announcementChannelId);
|
||||||
|
if (!channel) {
|
||||||
|
throw new Error(`Announcement channel is not configured or unavailable for event ${eventId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = buildEventEmbed(
|
||||||
|
'announcement',
|
||||||
|
event.title,
|
||||||
|
event.description,
|
||||||
|
event.startsAt,
|
||||||
|
event.createdByUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
|
||||||
|
await prisma.guildEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: { announcedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static startReminderLoop(client: Client) {
|
||||||
|
if (this.reminderInterval) {
|
||||||
|
clearInterval(this.reminderInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processDueEvents(client).catch((error) => {
|
||||||
|
logger.error('EventService: Initial event processing failed', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.reminderInterval = setInterval(() => {
|
||||||
|
this.processDueEvents(client).catch((error) => {
|
||||||
|
logger.error('EventService: Scheduled event processing failed', error);
|
||||||
|
});
|
||||||
|
}, REMINDER_INTERVAL_MS);
|
||||||
|
|
||||||
|
logger.info('EventService: Reminder loop started (1m interval).');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static stopReminderLoop() {
|
||||||
|
if (this.reminderInterval) {
|
||||||
|
clearInterval(this.reminderInterval);
|
||||||
|
this.reminderInterval = null;
|
||||||
|
logger.info('EventService: Reminder loop stopped.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async processDueEvents(client: Client) {
|
||||||
|
const now = new Date();
|
||||||
|
const upcomingEvents = await prisma.guildEvent.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
startsAt: { gt: now },
|
||||||
|
},
|
||||||
|
orderBy: { startsAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of upcomingEvents) {
|
||||||
|
if (event.reminderOffsets.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(event.guildId) ?? await client.guilds.fetch(event.guildId).catch(() => null);
|
||||||
|
if (!guild) continue;
|
||||||
|
|
||||||
|
const channel = await resolveAnnouncementChannel(guild, event.announcementChannelId);
|
||||||
|
if (!channel) continue;
|
||||||
|
|
||||||
|
const diff = event.startsAt.getTime() - now.getTime();
|
||||||
|
|
||||||
|
const dueOffsets = event.reminderOffsets.filter(offset =>
|
||||||
|
offset > 0 &&
|
||||||
|
!event.sentReminderOffsets.includes(offset) &&
|
||||||
|
diff <= offset * 60 * 1000 &&
|
||||||
|
diff > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const offset of dueOffsets) {
|
||||||
|
await this.sendReminder(guild, channel, event.id, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedEvents = await prisma.guildEvent.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
startsAt: { lte: now },
|
||||||
|
},
|
||||||
|
orderBy: { startsAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of startedEvents) {
|
||||||
|
const guild = client.guilds.cache.get(event.guildId) ?? await client.guilds.fetch(event.guildId).catch(() => null);
|
||||||
|
if (!guild) continue;
|
||||||
|
|
||||||
|
if (event.reminderOffsets.includes(0) && !event.sentReminderOffsets.includes(0)) {
|
||||||
|
const channel = await resolveAnnouncementChannel(guild, event.announcementChannelId);
|
||||||
|
|
||||||
|
if (channel) {
|
||||||
|
try {
|
||||||
|
const embed = buildEventEmbed(
|
||||||
|
'started',
|
||||||
|
event.title,
|
||||||
|
event.description,
|
||||||
|
event.startsAt,
|
||||||
|
event.createdByUserId,
|
||||||
|
);
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`EventService: Failed to send start announcement for event ${event.id}`, error);
|
||||||
|
await auditLogService.log(guild, {
|
||||||
|
category: 'SYSTEM',
|
||||||
|
severity: 'WARN',
|
||||||
|
title: 'Event Start Announcement Failed',
|
||||||
|
description: `Failed to send start announcement for event **${event.title}** (\`${event.id}\`).`,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.guildEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: {
|
||||||
|
startedAnnounced: event.reminderOffsets.includes(0) ? true : event.startedAnnounced,
|
||||||
|
sentReminderOffsets: event.reminderOffsets.includes(0) && !event.sentReminderOffsets.includes(0)
|
||||||
|
? [...event.sentReminderOffsets, 0]
|
||||||
|
: event.sentReminderOffsets,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async sendReminder(
|
||||||
|
guild: Guild,
|
||||||
|
channel: TextChannel,
|
||||||
|
eventId: string,
|
||||||
|
offsetMinutes: number,
|
||||||
|
) {
|
||||||
|
const event = await prisma.guildEvent.findUnique({ where: { id: eventId } });
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
const mode = offsetMinutes >= 60 ? 'oneHourReminder' : 'tenMinuteReminder';
|
||||||
|
const embed = buildEventEmbed(
|
||||||
|
mode,
|
||||||
|
event.title,
|
||||||
|
event.description,
|
||||||
|
event.startsAt,
|
||||||
|
event.createdByUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
|
||||||
|
await prisma.guildEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: {
|
||||||
|
sentReminderOffsets: [...event.sentReminderOffsets, offsetMinutes],
|
||||||
|
remindedOneHour: mode === 'oneHourReminder' ? true : event.remindedOneHour,
|
||||||
|
remindedTenMinutes: mode === 'tenMinuteReminder' ? true : event.remindedTenMinutes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`EventService: Failed to send ${offsetMinutes}m reminder for event ${event.id}`, error);
|
||||||
|
await auditLogService.log(guild, {
|
||||||
|
category: 'SYSTEM',
|
||||||
|
severity: 'WARN',
|
||||||
|
title: 'Event Reminder Failed',
|
||||||
|
description: `Failed to send ${offsetMinutes}m reminder for event **${event.title}** (\`${event.id}\`).`,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue