diff --git a/.agents/rules/kord_routine.md b/.agents/rules/kord_routine.md index 7f5a3b6..51d7a42 100644 --- a/.agents/rules/kord_routine.md +++ b/.agents/rules/kord_routine.md @@ -9,7 +9,7 @@ description: work routine ## 기본 원칙 (Work Rules) -1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL, Redis 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다. +1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다. 2. **협력적 기획, 독립적 실행**: 기능 기획과 설계(Architecture, Schema 등)는 사용자와 함께 논리적인 완결성을 갖출 때까지 충분히 논의합니다. 기획이 "완료 및 승인"된 후에는 후속 구현, 에러 디버깅, 자체 테스트를 추가적인 중간 확인 없이 에이전트가 주도를 가지고 끝마친 뒤 최종 결과를 보고합니다. ## 단계별 작업 루틴 diff --git a/.env.example b/.env.example index 38fabdc..4546796 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ DISCORD_CLIENT_ID=your_client_id_here # User/pass from docker-compose.yml DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public" -# Redis Configuration -REDIS_HOST="localhost" -REDIS_PORT=6379 \ No newline at end of file +# Logging (log4js — file only under LOG_DIR, no console appender) +# Levels: trace, debug, info, warn, error, fatal +LOG_LEVEL=info +# Log directory (kord.log + dated rotations). Relative = from process cwd; use an absolute path on servers +# if the deploy directory is wiped (e.g. Jenkins): LOG_DIR=/var/lib/kord/logs +LOG_DIR=logs diff --git a/Docs/Plans/Error_Guidance_Plan.md b/Docs/Plans/Error_Guidance_Plan.md index 68c1cae..5e9c11f 100644 --- a/Docs/Plans/Error_Guidance_Plan.md +++ b/Docs/Plans/Error_Guidance_Plan.md @@ -39,7 +39,7 @@ |----------|-------------|------|------------------| | `USER_INPUT` | `E1xxx` | 잘못된 입력값 (범위 초과, 형식 오류 등) | 올바른 입력 예시 안내, 재입력 유도 | | `PERMISSION` | `E2xxx` | 봇 또는 사용자 권한 부족 | 필요 권한 안내, 서버 관리자 문의 유도 | -| `BOT_INTERNAL` | `E3xxx` | 봇 내부 오류 (DB, Redis, 로직 오류) | 잠시 후 재시도 안내, 지속 시 관리자 문의 | +| `BOT_INTERNAL` | `E3xxx` | 봇 내부 오류 (DB, 로직 오류 등) | 잠시 후 재시도 안내, 지속 시 관리자 문의 | | `DISCORD_API` | `E4xxx` | Discord API 오류 (Rate Limit, 서비스 장애 등) | 잠시 후 재시도, Discord 상태 확인 안내 | ### 주요 에러 코드 예시 @@ -51,7 +51,7 @@ E2001 봇에 Manage Channels 권한 없음 E2002 사용자에게 관리자 권한 없음 E2003 채널 소유자만 사용 가능 E3001 데이터베이스 연결/쿼리 실패 -E3002 캐시(Redis) 연결 실패 +E3002 내부 캐시/상태 계층 오류 E4001 Discord API Rate Limit E4002 Discord API 50013 (Missing Permissions) E4003 Discord API 일시적 오류 diff --git a/Docs/Plans/Event_Schedule_Management_Plan.md b/Docs/Plans/Event_Schedule_Management_Plan.md index e9da768..afc4efc 100644 --- a/Docs/Plans/Event_Schedule_Management_Plan.md +++ b/Docs/Plans/Event_Schedule_Management_Plan.md @@ -173,11 +173,10 @@ enum EventStatus { ### 중복 방지 - 전송 직후 `remindedOneHour`, `remindedTenMinutes` 업데이트 -- 다중 인스턴스 환경에서는 Redis lock 또는 `INSTANCE_ID` 기반 단일 실행 제어 고려 +- 다중 인스턴스 환경에서는 DB advisory lock 또는 `INSTANCE_ID` 기반 단일 실행 제어 고려 > [!NOTE] -> 현재 프로젝트는 글로벌 커맨드 동기화 시 Redis lock을 사용하므로, -> 이벤트 리마인더도 같은 방식으로 확장하기 좋습니다. +> 글로벌 커맨드 등록 등은 애플리케이션 레벨에서 처리하며, 이벤트 리마인더도 유사한 단일 실행 패턴으로 확장할 수 있습니다. --- diff --git a/Docs/Plans/Setup_Wizard_Plan.md b/Docs/Plans/Setup_Wizard_Plan.md index 4f9a189..c253772 100644 --- a/Docs/Plans/Setup_Wizard_Plan.md +++ b/Docs/Plans/Setup_Wizard_Plan.md @@ -71,7 +71,7 @@ - **State Management (상태 관리)**: - 마법사는 ephemeral 메시지로 띄워지며, 세션 식별은 `customId` 릴레이 방식을 권장합니다. - 예시: `setup_action_next_2` (현재 Step 2, 다음으로 이동) 등 Stateless하게 관리하거나, - - 사용자/서버별 임시 Map/Redis에 `SetupSession` (진행 단계 등) 보관. (구현 편의상 `customId` payload에 스텝 인덱스를 담는 Stateless 방식 채택) + - 사용자/서버별 임시 Map 등에 `SetupSession` (진행 단계 등) 보관. (구현 편의상 `customId` payload에 스텝 인덱스를 담는 Stateless 방식 채택) - **i18n 통합**: - 버튼 레이블 및 Embed 내용은 모두 `t()` 함수를 거쳐야 합니다. - Step 1에서 언어가 변경되면, 즉시 그 언어 기준의 `t()`를 이용해 현재 뷰(View)를 갱신합니다. diff --git a/Docs/Rules/security_guidelines.md b/Docs/Rules/security_guidelines.md index 54c24f9..9731601 100644 --- a/Docs/Rules/security_guidelines.md +++ b/Docs/Rules/security_guidelines.md @@ -13,7 +13,7 @@ #### 절대 금지 대상 예시: - 디스코드 봇 토큰 (Discord Bot Tokens) - 데이터베이스 비밀번호 및 접속 주소 (e.g. `postgresql://user:password@host/db`) -- Redis 비밀번호, API 인증 키(Keys), 비밀 암호 해시, 사내용 중요 자격증명 리스트 +- 외부 서비스 비밀번호, API 인증 키(Keys), 비밀 암호 해시, 사내용 중요 자격증명 리스트 #### 올바른 해결 방법 1. **환경 변수 파일 (`.env`) 사용**: diff --git a/Docs/WorkDone/2026-03-27_Project_Initial_Setup.md b/Docs/WorkDone/2026-03-27_Project_Initial_Setup.md index b83a6de..8afb153 100644 --- a/Docs/WorkDone/2026-03-27_Project_Initial_Setup.md +++ b/Docs/WorkDone/2026-03-27_Project_Initial_Setup.md @@ -7,7 +7,7 @@ ### 1. 기술 스택 확정 및 초기화 - **언어 및 런타임**: Node.js, TypeScript - **프레임워크**: discord.js (v14+) -- **데이터베이스**: Prisma (PostgreSQL) + Redis (캐싱 및 동기화) +- **데이터베이스**: Prisma (PostgreSQL) - **빌드 도구**: ts-node, tsx, tsc ### 2. 프로젝트 기본 구조 설계 diff --git a/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md b/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md index 67c9553..e3e93c9 100644 --- a/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md +++ b/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md @@ -38,6 +38,6 @@ - **퇴장 시 채널 미삭제 및 유령 방 버그**: - *문제*: 채널 내에 음악봇 등 봇만 남았을 때 삭제 조건이 작동하지 않고, 권한 문제로 삭제가 실패했을 때 DB 무결성이 깨지는 버그. - *해결*: 휴먼 카운트(`humanCount`) 도입 및 삭제 롤백 검증 처리. 자세한 사항은 [handleLeave_ghost_channel.md](../Troubleshooting/handleLeave_ghost_channel.md) 참조. -- **멀티 인스턴스 대응 및 봇 재부팅 복구 (Boot Recovery)**: - - *사유*: 봇 재시작, 크래시, 혹은 다중 노드(Multi-Instance) 환경에서 DB와 디스코드 실제 채널 상태가 어긋나게 될 경우를 방지. - - *해결*: `ioredis` 분산 락(Distributed Lock)과 결합된 `VoiceService.syncChannels` 메서드를 구현하여, 부팅 시 딱 하나의 인스턴스만 DB를 훑으며 삭제되어야 할 유령 방들을 크로스체크 및 안전하게 청소(Garbage Collection)하도록 반영. +- **봇 재부팅 복구 (Boot Recovery)**: + - *사유*: 봇 재시작·크래시 등으로 DB와 디스코드 실제 채널 상태가 어긋나게 될 경우를 방지. + - *해결*: `VoiceService.syncChannels`로 부팅 시 DB를 기준으로 유령 방을 크로스체크 및 청소(Garbage Collection)하도록 반영. (다중 인스턴스 동시 실행 시 동일 작업이 겹칠 수 있으나 작업은 멱등에 가깝게 설계됨.) diff --git a/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md b/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md index cb2e422..bed2600 100644 --- a/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md +++ b/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md @@ -1,4 +1,4 @@ -# 2026-03-31 낚시 미니게임 Phase 1 구현 +# 2026-03-31 낚시 미니게임 Phase 1 구현 ## 개요 diff --git a/Docs/index.md b/Docs/index.md index 9589b81..e21659b 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -1,4 +1,4 @@ -# Kord Documentation Index +# Kord Documentation Index 이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다. diff --git a/README.md b/README.md index 6352554..6558780 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,13 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입 ## 1. 개요 (Overview) -**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)와 Redis를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다. +**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다. ## 2. 요구사항 (Requirements) - **Runtime**: Node.js v20 이상 - **Package Manager**: Yarn v4 (Berry) - **Database**: PostgreSQL (Prisma 사용) -- **Cache**: Redis (다중 인스턴스 동기화 및 캐싱) - **Discord**: Bot Token 및 Client ID (Slash Command 등록용) ## 3. 테스트 방법 (Test Methods) @@ -59,7 +58,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입 1. **빌드**: `yarn build` 2. **실행**: `yarn start` -3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB, Redis)을 실행할 수 있습니다. +3. **Docker**: `docker-compose up -d`를 통해 PostgreSQL 등 로컬 인프라를 실행할 수 있습니다. ## 5. 기능 목록 (Feature List) diff --git a/docker-compose.yml b/docker-compose.yml index 96a7b81..52e9837 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,15 +14,5 @@ services: - postgres_data:/var/lib/postgresql/data restart: unless-stopped - redis: - image: redis:7-alpine - container_name: kord-redis - ports: - - "6379:6379" - volumes: - - redis_data:/data - restart: unless-stopped - volumes: postgres_data: - redis_data: diff --git a/package.json b/package.json index 08bea9b..78f03d4 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "discord.js": "^14.25.1", "dotenv": "^17.3.1", "ffmpeg-static": "^5.3.0", - "ioredis": "^5.10.1", + "log4js": "^6.9.1", "pg": "^8.20.0", "prism-media": "^1.3.5", "sharp": "^0.34.5", diff --git a/scripts/setup-kord-user-log-file.sh b/scripts/setup-kord-user-log-file.sh new file mode 100644 index 0000000..bd725f0 --- /dev/null +++ b/scripts/setup-kord-user-log-file.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Run ON THE SERVER as the same user that runs kord (e.g. psa), after: ssh psa@server +# Switches kord user service from journal-only to append stdout/stderr under LOG_DIR/kord.log +# LOG_DIR is read from $KORD_HOME/.env (LOG_DIR=...) when present, else $KORD_HOME/logs. + +set -euo pipefail + +KORD_HOME="${KORD_HOME:-$HOME/kord}" +ENV_FILE="${KORD_ENV_FILE:-$KORD_HOME/.env}" +UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/kord.service" + +# Last LOG_DIR= line from .env; strip quotes and ~ ; relative paths are under KORD_HOME +resolve_log_dir() { + local default="${KORD_HOME}/logs" line raw + [[ -f "$ENV_FILE" ]] || { echo "$default"; return; } + line="$(grep -E '^[[:space:]]*LOG_DIR[[:space:]]*=' "$ENV_FILE" | tail -n1 || true)" + [[ -z "$line" ]] && { echo "$default"; return; } + raw="${line#*=}" + raw="$(printf '%s' "$raw" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e $'s/\r$//')" + if [[ "$raw" =~ ^\".*\"$ ]]; then raw="${raw#\"}"; raw="${raw%\"}"; fi + if [[ "$raw" =~ ^\'.*\'$ ]]; then raw="${raw#\'}"; raw="${raw%\'}"; fi + raw="${raw//\~/$HOME}" + [[ -z "$raw" ]] && { echo "$default"; return; } + if [[ "$raw" = /* ]]; then + echo "$raw" + else + echo "${KORD_HOME}/${raw#./}" + fi +} + +LOG_DIR="$(resolve_log_dir)" +LOG_FILE="${LOG_DIR}/kord.log" + +mkdir -p "$LOG_DIR" + +if [[ ! -f "$UNIT" ]]; then + echo "Unit not found: $UNIT" >&2 + exit 1 +fi + +cp -a "$UNIT" "${UNIT}.bak.$(date +%Y%m%d%H%M%S)" + +# Point journal or any previous append paths at the log file derived from .env LOG_DIR +sed -i \ + -e "s|^StandardOutput=journal|StandardOutput=append:${LOG_FILE}|" \ + -e "s|^StandardError=journal|StandardError=append:${LOG_FILE}|" \ + "$UNIT" +sed -i \ + -e "s|^StandardOutput=append:.*|StandardOutput=append:${LOG_FILE}|" \ + -e "s|^StandardError=append:.*|StandardError=append:${LOG_FILE}|" \ + "$UNIT" + +# systemd opens StandardOutput=append before ExecStart; missing parent dir → status 209/STDOUT +sed -i '/^ExecStartPre=-\/usr\/bin\/mkdir -p /d' "$UNIT" +tmp="$(mktemp)" +inserted=0 +while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^ExecStart= ]] && [[ "$inserted" -eq 0 ]]; then + printf '%s\n' "ExecStartPre=-/usr/bin/mkdir -p ${LOG_DIR}" + inserted=1 + fi + printf '%s\n' "$line" +done <"$UNIT" >"$tmp" +mv "$tmp" "$UNIT" + +systemctl --user daemon-reload +systemctl --user restart kord + +sleep 2 +systemctl --user --no-pager status kord || true + +echo "--- Last lines of $LOG_FILE (if any) ---" +if [[ -f "$LOG_FILE" ]]; then + tail -n 20 "$LOG_FILE" +else + echo "(file not created yet; check status above)" +fi + +echo +echo "LOG_DIR=$LOG_DIR" +echo "Follow logs: tail -f $LOG_FILE" diff --git a/src/cache/index.ts b/src/cache/index.ts deleted file mode 100644 index 3f0517a..0000000 --- a/src/cache/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import Redis from 'ioredis'; -import { env } from '../config/env'; -import { logger } from '../utils/logger'; - -export const redis = new Redis({ - host: env.REDIS_HOST, - port: env.REDIS_PORT, - lazyConnect: true, -}); - -redis.on('error', (err) => { - logger.error('Redis Error:', err); -}); - -export const connectRedis = async () => { - try { - await redis.connect(); - logger.info('Connected to Redis successfully.'); - } catch (error) { - logger.error('Failed to connect to Redis:', error); - process.exit(1); - } -}; diff --git a/src/client/KordClient.ts b/src/client/KordClient.ts index 7c11ee5..13145e3 100644 --- a/src/client/KordClient.ts +++ b/src/client/KordClient.ts @@ -5,7 +5,6 @@ import { loadCommands } from '../handlers/CommandLoader'; import { loadEvents } from '../handlers/EventLoader'; import { handleGlobalExceptions } from '../utils/errorHandler'; import { connectDB } from '../database'; -import { connectRedis } from '../cache'; import { FeverService } from '../services/FeverService'; export class KordClient extends Client { @@ -29,7 +28,6 @@ export class KordClient extends Client { // Connect to external services await connectDB(); - await connectRedis(); // Load Handlers await loadCommands(this); diff --git a/src/commands/audit.ts b/src/commands/audit.ts index c680994..7ae9409 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -118,9 +118,8 @@ export default { if (action === 'set') { const channel = interaction.options.getChannel('channel') as TextChannel; - if (!channel) return interaction.reply({ content: '❌ `channel` 옵션을 선택해주세요.', ephemeral: true }); + if (!channel) return interaction.editReply({ content: '❌ `channel` 옵션을 선택해주세요.' }); - await interaction.deferReply({ ephemeral: true }); const botMember = guild.members.me; if (!botMember) return; const perms = channel.permissionsFor(botMember); @@ -141,13 +140,11 @@ export default { } if (action === 'clear') { - await interaction.deferReply({ ephemeral: true }); await auditLogService.clearChannel(guild.id); return interaction.editReply({ content: `✅ 감사 채널 설정이 해제되었습니다.` }); } if (action === 'status') { - await interaction.deferReply({ ephemeral: true }); const config = await auditLogService.getChannel(guild.id); if (!config) return interaction.editReply({ content: `설정된 감사 채널이 없습니다. 먼저 \`/audit channel action:set\` 명령어로 채널을 설정해주세요.` }); @@ -168,10 +165,9 @@ export default { const enable = interaction.options.getBoolean('enable'); if (!category || enable === null) { - return interaction.reply({ content: '❌ `category` 및 `enable` 옵션을 모두 입력해주세요.', ephemeral: true }); + return interaction.editReply({ content: '❌ `category` 및 `enable` 옵션을 모두 입력해주세요.' }); } - await interaction.deferReply({ ephemeral: true }); const config = await auditLogService.getChannel(guild.id); if (!config) return interaction.editReply({ content: `설정된 감사 채널이 없습니다. 먼저 \`/audit channel action:set\` 명령어로 채널을 설정해주세요.` }); @@ -185,7 +181,6 @@ export default { const action = interaction.options.getString('action', true); if (action === 'permissions') { - await interaction.deferReply({ ephemeral: true }); const results = await PermissionAuditService.auditGuild(guild); if (results.length === 0) return interaction.editReply({ content: t(locale, 'commands.permissionAudit.noResults') }); @@ -230,8 +225,7 @@ export default { } } catch (error) { console.error('Error in audit command', error); - const reply = interaction.deferred ? interaction.editReply : interaction.reply; - return (reply as any)({ content: '명령 실행 중 오류가 발생했습니다.', ephemeral: true }); + return interaction.editReply({ content: '명령 실행 중 오류가 발생했습니다.' }); } }, }; diff --git a/src/commands/config.ts b/src/commands/config.ts index 3d687c2..66d7c42 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -76,14 +76,13 @@ export default { if (action === 'language') { const newLocale = interaction.options.getString('locale') as SupportedLocale; if (!newLocale) { - return interaction.reply({ + return interaction.editReply({ content: '❌ `locale` 옵션을 선택해주세요.', - ephemeral: true }); } if (!SUPPORTED_LOCALES.includes(newLocale)) { - return interaction.reply({ content: `Unsupported locale: ${newLocale}`, ephemeral: true }); + return interaction.editReply({ content: `Unsupported locale: ${newLocale}` }); } await prisma.guildConfig.upsert({ @@ -92,9 +91,8 @@ export default { create: { guildId: interaction.guildId, locale: newLocale }, }); - return interaction.reply({ + return interaction.editReply({ content: t(newLocale, 'commands.language.serverSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }), - ephemeral: true, }); } } @@ -132,7 +130,7 @@ export default { .setTitle(t(locale, 'commands.config.title')) .setDescription(`${label}: **${state}**`); - return interaction.reply({ embeds: [embed], ephemeral: true }); + return interaction.editReply({ embeds: [embed] }); } }, }; diff --git a/src/commands/event.ts b/src/commands/event.ts index 7986e4c..411ba87 100644 --- a/src/commands/event.ts +++ b/src/commands/event.ts @@ -201,26 +201,23 @@ export default { const startsAt = parseSeoulDateTime(date, time); if (!startsAt) { - await interaction.reply({ + await interaction.editReply({ content: `${t(locale, 'commands.event.invalidDateTime')}\n${t(locale, 'commands.event.invalidDateTimeResolution')}`, - ephemeral: true, }); return; } if (startsAt.getTime() <= Date.now()) { - await interaction.reply({ + await interaction.editReply({ content: `${t(locale, 'commands.event.invalidPastDateTime')}\n${t(locale, 'commands.event.invalidPastDateTimeResolution')}`, - ephemeral: true, }); return; } const reminderOffsets = parseReminderOffsets(reminderRaw); if (!reminderOffsets) { - await interaction.reply({ + await interaction.editReply({ content: `${t(locale, 'commands.event.invalidReminderOffsets')}\n${t(locale, 'commands.event.invalidReminderOffsetsResolution')}`, - ephemeral: true, }); return; } @@ -252,7 +249,7 @@ export default { ) .setTimestamp(); - await interaction.reply({ embeds: [embed], ephemeral: true }); + await interaction.editReply({ embeds: [embed] }); return; } @@ -268,9 +265,8 @@ export default { }); if (events.length === 0) { - await interaction.reply({ + await interaction.editReply({ content: t(locale, 'commands.event.listEmpty'), - ephemeral: true, }); return; } @@ -295,7 +291,7 @@ export default { }); } - await interaction.reply({ embeds: [embed], ephemeral: true }); + await interaction.editReply({ embeds: [embed] }); return; } @@ -311,9 +307,8 @@ export default { }); if (!event) { - await interaction.reply({ + await interaction.editReply({ content: t(locale, 'commands.event.cancelNotFound', { id }), - ephemeral: true, }); return; } @@ -323,9 +318,8 @@ export default { data: { status: 'CANCELLED' }, }); - await interaction.reply({ + await interaction.editReply({ content: t(locale, 'commands.event.cancelSuccess', { id }), - ephemeral: true, }); return; } @@ -340,34 +334,30 @@ export default { }); if (!event) { - await interaction.reply({ + await interaction.editReply({ content: t(locale, 'commands.event.cancelNotFound', { id }), - ephemeral: true, }); return; } if (!event.announcementChannelId) { - await interaction.reply({ + await interaction.editReply({ content: t(locale, 'commands.event.announceNotAvailable'), - ephemeral: true, }); return; } try { await EventService.announceEvent(interaction.guild!, event.id); - await interaction.reply({ + await interaction.editReply({ content: t(locale, 'commands.event.announceSuccess', { id, channel: `<#${event.announcementChannelId}>`, }), - ephemeral: true, }); } catch { - await interaction.reply({ + await interaction.editReply({ content: t(locale, 'commands.event.announceNotAvailable'), - ephemeral: true, }); } } diff --git a/src/commands/fishing.ts b/src/commands/fishing.ts index e6f08d7..e693a96 100644 --- a/src/commands/fishing.ts +++ b/src/commands/fishing.ts @@ -89,8 +89,6 @@ export default { const subcommand = interaction.options.getSubcommand(); if (subcommand === 'enter') { - await interaction.deferReply({ ephemeral: true }); - if (!config || !config.enabled) { await interaction.editReply({ content: t(locale, 'commands.fishing.disabled'), @@ -124,8 +122,6 @@ export default { } if (subcommand === 'cast') { - await interaction.deferReply({ ephemeral: true }); - if (!config || !config.enabled) { await interaction.editReply({ content: t(locale, 'commands.fishing.disabled'), @@ -150,8 +146,6 @@ export default { } if (subcommand === 'end') { - await interaction.deferReply({ ephemeral: true }); - const ended = await FishingService.endThreadByUser(interaction, locale); if (!ended) { await interaction.editReply({ diff --git a/src/commands/language.ts b/src/commands/language.ts index 476670c..7701053 100644 --- a/src/commands/language.ts +++ b/src/commands/language.ts @@ -27,7 +27,7 @@ export default { // Validate locale (safety check) if (!SUPPORTED_LOCALES.includes(newLocale)) { - await interaction.reply({ content: `Unsupported locale: ${newLocale}`, ephemeral: true }); + await interaction.editReply({ content: `Unsupported locale: ${newLocale}` }); return; } @@ -38,9 +38,8 @@ export default { }); // Respond in the NEWLY selected locale - await interaction.reply({ + await interaction.editReply({ content: t(newLocale, 'commands.language.userSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }), - ephemeral: true, }); }, }; diff --git a/src/commands/minigame.ts b/src/commands/minigame.ts index 7fb9086..d57085f 100644 --- a/src/commands/minigame.ts +++ b/src/commands/minigame.ts @@ -81,7 +81,7 @@ export default { .setTitle('🎮 미니게임 설정 변경') .setDescription(`${game.name} 미니게임이 **${state}**되었습니다.`); - return interaction.reply({ embeds: [embed], ephemeral: true }); + return interaction.editReply({ embeds: [embed] }); } if (subcommand === 'status') { @@ -106,7 +106,7 @@ export default { }); }); - return interaction.reply({ embeds: [embed], ephemeral: true }); + return interaction.editReply({ embeds: [embed] }); } if (subcommand === 'channel') { @@ -127,7 +127,7 @@ export default { .setTitle('🎮 미니게임 채널 설정') .setDescription(`${game.name} 미니게임 전용 채널이 **${channelMsg}**로 설정되었습니다.`); - return interaction.reply({ embeds: [embed], ephemeral: true }); + return interaction.editReply({ embeds: [embed] }); } }, }; diff --git a/src/commands/music.ts b/src/commands/music.ts index 0beb3c4..afb7514 100644 --- a/src/commands/music.ts +++ b/src/commands/music.ts @@ -1,4 +1,4 @@ -import { ChatInputCommandInteraction, GuildMember, SlashCommandBuilder } from 'discord.js'; +import { ChatInputCommandInteraction, GuildMember, SlashCommandBuilder } from 'discord.js'; import { SupportedLocale, t } from '../i18n'; import { MusicService } from '../services/MusicService'; import { logger } from '../utils/logger'; @@ -26,7 +26,7 @@ async function respond( return; } - await interaction.reply({ content, ephemeral }); + await interaction.editReply({ content }); } export default { @@ -136,33 +136,28 @@ export default { const url = interaction.options.getString('url'); if ((!query && !url) || (query && url)) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'addMutuallyExclusive'), - ephemeral: true, }); return; } const member = interaction.member as GuildMember; if (!member.voice.channel) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), - ephemeral: true, }); return; } const textChannel = interaction.channel as any; if (!textChannel?.send) { - await interaction.reply({ + await interaction.editReply({ content: t(locale, 'errors.E3003.userMessage'), - ephemeral: true, }); return; } - await interaction.deferReply(); - const result = query ? await MusicService.addFromQuery(member, textChannel, query, locale) : await MusicService.addFromUrl(member, textChannel, url!, locale); @@ -191,7 +186,7 @@ export default { } if (subcommand === 'queue') { - await interaction.reply({ + await interaction.editReply({ embeds: [MusicService.getQueueEmbed(interaction.guildId!, locale)], }); return; @@ -200,18 +195,16 @@ export default { if (subcommand === 'remove') { const member = interaction.member as GuildMember; if (!member.voice.channel) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), - ephemeral: true, }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), - ephemeral: true, }); return; } @@ -219,14 +212,13 @@ export default { const index = interaction.options.getInteger('index', true); const removed = await MusicService.remove(interaction.guildId!, index); if (!removed) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), - ephemeral: true, }); return; } - await interaction.reply({ + await interaction.editReply({ content: t(locale, 'commands.music.queueRemoved', { title: removed.title, }), @@ -237,160 +229,145 @@ export default { if (subcommand === 'pause') { const member = interaction.member as GuildMember; if (!member.voice.channel) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), - ephemeral: true, }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), - ephemeral: true, }); return; } const paused = await MusicService.pause(interaction.guildId!, locale); if (!paused) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), - ephemeral: true, }); return; } - await interaction.reply({ content: t(locale, 'commands.music.pauseSuccess') }); + await interaction.editReply({ content: t(locale, 'commands.music.pauseSuccess') }); return; } if (subcommand === 'resume') { const member = interaction.member as GuildMember; if (!member.voice.channel) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), - ephemeral: true, }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), - ephemeral: true, }); return; } const resumed = await MusicService.resume(interaction.guildId!, locale); if (!resumed) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), - ephemeral: true, }); return; } - await interaction.reply({ content: t(locale, 'commands.music.resumeSuccess') }); + await interaction.editReply({ content: t(locale, 'commands.music.resumeSuccess') }); return; } if (subcommand === 'skip') { const member = interaction.member as GuildMember; if (!member.voice.channel) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), - ephemeral: true, }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), - ephemeral: true, }); return; } const skipped = await MusicService.skip(interaction.guildId!); if (!skipped) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), - ephemeral: true, }); return; } - await interaction.reply({ content: t(locale, 'commands.music.skipSuccess') }); + await interaction.editReply({ content: t(locale, 'commands.music.skipSuccess') }); return; } if (subcommand === 'stop') { const member = interaction.member as GuildMember; if (!member.voice.channel) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), - ephemeral: true, }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), - ephemeral: true, }); return; } const stopped = await MusicService.stop(interaction.guildId!, locale); if (!stopped) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), - ephemeral: true, }); return; } - await interaction.reply({ content: t(locale, 'commands.music.stopSuccess') }); + await interaction.editReply({ content: t(locale, 'commands.music.stopSuccess') }); return; } if (subcommand === 'leave') { const member = interaction.member as GuildMember; if (!member.voice.channel) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'notInVoice'), - ephemeral: true, }); return; } const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!); if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'differentVoiceChannel'), - ephemeral: true, }); return; } const left = await MusicService.leave(interaction.guildId!, locale); if (!left) { - await interaction.reply({ + await interaction.editReply({ content: buildErrorMessage(locale, 'noActiveSession'), - ephemeral: true, }); return; } - await interaction.reply({ content: t(locale, 'commands.music.leaveSuccess') }); + await interaction.editReply({ content: t(locale, 'commands.music.leaveSuccess') }); } } catch (error) { logger.error('Error in music command:', error); @@ -417,15 +394,7 @@ export default { return; } - if (interaction.replied || interaction.deferred) { - await respond(interaction, t(locale, 'errors.E3003.userMessage'), true); - return; - } - - await interaction.reply({ - content: t(locale, 'errors.E3003.userMessage'), - ephemeral: true, - }); + await respond(interaction, t(locale, 'errors.E3003.userMessage'), true); } }, }; diff --git a/src/commands/refine.ts b/src/commands/refine.ts index b576f9e..8022c5a 100644 --- a/src/commands/refine.ts +++ b/src/commands/refine.ts @@ -97,11 +97,11 @@ export default { }); if (!config || !config.enabled) { - return interaction.reply({ content: '❌ 이 서버에서는 재련 미니게임이 비활성화되어 있습니다.', ephemeral: true }); + return interaction.editReply({ content: '❌ 이 서버에서는 재련 미니게임이 비활성화되어 있습니다.' }); } if (config.channelId && config.channelId !== interaction.channelId) { - return interaction.reply({ content: `❌ 재련 미니게임은 <#${config.channelId}> 채널에서만 이용 가능합니다.`, ephemeral: true }); + return interaction.editReply({ content: `❌ 재련 미니게임은 <#${config.channelId}> 채널에서만 이용 가능합니다.` }); } const subcommand = interaction.options.getSubcommand(); @@ -132,9 +132,9 @@ export default { const row = new ActionRowBuilder().addComponents(retryBtn); - return interaction.reply({ embeds: [embed], components: [row] }); + return interaction.editReply({ embeds: [embed], components: [row] }); } catch (err: any) { - return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true }); + return interaction.editReply({ content: `❌ 오류: ${err.message}` }); } } @@ -142,10 +142,10 @@ export default { if (subcommand === 'battle') { const targetUser = interaction.options.getUser('target', true); if (targetUser.id === interaction.user.id) { - return interaction.reply({ content: '❌ 자신을 공격할 수 없습니다.', ephemeral: true }); + return interaction.editReply({ content: '❌ 자신을 공격할 수 없습니다.' }); } if (targetUser.bot) { - return interaction.reply({ content: '❌ 봇과 전투할 수 없습니다.', ephemeral: true }); + return interaction.editReply({ content: '❌ 봇과 전투할 수 없습니다.' }); } try { @@ -170,9 +170,9 @@ export default { if (result.attackerDurability <= 0) embed.setFooter({ text: '⚠️ [경고] 무기가 전투 불능이 되었습니다. 내구도 0에서 다시 공격하면 파괴됩니다!' }); } - return interaction.reply({ embeds: [embed] }); + return interaction.editReply({ embeds: [embed] }); } catch (err: any) { - return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true }); + return interaction.editReply({ content: `❌ 오류: ${err.message}` }); } } @@ -180,9 +180,9 @@ export default { if (subcommand === 'checkin') { try { const res = await RefinementService.checkIn(interaction.user.id, interaction.guildId); - return interaction.reply({ content: `✅ 출석 완료! **${res.goldAdded} G**를 수령했습니다. (총: ${res.totalGold} G)`, ephemeral: true }); + return interaction.editReply({ content: `✅ 출석 완료! **${res.goldAdded} G**를 수령했습니다. (총: ${res.totalGold} G)` }); } catch (err: any) { - return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true }); + return interaction.editReply({ content: `❌ 오류: ${err.message}` }); } } @@ -208,7 +208,7 @@ export default { { name: '전투', value: `승리: ${profile.battleWin} | 패배: ${profile.battleLoss}`, inline: false } ); - return interaction.reply({ embeds: [embed] }); + return interaction.editReply({ embeds: [embed] }); } // --- RANKING --- @@ -243,16 +243,16 @@ export default { }).join('\n') || '데이터가 없습니다.'; embed.setDescription(listStr); - return interaction.reply({ embeds: [embed] }); + return interaction.editReply({ embeds: [embed] }); } // --- SELL --- if (subcommand === 'sell') { try { const res = await RefinementService.sellWeapon(interaction.user.id, interaction.guildId); - return interaction.reply({ content: `💰 **${res.level}단계** 무기를 판매하고 **${res.price} G**를 받았습니다. (총: ${res.gold} G)`, ephemeral: true }); + return interaction.editReply({ content: `💰 **${res.level}단계** 무기를 판매하고 **${res.price} G**를 받았습니다. (총: ${res.gold} G)` }); } catch (err: any) { - return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true }); + return interaction.editReply({ content: `❌ 오류: ${err.message}` }); } } @@ -271,7 +271,7 @@ export default { { name: '🔥 피버 타임', value: '서버가 가장 활발한 시간대에 재련 성공 확률이 10% 증가합니다!' } ); - return interaction.reply({ embeds: [embed] }); + return interaction.editReply({ embeds: [embed] }); } }, }; diff --git a/src/commands/setup.ts b/src/commands/setup.ts index d676a51..f814f23 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -19,10 +19,9 @@ export default { if (!interaction.guildId) return; const { embed, components } = await SetupWizardRenderer.renderStep(0, interaction, locale); - return interaction.reply({ + return interaction.editReply({ embeds: [embed], components, - ephemeral: true, }); }, }; diff --git a/src/commands/voice.ts b/src/commands/voice.ts index ba86aff..573b7e4 100644 --- a/src/commands/voice.ts +++ b/src/commands/voice.ts @@ -88,22 +88,21 @@ export default { if (action === 'set') { const channel = interaction.options.getChannel('channel'); - if (!channel) return interaction.reply({ content: '❌ `channel` 옵션을 선택해주세요.', ephemeral: true }); + if (!channel) return interaction.editReply({ content: '❌ `channel` 옵션을 선택해주세요.' }); await prisma.voiceGenerator.upsert({ where: { channelId: channel.id }, update: { categoryId: category?.id || null, guildId }, create: { channelId: channel.id, guildId, categoryId: category?.id || null } }); - return interaction.reply({ + return interaction.editReply({ content: t(locale, 'commands.voiceSetup.setSuccess', { channel: `${channel}` }), - ephemeral: true }); } if (action === 'create') { const name = interaction.options.getString('name'); - if (!name) return interaction.reply({ content: '❌ `name` 옵션을 입력해주세요.', ephemeral: true }); + if (!name) return interaction.editReply({ content: '❌ `name` 옵션을 입력해주세요.' }); const newChannel = await interaction.guild!.channels.create({ name, @@ -113,9 +112,8 @@ export default { await prisma.voiceGenerator.create({ data: { channelId: newChannel.id, guildId, categoryId: category?.id || null } }); - return interaction.reply({ + return interaction.editReply({ content: t(locale, 'commands.voiceSetup.createSuccess', { channel: `${newChannel}` }), - ephemeral: true }); } } @@ -126,31 +124,29 @@ export default { if (action === 'name') { const template = interaction.options.getString('template'); - if (!template) return interaction.reply({ content: '❌ `template` 옵션을 입력해주세요.', ephemeral: true }); + if (!template) return interaction.editReply({ content: '❌ `template` 옵션을 입력해주세요.' }); await prisma.voiceGuildConfig.upsert({ where: { guildId }, update: { defaultNameTemplate: template }, create: { guildId, defaultNameTemplate: template } }); - return interaction.reply({ + return interaction.editReply({ content: t(locale, 'commands.voiceConfig.setSuccess'), - ephemeral: true }); } if (action === 'limit') { const limit = interaction.options.getInteger('limit'); - if (limit === null) return interaction.reply({ content: '❌ `limit` 옵션을 입력해주세요.', ephemeral: true }); + if (limit === null) return interaction.editReply({ content: '❌ `limit` 옵션을 입력해주세요.' }); await prisma.voiceGuildConfig.upsert({ where: { guildId }, update: { defaultUserLimit: limit }, create: { guildId, defaultUserLimit: limit } }); - return interaction.reply({ + return interaction.editReply({ content: t(locale, 'commands.voiceConfig.setSuccess'), - ephemeral: true }); } @@ -171,14 +167,13 @@ export default { inline: true } ); - return interaction.reply({ embeds: [embed], ephemeral: true }); + return interaction.editReply({ embeds: [embed] }); } } } catch (error) { logger.error('Error in voice command', error); - return interaction.reply({ + return interaction.editReply({ content: t(locale, 'errors.E3003.userMessage'), - ephemeral: true }); } }, diff --git a/src/config/env.ts b/src/config/env.ts index dbb2300..046a3cf 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,6 +1,9 @@ import { config } from 'dotenv'; import { hostname } from 'os'; -config(); +import { resolve } from 'path'; + +// Prefer systemd/cron-set DOTENV_CONFIG_PATH; otherwise cwd .env (default dotenv behavior). +config({ path: process.env.DOTENV_CONFIG_PATH || resolve(process.cwd(), '.env') }); const generateInstanceId = () => { return process.env.INSTANCE_ID || hostname() || `kord-${Math.random().toString(36).substring(2, 7)}`; @@ -11,9 +14,15 @@ export const env = { DISCORD_TOKEN: process.env.DISCORD_TOKEN || '', DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '', DATABASE_URL: process.env.DATABASE_URL || '', - REDIS_HOST: process.env.REDIS_HOST || 'localhost', - REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379', 10), VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '', VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '', + /** log4js level: trace | debug | info | warn | error | fatal */ + LOG_LEVEL: process.env.LOG_LEVEL || 'info', + /** + * Directory for log4js `kord.log` (created at startup). Relative paths resolve from `process.cwd()`. + * For Jenkins or wipe-and-redeploy flows, set an absolute path **outside** the deploy tree (e.g. `/var/lib/kord/logs`) + * so logs survive redeploys and match `StandardOutput=append` in systemd if you point it at the same file. + */ + LOG_DIR: process.env.LOG_DIR || 'logs', INSTANCE_ID: generateInstanceId(), }; diff --git a/src/database/index.ts b/src/database/index.ts index 474e501..2bc929b 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -1,11 +1,36 @@ +import type { PoolConfig } from 'pg'; import { Pool } from 'pg'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; +import { env } from '../config/env'; import { logger } from '../utils/logger'; -// Prisma 7 requires a driver adapter for direct database connections. -const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -const adapter = new PrismaPg(pool); +/** + * `?schema=` in DATABASE_URL is a Prisma URL extension. node-postgres does not apply it, + * so connections default to `search_path=public`. PrismaPg also needs an explicit schema + * option in some setups (see prisma/prisma#28611). + */ +function createPgPoolConfig(connectionString: string): { poolConfig: PoolConfig; prismaSchema?: string } { + if (!connectionString) { + return { poolConfig: { connectionString: '' } }; + } + try { + const url = new URL(connectionString); + const schema = url.searchParams.get('schema')?.trim(); + const poolConfig: PoolConfig = { connectionString }; + if (schema) { + const escaped = schema.replace(/"/g, '""'); + poolConfig.options = `-c search_path="${escaped}"`; + } + return { poolConfig, prismaSchema: schema || undefined }; + } catch { + return { poolConfig: { connectionString } }; + } +} + +const { poolConfig, prismaSchema } = createPgPoolConfig(env.DATABASE_URL); +const pool = new Pool(poolConfig); +const adapter = prismaSchema ? new PrismaPg(pool, { schema: prismaSchema }) : new PrismaPg(pool); export const prisma = new PrismaClient({ adapter, diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 663a3a9..ef0a16f 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -18,6 +18,8 @@ export default { const command = client.commands.get(interaction.commandName); if (!command) return; + // Acknowledge before locale DB reads so Discord's ~3s interaction window is never missed. + await interaction.deferReply({ ephemeral: true }); const locale = await getInteractionLocale(interaction); await withErrorHandler(interaction, async () => { await command.execute(interaction, locale); diff --git a/src/events/ready.ts b/src/events/ready.ts index 427477d..c61f276 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,5 +1,4 @@ import { Events } from 'discord.js'; -import { createHash } from 'crypto'; import { KordClient } from '../client/KordClient'; import { logger } from '../utils/logger'; import { InviteService } from '../services/InviteService'; @@ -7,7 +6,6 @@ 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'; export default { @@ -22,19 +20,8 @@ export default { try { const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON()); - const commandsHash = createHash('sha256') - .update(JSON.stringify(commandsData)) - .digest('hex'); - const lockKey = `commands:sync:lock:${commandsHash}`; - // Lock per command definition set so updated commands can still sync on the next boot. - const acquired = await redis.set(lockKey, '1', 'EX', 300, 'NX'); - - if (acquired) { - await client.application?.commands.set(commandsData); - logger.info(`Successfully registered ${commandsData.length} global application commands. hash=${commandsHash}`); - } else { - logger.info(`Global commands registration skipped for hash=${commandsHash} (already handled by another instance).`); - } + await client.application?.commands.set(commandsData); + logger.info(`Successfully registered ${commandsData.length} global application commands.`); } catch (e) { logger.error('Failed to register global commands', e); } diff --git a/src/i18n/localeHelper.ts b/src/i18n/localeHelper.ts index 663fd8d..c3391d6 100644 --- a/src/i18n/localeHelper.ts +++ b/src/i18n/localeHelper.ts @@ -23,21 +23,20 @@ export async function getInteractionLocale(interaction: Interaction): Promise(); + public static async cacheAllInvites(client: Client) { for (const [, guild] of client.guilds.cache) { await this.cacheGuildInvites(guild); @@ -18,7 +20,7 @@ export class InviteService { code: inv.code, uses: inv.uses || 0 })); - await redis.set(`invites:${guild.id}`, JSON.stringify(inviteData)); + this.inviteCache.set(guild.id, JSON.stringify(inviteData)); } catch (error) { logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error); } @@ -41,7 +43,7 @@ export class InviteService { try { // Fetch current active invites const newInvites = await guild.invites.fetch(); - const cachedData = await redis.get(`invites:${guild.id}`); + const cachedData = this.inviteCache.get(guild.id); let usedInvite: Invite | undefined; diff --git a/src/services/MusicService.ts b/src/services/MusicService.ts index a2a2f69..23658d8 100644 --- a/src/services/MusicService.ts +++ b/src/services/MusicService.ts @@ -1,4 +1,4 @@ -import { +import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index e23a7e7..c3968a4 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -1,7 +1,6 @@ import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js'; import { prisma } from '../database'; import { logger } from '../utils/logger'; -import { redis } from '../cache'; import { ErrorDefs } from '../errors/ErrorCodes'; import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n'; import { getContextLocale } from '../i18n/localeHelper'; @@ -9,56 +8,43 @@ import { auditLogService } from './AuditLogService'; export class VoiceService { public static async syncChannels(client: Client) { - const lockKey = 'voice:sync:lock'; - // NX = only set if not exists, EX = expire in 60s - const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX'); - if (!acquired) { - logger.info('VoiceService: Another instance is already syncing channels. Skipping.'); - return; - } + logger.info('VoiceService: Starting channel synchronization...'); + const channels = await prisma.tempVoiceChannel.findMany(); - try { - logger.info('VoiceService: Starting channel synchronization...'); - const channels = await prisma.tempVoiceChannel.findMany(); - - for (const temp of channels) { - try { - const guild = client.guilds.cache.get(temp.guildId) || await client.guilds.fetch(temp.guildId).catch(() => null); - if (!guild) continue; + for (const temp of channels) { + try { + const guild = client.guilds.cache.get(temp.guildId) || await client.guilds.fetch(temp.guildId).catch(() => null); + if (!guild) continue; - const channel = guild.channels.cache.get(temp.channelId) || await guild.channels.fetch(temp.channelId).catch(() => null); - - if (!channel || channel.type !== ChannelType.GuildVoice) { - await prisma.tempVoiceChannel.delete({ where: { channelId: temp.channelId } }); - logger.info(`VoiceService: Purged missing Discord channel ${temp.channelId} from DB`); - continue; - } + const channel = guild.channels.cache.get(temp.channelId) || await guild.channels.fetch(temp.channelId).catch(() => null); - const voiceChannel = channel as VoiceChannel; - const humanCount = voiceChannel.members.filter(m => !m.user.bot).size; - - let shouldDelete = false; - if (temp.deleteWhen === 'EMPTY' && humanCount === 0) { - shouldDelete = true; - } else if (temp.deleteWhen === 'OWNER_LEAVE') { - const ownerInChannel = voiceChannel.members.has(temp.ownerId); - if (!ownerInChannel) shouldDelete = true; - } - - if (shouldDelete) { - await voiceChannel.delete().catch(() => {}); - await prisma.tempVoiceChannel.delete({ where: { channelId: temp.channelId } }).catch(() => {}); - logger.info(`VoiceService: Cleaned up orphaned channel ${voiceChannel.name} during boot`); - } - } catch (error) { - logger.error(`VoiceService: Error syncing channel ${temp.channelId}`, error); + if (!channel || channel.type !== ChannelType.GuildVoice) { + await prisma.tempVoiceChannel.delete({ where: { channelId: temp.channelId } }); + logger.info(`VoiceService: Purged missing Discord channel ${temp.channelId} from DB`); + continue; } + + const voiceChannel = channel as VoiceChannel; + const humanCount = voiceChannel.members.filter(m => !m.user.bot).size; + + let shouldDelete = false; + if (temp.deleteWhen === 'EMPTY' && humanCount === 0) { + shouldDelete = true; + } else if (temp.deleteWhen === 'OWNER_LEAVE') { + const ownerInChannel = voiceChannel.members.has(temp.ownerId); + if (!ownerInChannel) shouldDelete = true; + } + + if (shouldDelete) { + await voiceChannel.delete().catch(() => {}); + await prisma.tempVoiceChannel.delete({ where: { channelId: temp.channelId } }).catch(() => {}); + logger.info(`VoiceService: Cleaned up orphaned channel ${voiceChannel.name} during boot`); + } + } catch (error) { + logger.error(`VoiceService: Error syncing channel ${temp.channelId}`, error); } - logger.info('VoiceService: Channel synchronization complete.'); - } finally { - // Free the lock just in case, though EX ensures it doesn't hang forever - await redis.del(lockKey).catch(() => {}); } + logger.info('VoiceService: Channel synchronization complete.'); } public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { const member = newState.member; diff --git a/src/services/WebhookService.ts b/src/services/WebhookService.ts index 25dbec7..749f799 100644 --- a/src/services/WebhookService.ts +++ b/src/services/WebhookService.ts @@ -1,18 +1,21 @@ import { TextChannel, WebhookClient } from 'discord.js'; import { logger } from '../utils/logger'; -import { redis } from '../cache'; export class WebhookService { private static readonly MAX_WEBHOOKS = 10; private static readonly WEBHOOK_NAME = 'Kord Mimic Webhook'; + private static readonly WEBHOOK_CACHE_TTL_MS = 86400 * 1000; + private static readonly webhookCache = new Map< + string, + { id: string; token: string; expiresAt: number } + >(); public static async getWebhookClient(channel: TextChannel): Promise { try { - // 1. Check cache - const cachedData = await redis.get(`webhook:${channel.id}`); - if (cachedData) { - const { id, token } = JSON.parse(cachedData); - return new WebhookClient({ id, token }); + const now = Date.now(); + const cached = this.webhookCache.get(channel.id); + if (cached && now < cached.expiresAt) { + return new WebhookClient({ id: cached.id, token: cached.token }); } // 2. Fetch from Discord API @@ -40,14 +43,12 @@ export class WebhookService { logger.info(`Created new webhook for channel ${channel.id}`); } - // 3. Save to Redis Cache (expire in 1 day to ensure token freshness) if (kordWebhook.token) { - await redis.set( - `webhook:${channel.id}`, - JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), - 'EX', - 86400 - ); + this.webhookCache.set(channel.id, { + id: kordWebhook.id, + token: kordWebhook.token, + expiresAt: Date.now() + this.WEBHOOK_CACHE_TTL_MS, + }); return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token }); } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 36cf729..ac21e7a 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,6 +1,65 @@ -export const logger = { - info: (...args: any[]) => console.log('\x1b[36m[INFO]\x1b[0m', ...args), - warn: (...args: any[]) => console.log('\x1b[33m[WARN]\x1b[0m', ...args), - error: (...args: any[]) => console.error('\x1b[31m[ERROR]\x1b[0m', ...args), - debug: (...args: any[]) => console.debug('\x1b[90m[DEBUG]\x1b[0m', ...args), -}; +import { mkdirSync } from 'fs'; +import log4js from 'log4js'; +import { resolve } from 'path'; +import { env } from '../config/env'; + +const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const; +type LogLevel = (typeof LOG_LEVELS)[number]; + +function resolveLogLevel(): LogLevel { + const raw = env.LOG_LEVEL.toLowerCase(); + return (LOG_LEVELS as readonly string[]).includes(raw) ? (raw as LogLevel) : 'info'; +} + +/** Resolves LOG_DIR from .env: absolute paths unchanged; relative paths from cwd. */ +function resolveLogDir(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return resolve('logs'); + } + return resolve(trimmed); +} + +function ensureLogDir(dir: string): void { + try { + mkdirSync(dir, { recursive: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[kord] Failed to create LOG_DIR "${dir}": ${msg}\n`); + throw err; + } +} + +const logDir = resolveLogDir(env.LOG_DIR); +const level = resolveLogLevel(); + +ensureLogDir(logDir); + +log4js.configure({ + appenders: { + file: { + type: 'dateFile', + filename: resolve(logDir, 'kord.log'), + pattern: 'yyyy-MM-dd', + alwaysIncludePattern: true, + numBackups: 7, + layout: { + type: 'pattern', + pattern: '%d{yyyy-MM-dd hh:mm:ss.SSS} [%p] %m', + }, + }, + }, + categories: { + default: { appenders: ['file'], level }, + }, +}); + +process.on('exit', () => { + try { + log4js.shutdown(); + } catch { + // ignore + } +}); + +export const logger = log4js.getLogger(); diff --git a/tests/services/FishingService.test.ts b/tests/services/FishingService.test.ts index 11eaff6..36c6e2f 100644 --- a/tests/services/FishingService.test.ts +++ b/tests/services/FishingService.test.ts @@ -1,4 +1,4 @@ -import { buildFishingGauge, buildFishingLane } from '../../src/services/FishingService'; +import { buildFishingGauge, buildFishingLane } from '../../src/services/FishingService'; describe('FishingService helpers', () => { it('renders a gauge with filled and empty segments', () => { diff --git a/yarn.lock b/yarn.lock index 7af0237..034a1b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1120,13 +1120,6 @@ __metadata: languageName: node linkType: hard -"@ioredis/commands@npm:1.5.1": - version: 1.5.1 - resolution: "@ioredis/commands@npm:1.5.1" - checksum: 10c0/cb8f6d13cff0753e3e7ef001fb895491985d9a623248192538f13bc2fd9bfdfde3c18cf2ba6f20ec8ceaa681b0771070d3a09b82eed044c798bcfef5e3ae54b3 - languageName: node - linkType: hard - "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -3017,13 +3010,6 @@ __metadata: languageName: node linkType: hard -"cluster-key-slot@npm:^1.1.0": - version: 1.1.2 - resolution: "cluster-key-slot@npm:1.1.2" - checksum: 10c0/d7d39ca28a8786e9e801eeb8c770e3c3236a566625d7299a47bb71113fb2298ce1039596acb82590e598c52dbc9b1f088c8f587803e697cb58e1867a95ff94d3 - languageName: node - linkType: hard - "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -3121,6 +3107,13 @@ __metadata: languageName: node linkType: hard +"date-format@npm:^4.0.14": + version: 4.0.14 + resolution: "date-format@npm:4.0.14" + checksum: 10c0/1c67a4d77c677bb880328c81d81f5b9ed7fbf672bdaff74e5a0f7314b21188f3a829b06acf120c70cc1df876a7724e3e5c23d511e86d64656a3035a76ac3930b + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" @@ -3727,7 +3720,7 @@ __metadata: languageName: node linkType: hard -"flatted@npm:^3.2.9": +"flatted@npm:^3.2.7, flatted@npm:^3.2.9": version: 3.4.2 resolution: "flatted@npm:3.4.2" checksum: 10c0/a65b67aae7172d6cdf63691be7de6c5cd5adbdfdfe2e9da1a09b617c9512ed794037741ee53d93114276bff3f93cd3b0d97d54f9b316e1e4885dde6e9ffdf7ed @@ -3744,6 +3737,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^8.1.0": + version: 8.1.0 + resolution: "fs-extra@npm:8.1.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^4.0.0" + universalify: "npm:^0.1.0" + checksum: 10c0/259f7b814d9e50d686899550c4f9ded85c46c643f7fe19be69504888e007fcbc08f306fae8ec495b8b998635e997c9e3e175ff2eeed230524ef1c1684cc96423 + languageName: node + linkType: hard + "fs-minipass@npm:^2.0.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" @@ -3924,7 +3928,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -4110,23 +4114,6 @@ __metadata: languageName: node linkType: hard -"ioredis@npm:^5.10.1": - version: 5.10.1 - resolution: "ioredis@npm:5.10.1" - dependencies: - "@ioredis/commands": "npm:1.5.1" - cluster-key-slot: "npm:^1.1.0" - debug: "npm:^4.3.4" - denque: "npm:^2.1.0" - lodash.defaults: "npm:^4.2.0" - lodash.isarguments: "npm:^3.1.0" - redis-errors: "npm:^1.2.0" - redis-parser: "npm:^3.0.0" - standard-as-callback: "npm:^2.1.0" - checksum: 10c0/d0507b52520d3bdd5dacaa33aed9dd3133794d8633b43a6b7fc3199a5e73f92cb77409f6904abe68e3221a95a630d97073b8c1c9e2c0c7613124db67e97c0eb0 - languageName: node - linkType: hard - "ip-address@npm:^10.0.1": version: 10.1.0 resolution: "ip-address@npm:10.1.0" @@ -4782,6 +4769,18 @@ __metadata: languageName: node linkType: hard +"jsonfile@npm:^4.0.0": + version: 4.0.0 + resolution: "jsonfile@npm:4.0.0" + dependencies: + graceful-fs: "npm:^4.1.6" + dependenciesMeta: + graceful-fs: + optional: true + checksum: 10c0/7dc94b628d57a66b71fb1b79510d460d662eb975b5f876d723f81549c2e9cd316d58a2ddf742b2b93a4fa6b17b2accaf1a738a0e2ea114bdfb13a32e5377e480 + languageName: node + linkType: hard + "keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -4808,8 +4807,8 @@ __metadata: dotenv: "npm:^17.3.1" eslint: "npm:^10.1.0" ffmpeg-static: "npm:^5.3.0" - ioredis: "npm:^5.10.1" jest: "npm:^30.3.0" + log4js: "npm:^6.9.1" pg: "npm:^8.20.0" prettier: "npm:^3.8.1" prism-media: "npm:^1.3.5" @@ -4864,20 +4863,6 @@ __metadata: languageName: node linkType: hard -"lodash.defaults@npm:^4.2.0": - version: 4.2.0 - resolution: "lodash.defaults@npm:4.2.0" - checksum: 10c0/d5b77aeb702caa69b17be1358faece33a84497bcca814897383c58b28a2f8dfc381b1d9edbec239f8b425126a3bbe4916223da2a576bb0411c2cefd67df80707 - languageName: node - linkType: hard - -"lodash.isarguments@npm:^3.1.0": - version: 3.1.0 - resolution: "lodash.isarguments@npm:3.1.0" - checksum: 10c0/5e8f95ba10975900a3920fb039a3f89a5a79359a1b5565e4e5b4310ed6ebe64011e31d402e34f577eca983a1fc01ff86c926e3cbe602e1ddfc858fdd353e62d8 - languageName: node - linkType: hard - "lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -4899,6 +4884,19 @@ __metadata: languageName: node linkType: hard +"log4js@npm:^6.9.1": + version: 6.9.1 + resolution: "log4js@npm:6.9.1" + dependencies: + date-format: "npm:^4.0.14" + debug: "npm:^4.3.4" + flatted: "npm:^3.2.7" + rfdc: "npm:^1.3.0" + streamroller: "npm:^3.1.5" + checksum: 10c0/05846e48f72d662800c8189bd178c42b4aa2f0c574cfc90a1942cf90b76f621c44019e26796c8fd88da1b6f0fe8272cba607cbaad6ae6ede50a7a096b58197ea + languageName: node + linkType: hard + "long@npm:^5.2.1": version: 5.3.2 resolution: "long@npm:5.3.2" @@ -5847,22 +5845,6 @@ __metadata: languageName: node linkType: hard -"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": - version: 1.2.0 - resolution: "redis-errors@npm:1.2.0" - checksum: 10c0/5b316736e9f532d91a35bff631335137a4f974927bb2fb42bf8c2f18879173a211787db8ac4c3fde8f75ed6233eb0888e55d52510b5620e30d69d7d719c8b8a7 - languageName: node - linkType: hard - -"redis-parser@npm:^3.0.0": - version: 3.0.0 - resolution: "redis-parser@npm:3.0.0" - dependencies: - redis-errors: "npm:^1.0.0" - checksum: 10c0/ee16ac4c7b2a60b1f42a2cdaee22b005bd4453eb2d0588b8a4939718997ae269da717434da5d570fe0b05030466eeb3f902a58cf2e8e1ca058bf6c9c596f632f - languageName: node - linkType: hard - "remeda@npm:2.33.4": version: 2.33.4 resolution: "remeda@npm:2.33.4" @@ -5914,6 +5896,13 @@ __metadata: languageName: node linkType: hard +"rfdc@npm:^1.3.0": + version: 1.4.1 + resolution: "rfdc@npm:1.4.1" + checksum: 10c0/4614e4292356cafade0b6031527eea9bc90f2372a22c012313be1dcc69a3b90c7338158b414539be863fa95bfcb2ddcd0587be696841af4e6679d85e62c060c7 + languageName: node + linkType: hard + "rimraf@npm:^3.0.2": version: 3.0.2 resolution: "rimraf@npm:3.0.2" @@ -6183,13 +6172,6 @@ __metadata: languageName: node linkType: hard -"standard-as-callback@npm:^2.1.0": - version: 2.1.0 - resolution: "standard-as-callback@npm:2.1.0" - checksum: 10c0/012677236e3d3fdc5689d29e64ea8a599331c4babe86956bf92fc5e127d53f85411c5536ee0079c52c43beb0026b5ce7aa1d834dd35dd026e82a15d1bcaead1f - languageName: node - linkType: hard - "std-env@npm:3.10.0": version: 3.10.0 resolution: "std-env@npm:3.10.0" @@ -6197,6 +6179,17 @@ __metadata: languageName: node linkType: hard +"streamroller@npm:^3.1.5": + version: 3.1.5 + resolution: "streamroller@npm:3.1.5" + dependencies: + date-format: "npm:^4.0.14" + debug: "npm:^4.3.4" + fs-extra: "npm:^8.1.0" + checksum: 10c0/0bdeec34ad37487d959ba908f17067c938f544db88b5bb1669497a67a6b676413229ce5a6145c2812d06959ebeb8842e751076647d4b323ca06be612963b9099 + languageName: node + linkType: hard + "string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2" @@ -6539,6 +6532,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^0.1.0": + version: 0.1.2 + resolution: "universalify@npm:0.1.2" + checksum: 10c0/e70e0339f6b36f34c9816f6bf9662372bd241714dc77508d231d08386d94f2c4aa1ba1318614f92015f40d45aae1b9075cd30bd490efbe39387b60a76ca3f045 + languageName: node + linkType: hard + "unrs-resolver@npm:^1.7.11": version: 1.11.1 resolution: "unrs-resolver@npm:1.11.1"