Compare commits

..

No commits in common. "019cb314be06914b2a413b0f588df3e560d2e937" and "122f20d031218d8ae5ca845196f7b5cda2e9b5a2" have entirely different histories.

16 changed files with 0 additions and 1313 deletions

View File

@ -1,315 +0,0 @@
# 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) |

View File

@ -1,32 +0,0 @@
# 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개 테스트 통과

View File

@ -1,49 +0,0 @@
# 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 전송 기능을 추가할 수 있습니다.
- 리마인더 주기 작업 및 감사 로그 연동은 이후 단계에서 확장 예정입니다.

View File

@ -1,43 +0,0 @@
# 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 연동은 후속 확장 과제로 남아 있습니다.

View File

@ -1,25 +0,0 @@
# 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개 테스트 통과

View File

@ -22,7 +22,6 @@
- [봇 상태 메시지 기획 (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)
@ -48,8 +47,4 @@
- [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)
- [2026-03-30: 명령어 계층 구조 리팩토링 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md)

View File

@ -1,29 +0,0 @@
-- 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");

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "GuildEvent" ADD COLUMN "startedAnnounced" BOOLEAN NOT NULL DEFAULT false;

View File

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "GuildEvent" ADD COLUMN "reminderOffsets" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
ADD COLUMN "sentReminderOffsets" INTEGER[] DEFAULT ARRAY[]::INTEGER[];

View File

@ -110,33 +110,3 @@ 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
}

View File

@ -1,375 +0,0 @@
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,
});
}
}
},
};

View File

@ -4,7 +4,6 @@ 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';
@ -17,7 +16,6 @@ export default {
await InviteService.cacheAllInvites(client);
await VoiceService.syncChannels(client);
PresenceService.startActivePresence(client);
EventService.startReminderLoop(client);
try {
const lockKey = 'commands:sync:lock';

View File

@ -145,52 +145,6 @@ 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',

View File

@ -145,52 +145,6 @@ 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: '채널',

View File

@ -99,52 +99,6 @@ 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;

View File

@ -1,265 +0,0 @@
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(() => {});
}
}
}