From f9206ce7b2fc7bd71dc9f682895ceea0abd9df81 Mon Sep 17 00:00:00 2001 From: MyungHyun Date: Mon, 30 Mar 2026 16:31:24 +0900 Subject: [PATCH 1/2] Add event schedule management plan --- Docs/Plans/Event_Schedule_Management_Plan.md | 315 +++++++++++++++++++ Docs/index.md | 1 + 2 files changed, 316 insertions(+) create mode 100644 Docs/Plans/Event_Schedule_Management_Plan.md diff --git a/Docs/Plans/Event_Schedule_Management_Plan.md b/Docs/Plans/Event_Schedule_Management_Plan.md new file mode 100644 index 0000000..e9da768 --- /dev/null +++ b/Docs/Plans/Event_Schedule_Management_Plan.md @@ -0,0 +1,315 @@ +# Kord - 서버 이벤트 일정 관리 기능 기획안 (Event Schedule Management) + +## 체인지로그 (Changelog) +- **2026-03-30**: 최초 기획안 작성 + +--- + +## 1. 개요 + +서버 이벤트 일정 관리 기능은 관리자 또는 운영진이 디스코드 서버 내 이벤트를 등록하고, +참여자에게 일정 정보를 공지하며, 시작 전 자동 리마인더를 보낼 수 있도록 돕는 기능입니다. + +현재 Kord가 제공하는 운영 보조 기능과 자연스럽게 연결되며, +특히 감사 로그, i18n, 설정 마법사와의 연동 가치가 높습니다. + +### 목표 + +- 서버 공용 이벤트 일정을 등록, 수정, 취소할 수 있어야 함 +- 예정된 이벤트 목록을 슬래시 커맨드로 빠르게 조회할 수 있어야 함 +- 이벤트 시작 전 지정 채널에 자동 리마인더를 보낼 수 있어야 함 +- 이벤트 생성/수정/취소 내역을 감사 로그 채널에 기록할 수 있어야 함 +- 향후 RSVP, 반복 일정, Discord Scheduled Event 연동으로 확장 가능해야 함 + +### 초기 범위 (MVP) + +- 서버 단위 공용 이벤트만 지원 +- 단발성 이벤트만 지원 +- 이벤트 생성 / 목록 조회 / 삭제 / 공지 / 리마인더 제공 +- 시간대는 우선 서버 기본 시간대 기준으로 처리 + +--- + +## 2. 주요 사용자 시나리오 + +### 시나리오 A: 운영진이 서버 이벤트를 등록 + +1. 관리자가 `/event create` 명령을 실행 +2. 제목, 시작 일시, 설명, 공지 채널, 리마인더 여부를 입력 +3. Kord가 DB에 이벤트를 저장 +4. 필요 시 지정 채널에 이벤트 공지 Embed를 게시 +5. 감사 로그 채널에 "이벤트 생성" 로그 기록 + +### 시나리오 B: 일반 사용자가 예정된 이벤트를 확인 + +1. 사용자가 `/event list` 명령을 실행 +2. Kord가 아직 시작하지 않은 이벤트를 시작 시각 순으로 정렬해 표시 +3. 사용자는 제목, 시간, 설명, 공지 채널 정보를 확인 + +### 시나리오 C: 이벤트 시작 전 자동 알림 + +1. 백그라운드 스케줄러가 예정된 이벤트를 주기적으로 확인 +2. 이벤트 시작 1시간 전 또는 10분 전에 리마인더 조건이 충족되면 +3. 지정 채널에 리마인더 Embed를 전송 +4. 중복 전송 방지를 위해 리마인더 전송 상태를 저장 + +--- + +## 3. 명령 구조 제안 + +### 3.1 `/event create` + +이벤트를 새로 등록합니다. + +입력 예시: + +- `title`: 이벤트 제목 +- `starts_at`: 시작 시각 +- `description`: 이벤트 설명 +- `channel`: 공지 채널 +- `reminder`: 리마인더 사용 여부 + +응답: + +- 성공 시 Ephemeral Embed로 생성 결과 표시 +- 필요 시 공지 채널에 이벤트 공지 Embed 전송 + +### 3.2 `/event list` + +예정된 이벤트 목록을 조회합니다. + +출력: + +- 가까운 순으로 정렬된 이벤트 목록 +- 제목 / 시작 시각 / 남은 시간 / 공지 채널 / 리마인더 여부 표시 + +### 3.3 `/event cancel` + +등록된 이벤트를 취소합니다. + +동작: + +- 이벤트 상태를 `CANCELLED`로 변경하거나 삭제 +- 이미 공지된 이벤트라면 취소 안내 메시지 전송 옵션 제공 가능 + +### 3.4 `/event announce` + +등록된 이벤트를 지정 채널에 다시 공지합니다. + +활용: + +- 공지 메시지를 다시 올리고 싶을 때 +- 최초 생성 시 자동 공지를 끈 경우 수동으로 게시할 때 + +### 3.5 `/event detail` + +특정 이벤트의 상세 정보를 조회합니다. + +출력: + +- 제목 +- 설명 +- 시작 시각 +- 생성자 +- 공지 채널 +- 리마인더 상태 + +--- + +## 4. 데이터 모델 제안 + +### `GuildEvent` + +```prisma +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) + remindedOneHour Boolean @default(false) + remindedTenMinutes Boolean @default(false) + announcedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([guildId, startsAt]) + @@index([guildId, status]) +} + +enum EventStatus { + SCHEDULED + CANCELLED + COMPLETED +} +``` + +### 설계 메모 + +- `status`를 두어 삭제 대신 취소/완료 상태 전환 가능 +- 리마인더 중복 전송 방지를 위해 플래그 저장 +- 시간대는 초기에는 서버 기본값 하나를 두고, 이후 서버별 설정으로 확장 + +--- + +## 5. 리마인더 처리 방식 + +### 기본 방향 + +- 별도 외부 스케줄러 없이 봇 프로세스 내부 주기 작업으로 시작 +- 예: 1분 간격으로 `SCHEDULED` 이벤트 조회 +- 현재 시각과 비교해 리마인더 조건 충족 시 공지 채널에 메시지 전송 + +### 리마인더 규칙 (초기안) + +- 시작 1시간 전 +- 시작 10분 전 + +### 중복 방지 + +- 전송 직후 `remindedOneHour`, `remindedTenMinutes` 업데이트 +- 다중 인스턴스 환경에서는 Redis lock 또는 `INSTANCE_ID` 기반 단일 실행 제어 고려 + +> [!NOTE] +> 현재 프로젝트는 글로벌 커맨드 동기화 시 Redis lock을 사용하므로, +> 이벤트 리마인더도 같은 방식으로 확장하기 좋습니다. + +--- + +## 6. UI 설계 + +### 이벤트 공지 Embed + +포함 정보: + +- 이벤트 제목 +- 시작 시각 +- 남은 시간 +- 설명 +- 공지 채널 +- 주최자 또는 생성자 + +예시 필드: + +- `시작 시각` +- `남은 시간` +- `안내 채널` +- `설명` + +### 리마인더 Embed + +용도: + +- 이벤트 시작이 가까워졌음을 강조 + +예시 문구: + +- "이 이벤트가 1시간 뒤 시작됩니다." +- "이 이벤트가 10분 뒤 시작됩니다." + +### 목록 조회 Embed + +출력 형태: + +- 이벤트 5개 또는 10개 단위 요약 +- 오래된 완료 이벤트는 제외 +- 이벤트가 없을 경우 친절한 Empty State 메시지 제공 + +--- + +## 7. 권한 및 운영 정책 + +### 실행 권한 + +- `/event create`, `/event cancel`, `/event announce` + - 관리자 또는 `Manage Guild` 권한 사용자만 허용 권장 +- `/event list`, `/event detail` + - 일반 사용자도 허용 가능 + +### 감사 로그 연동 + +아래 이벤트를 감사 로그 채널에 남깁니다. + +- 이벤트 생성 +- 이벤트 수정 +- 이벤트 취소 +- 리마인더 전송 실패 + +추천 카테고리: + +- 신규 카테고리 `EVENT` + 또는 +- 초기에는 `SYSTEM` 카테고리에 포함 + +--- + +## 8. i18n 고려사항 + +- 날짜/시간 포맷은 locale에 맞춰 표시 필요 +- 공지/리마인더/오류 문구는 모두 `t()` 기반으로 관리 +- 명령 설명에도 `setDescriptionLocalizations()` 적용 + +초기 지원 언어: + +- `en` +- `ko` + +--- + +## 9. 구현 단계 (Phased Implementation) + +| 단계 | 내용 | +|------|------| +| **Phase 1** | Prisma `GuildEvent` 모델 추가 및 마이그레이션 | +| **Phase 1** | `/event create`, `/event list`, `/event cancel` 기본 명령 구현 | +| **Phase 2** | 이벤트 공지 Embed 및 `/event announce` 구현 | +| **Phase 2** | 내부 주기 작업 기반 리마인더 전송 구현 | +| **Phase 3** | 감사 로그 연동 및 에러 처리 고도화 | +| **Phase 3** | i18n 문구 정리 및 테스트 추가 | +| **Phase 4** | RSVP 버튼, 반복 일정, 서버 기본 시간대 설정 확장 | + +--- + +## 10. 검증 계획 + +### 자동 테스트 + +- 이벤트 생성/조회/취소 서비스 로직 테스트 +- 리마인더 조건 계산 테스트 +- 이벤트 상태 전환 테스트 +- i18n 문구 조회 테스트 + +### 수동 테스트 + +1. `/event create`로 10분 뒤 이벤트 생성 +2. `/event list`에서 조회되는지 확인 +3. 지정 채널에 공지 Embed가 정상 게시되는지 확인 +4. 리마인더 시각 도달 시 중복 없이 전송되는지 확인 +5. `/event cancel` 후 더 이상 리마인더가 가지 않는지 확인 + +--- + +## 11. 후속 확장 아이디어 + +- RSVP 버튼 (`참석`, `불참`, `미정`) +- Discord Scheduled Event API 연동 +- 반복 일정 (`매주`, `매월`) +- 이벤트 참가자 역할 자동 부여 +- 이벤트 전용 임시 음성채널 자동 생성 +- 서버 기본 시간대 설정 (`/config timezone`) + +--- + +## 12. 관련 문서 + +| 문서 | 링크 | +|------|------| +| 기능 로드맵 | [`Feature_Roadmap.md`](./Feature_Roadmap.md) | +| 감사 채널 기획안 | [`Audit_Channel_Plan.md`](./Audit_Channel_Plan.md) | +| i18n 기획안 | [`i18n_Plan.md`](./i18n_Plan.md) | +| 설정 마법사 기획안 | [`Setup_Wizard_Plan.md`](./Setup_Wizard_Plan.md) | diff --git a/Docs/index.md b/Docs/index.md index cd7d4fd..baa8278 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -22,6 +22,7 @@ - [봇 상태 메시지 기획 (Bot Presence Plan)](Plans/Bot_Presence_Plan.md) - [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md) - [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md) +- [서버 이벤트 일정 관리 기능 기획안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md) ## 아키텍처 및 정책 결정 (Decisions) From 6eb382673732f043d3a43dd552af7703e6ccb565 Mon Sep 17 00:00:00 2001 From: MyungHyun Date: Mon, 30 Mar 2026 17:37:32 +0900 Subject: [PATCH 2/2] Implement event scheduling workflow --- ...0_Event_Reminder_Offsets_Implementation.md | 32 ++ ...30_Event_Schedule_Phase1_Implementation.md | 49 +++ ...30_Event_Schedule_Phase2_Implementation.md | 43 ++ ...edule_Start_Announcement_Implementation.md | 25 ++ Docs/index.md | 4 + .../migration.sql | 29 ++ .../migration.sql | 2 + .../migration.sql | 3 + prisma/schema.prisma | 30 ++ src/commands/event.ts | 375 ++++++++++++++++++ src/events/ready.ts | 2 + src/i18n/locales/en.ts | 46 +++ src/i18n/locales/ko.ts | 46 +++ src/i18n/types.ts | 46 +++ src/services/EventService.ts | 265 +++++++++++++ 15 files changed, 997 insertions(+) create mode 100644 Docs/WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md create mode 100644 Docs/WorkDone/2026-03-30_Event_Schedule_Phase1_Implementation.md create mode 100644 Docs/WorkDone/2026-03-30_Event_Schedule_Phase2_Implementation.md create mode 100644 Docs/WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md create mode 100644 prisma/migrations/20260330073722_add_guild_events/migration.sql create mode 100644 prisma/migrations/20260330075712_add_started_announcement_flag/migration.sql create mode 100644 prisma/migrations/20260330081452_add_dynamic_event_reminder_offsets/migration.sql create mode 100644 src/commands/event.ts create mode 100644 src/services/EventService.ts diff --git a/Docs/WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md b/Docs/WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md new file mode 100644 index 0000000..2df7542 --- /dev/null +++ b/Docs/WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md @@ -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개 테스트 통과 diff --git a/Docs/WorkDone/2026-03-30_Event_Schedule_Phase1_Implementation.md b/Docs/WorkDone/2026-03-30_Event_Schedule_Phase1_Implementation.md new file mode 100644 index 0000000..014f67e --- /dev/null +++ b/Docs/WorkDone/2026-03-30_Event_Schedule_Phase1_Implementation.md @@ -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:` 형태로 예약 이벤트를 취소할 수 있게 했습니다. +- 실제 삭제 대신 상태를 `CANCELLED`로 변경하는 방식으로 처리했습니다. +- 서버 ID와 이벤트 상태를 함께 검사해 다른 서버 이벤트 또는 이미 취소된 이벤트를 잘못 취소하지 않도록 했습니다. + +### 6. i18n 문구 추가 +- `en`, `ko` 번역 파일에 이벤트 명령 관련 문구를 추가했습니다. +- `TranslationSchema`에 `commands.event` 구조를 반영해 타입 안정성을 유지했습니다. + +## 검증 결과 +- `yarn build`: 성공 +- `yarn test --runInBand`: 6개 테스트 스위트, 40개 테스트 통과 + +## 다음 단계 메모 +- Phase 2에서 `/event announce`와 공지 Embed 전송 기능을 추가할 수 있습니다. +- 리마인더 주기 작업 및 감사 로그 연동은 이후 단계에서 확장 예정입니다. diff --git a/Docs/WorkDone/2026-03-30_Event_Schedule_Phase2_Implementation.md b/Docs/WorkDone/2026-03-30_Event_Schedule_Phase2_Implementation.md new file mode 100644 index 0000000..c5296d4 --- /dev/null +++ b/Docs/WorkDone/2026-03-30_Event_Schedule_Phase2_Implementation.md @@ -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 연동은 후속 확장 과제로 남아 있습니다. diff --git a/Docs/WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md b/Docs/WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md new file mode 100644 index 0000000..d222076 --- /dev/null +++ b/Docs/WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md @@ -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개 테스트 통과 diff --git a/Docs/index.md b/Docs/index.md index baa8278..038cdf3 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -48,3 +48,7 @@ - [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: 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) diff --git a/prisma/migrations/20260330073722_add_guild_events/migration.sql b/prisma/migrations/20260330073722_add_guild_events/migration.sql new file mode 100644 index 0000000..5949cbe --- /dev/null +++ b/prisma/migrations/20260330073722_add_guild_events/migration.sql @@ -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"); diff --git a/prisma/migrations/20260330075712_add_started_announcement_flag/migration.sql b/prisma/migrations/20260330075712_add_started_announcement_flag/migration.sql new file mode 100644 index 0000000..4ea6226 --- /dev/null +++ b/prisma/migrations/20260330075712_add_started_announcement_flag/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "GuildEvent" ADD COLUMN "startedAnnounced" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260330081452_add_dynamic_event_reminder_offsets/migration.sql b/prisma/migrations/20260330081452_add_dynamic_event_reminder_offsets/migration.sql new file mode 100644 index 0000000..19a4932 --- /dev/null +++ b/prisma/migrations/20260330081452_add_dynamic_event_reminder_offsets/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "GuildEvent" ADD COLUMN "reminderOffsets" INTEGER[] DEFAULT ARRAY[]::INTEGER[], +ADD COLUMN "sentReminderOffsets" INTEGER[] DEFAULT ARRAY[]::INTEGER[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 87abba4..cc790cd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,3 +110,33 @@ model VoiceGuildConfig { createdAt DateTime @default(now()) 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 +} diff --git a/src/commands/event.ts b/src/commands/event.ts new file mode 100644 index 0000000..7986e4c --- /dev/null +++ b/src/commands/event.ts @@ -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: ``, + 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.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, + }); + } + } + }, +}; diff --git a/src/events/ready.ts b/src/events/ready.ts index de47035..0ecf7c5 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -4,6 +4,7 @@ import { logger } from '../utils/logger'; import { InviteService } from '../services/InviteService'; import { VoiceService } from '../services/VoiceService'; import { PresenceService } from '../services/PresenceService'; +import { EventService } from '../services/EventService'; import { auditLogService } from '../services/AuditLogService'; import { redis } from '../cache'; import { env } from '../config/env'; @@ -16,6 +17,7 @@ export default { await InviteService.cacheAllInvites(client); await VoiceService.syncChannels(client); PresenceService.startActivePresence(client); + EventService.startReminderLoop(client); try { const lockKey = 'commands:sync:lock'; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4482ca5..3b5865c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -145,6 +145,52 @@ export const en: TranslationSchema = { serverSet: 'Server language has been set to **{{locale}}**.', 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: { title: 'Bot Permission Audit Report', channel: 'Channel', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 8c28a56..d64eef3 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -145,6 +145,52 @@ export const ko: TranslationSchema = { serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.', 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: { title: '봇 권한 진단 보고서', channel: '채널', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index c3eab65..883850c 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -99,6 +99,52 @@ export interface TranslationSchema { serverSet: 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: { title: string; channel: string; diff --git a/src/services/EventService.ts b/src/services/EventService.ts new file mode 100644 index 0000000..83a61c8 --- /dev/null +++ b/src/services/EventService.ts @@ -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: ``, + relative: ``, + }; +} + +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 { + 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(() => {}); + } + } +}