Compare commits
No commits in common. "233615e6d069307dcefd2e4aa8e64b91f0e0ca02" and "9f903f676f461d743348a2e810b2391637d6d968" have entirely different histories.
233615e6d0
...
9f903f676f
|
|
@ -9,7 +9,7 @@ description: work routine
|
|||
|
||||
## 기본 원칙 (Work Rules)
|
||||
|
||||
1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다.
|
||||
1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL, Redis 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다.
|
||||
2. **협력적 기획, 독립적 실행**: 기능 기획과 설계(Architecture, Schema 등)는 사용자와 함께 논리적인 완결성을 갖출 때까지 충분히 논의합니다. 기획이 "완료 및 승인"된 후에는 후속 구현, 에러 디버깅, 자체 테스트를 추가적인 중간 확인 없이 에이전트가 주도를 가지고 끝마친 뒤 최종 결과를 보고합니다.
|
||||
|
||||
## 단계별 작업 루틴
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@ DISCORD_CLIENT_ID=your_client_id_here
|
|||
# User/pass from docker-compose.yml
|
||||
DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public"
|
||||
|
||||
# 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
|
||||
# Redis Configuration
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT=6379
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
|----------|-------------|------|------------------|
|
||||
| `USER_INPUT` | `E1xxx` | 잘못된 입력값 (범위 초과, 형식 오류 등) | 올바른 입력 예시 안내, 재입력 유도 |
|
||||
| `PERMISSION` | `E2xxx` | 봇 또는 사용자 권한 부족 | 필요 권한 안내, 서버 관리자 문의 유도 |
|
||||
| `BOT_INTERNAL` | `E3xxx` | 봇 내부 오류 (DB, 로직 오류 등) | 잠시 후 재시도 안내, 지속 시 관리자 문의 |
|
||||
| `BOT_INTERNAL` | `E3xxx` | 봇 내부 오류 (DB, Redis, 로직 오류) | 잠시 후 재시도 안내, 지속 시 관리자 문의 |
|
||||
| `DISCORD_API` | `E4xxx` | Discord API 오류 (Rate Limit, 서비스 장애 등) | 잠시 후 재시도, Discord 상태 확인 안내 |
|
||||
|
||||
### 주요 에러 코드 예시
|
||||
|
|
@ -51,7 +51,7 @@ E2001 봇에 Manage Channels 권한 없음
|
|||
E2002 사용자에게 관리자 권한 없음
|
||||
E2003 채널 소유자만 사용 가능
|
||||
E3001 데이터베이스 연결/쿼리 실패
|
||||
E3002 내부 캐시/상태 계층 오류
|
||||
E3002 캐시(Redis) 연결 실패
|
||||
E4001 Discord API Rate Limit
|
||||
E4002 Discord API 50013 (Missing Permissions)
|
||||
E4003 Discord API 일시적 오류
|
||||
|
|
|
|||
|
|
@ -173,10 +173,11 @@ enum EventStatus {
|
|||
### 중복 방지
|
||||
|
||||
- 전송 직후 `remindedOneHour`, `remindedTenMinutes` 업데이트
|
||||
- 다중 인스턴스 환경에서는 DB advisory lock 또는 `INSTANCE_ID` 기반 단일 실행 제어 고려
|
||||
- 다중 인스턴스 환경에서는 Redis lock 또는 `INSTANCE_ID` 기반 단일 실행 제어 고려
|
||||
|
||||
> [!NOTE]
|
||||
> 글로벌 커맨드 등록 등은 애플리케이션 레벨에서 처리하며, 이벤트 리마인더도 유사한 단일 실행 패턴으로 확장할 수 있습니다.
|
||||
> 현재 프로젝트는 글로벌 커맨드 동기화 시 Redis lock을 사용하므로,
|
||||
> 이벤트 리마인더도 같은 방식으로 확장하기 좋습니다.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
- **State Management (상태 관리)**:
|
||||
- 마법사는 ephemeral 메시지로 띄워지며, 세션 식별은 `customId` 릴레이 방식을 권장합니다.
|
||||
- 예시: `setup_action_next_2` (현재 Step 2, 다음으로 이동) 등 Stateless하게 관리하거나,
|
||||
- 사용자/서버별 임시 Map 등에 `SetupSession` (진행 단계 등) 보관. (구현 편의상 `customId` payload에 스텝 인덱스를 담는 Stateless 방식 채택)
|
||||
- 사용자/서버별 임시 Map/Redis에 `SetupSession` (진행 단계 등) 보관. (구현 편의상 `customId` payload에 스텝 인덱스를 담는 Stateless 방식 채택)
|
||||
- **i18n 통합**:
|
||||
- 버튼 레이블 및 Embed 내용은 모두 `t()` 함수를 거쳐야 합니다.
|
||||
- Step 1에서 언어가 변경되면, 즉시 그 언어 기준의 `t()`를 이용해 현재 뷰(View)를 갱신합니다.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
#### 절대 금지 대상 예시:
|
||||
- 디스코드 봇 토큰 (Discord Bot Tokens)
|
||||
- 데이터베이스 비밀번호 및 접속 주소 (e.g. `postgresql://user:password@host/db`)
|
||||
- 외부 서비스 비밀번호, API 인증 키(Keys), 비밀 암호 해시, 사내용 중요 자격증명 리스트
|
||||
- Redis 비밀번호, API 인증 키(Keys), 비밀 암호 해시, 사내용 중요 자격증명 리스트
|
||||
|
||||
#### 올바른 해결 방법
|
||||
1. **환경 변수 파일 (`.env`) 사용**:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
### 1. 기술 스택 확정 및 초기화
|
||||
- **언어 및 런타임**: Node.js, TypeScript
|
||||
- **프레임워크**: discord.js (v14+)
|
||||
- **데이터베이스**: Prisma (PostgreSQL)
|
||||
- **데이터베이스**: Prisma (PostgreSQL) + Redis (캐싱 및 동기화)
|
||||
- **빌드 도구**: ts-node, tsx, tsc
|
||||
|
||||
### 2. 프로젝트 기본 구조 설계
|
||||
|
|
|
|||
|
|
@ -38,6 +38,6 @@
|
|||
- **퇴장 시 채널 미삭제 및 유령 방 버그**:
|
||||
- *문제*: 채널 내에 음악봇 등 봇만 남았을 때 삭제 조건이 작동하지 않고, 권한 문제로 삭제가 실패했을 때 DB 무결성이 깨지는 버그.
|
||||
- *해결*: 휴먼 카운트(`humanCount`) 도입 및 삭제 롤백 검증 처리. 자세한 사항은 [handleLeave_ghost_channel.md](../Troubleshooting/handleLeave_ghost_channel.md) 참조.
|
||||
- **봇 재부팅 복구 (Boot Recovery)**:
|
||||
- *사유*: 봇 재시작·크래시 등으로 DB와 디스코드 실제 채널 상태가 어긋나게 될 경우를 방지.
|
||||
- *해결*: `VoiceService.syncChannels`로 부팅 시 DB를 기준으로 유령 방을 크로스체크 및 청소(Garbage Collection)하도록 반영. (다중 인스턴스 동시 실행 시 동일 작업이 겹칠 수 있으나 작업은 멱등에 가깝게 설계됨.)
|
||||
- **멀티 인스턴스 대응 및 봇 재부팅 복구 (Boot Recovery)**:
|
||||
- *사유*: 봇 재시작, 크래시, 혹은 다중 노드(Multi-Instance) 환경에서 DB와 디스코드 실제 채널 상태가 어긋나게 될 경우를 방지.
|
||||
- *해결*: `ioredis` 분산 락(Distributed Lock)과 결합된 `VoiceService.syncChannels` 메서드를 구현하여, 부팅 시 딱 하나의 인스턴스만 DB를 훑으며 삭제되어야 할 유령 방들을 크로스체크 및 안전하게 청소(Garbage Collection)하도록 반영.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# 2026-03-31 낚시 미니게임 Phase 1 구현
|
||||
# 2026-03-31 낚시 미니게임 Phase 1 구현
|
||||
|
||||
## 개요
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Kord Documentation Index
|
||||
# Kord Documentation Index
|
||||
|
||||
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
|
|||
|
||||
## 1. 개요 (Overview)
|
||||
|
||||
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
|
||||
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)와 Redis를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
|
||||
|
||||
## 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)
|
||||
|
|
@ -58,7 +59,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
|
|||
|
||||
1. **빌드**: `yarn build`
|
||||
2. **실행**: `yarn start`
|
||||
3. **Docker**: `docker-compose up -d`를 통해 PostgreSQL 등 로컬 인프라를 실행할 수 있습니다.
|
||||
3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB, Redis)을 실행할 수 있습니다.
|
||||
|
||||
## 5. 기능 목록 (Feature List)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,5 +14,15 @@ 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:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"log4js": "^6.9.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"pg": "^8.20.0",
|
||||
"prism-media": "^1.3.5",
|
||||
"sharp": "^0.34.5",
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -5,6 +5,7 @@ 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 {
|
||||
|
|
@ -28,6 +29,7 @@ export class KordClient extends Client {
|
|||
|
||||
// Connect to external services
|
||||
await connectDB();
|
||||
await connectRedis();
|
||||
|
||||
// Load Handlers
|
||||
await loadCommands(this);
|
||||
|
|
|
|||
|
|
@ -118,8 +118,9 @@ export default {
|
|||
|
||||
if (action === 'set') {
|
||||
const channel = interaction.options.getChannel('channel') as TextChannel;
|
||||
if (!channel) return interaction.editReply({ content: '❌ `channel` 옵션을 선택해주세요.' });
|
||||
if (!channel) return interaction.reply({ content: '❌ `channel` 옵션을 선택해주세요.', ephemeral: true });
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const botMember = guild.members.me;
|
||||
if (!botMember) return;
|
||||
const perms = channel.permissionsFor(botMember);
|
||||
|
|
@ -140,11 +141,13 @@ 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\` 명령어로 채널을 설정해주세요.` });
|
||||
|
||||
|
|
@ -165,9 +168,10 @@ export default {
|
|||
const enable = interaction.options.getBoolean('enable');
|
||||
|
||||
if (!category || enable === null) {
|
||||
return interaction.editReply({ content: '❌ `category` 및 `enable` 옵션을 모두 입력해주세요.' });
|
||||
return interaction.reply({ content: '❌ `category` 및 `enable` 옵션을 모두 입력해주세요.', ephemeral: true });
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const config = await auditLogService.getChannel(guild.id);
|
||||
if (!config) return interaction.editReply({ content: `설정된 감사 채널이 없습니다. 먼저 \`/audit channel action:set\` 명령어로 채널을 설정해주세요.` });
|
||||
|
||||
|
|
@ -181,6 +185,7 @@ 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') });
|
||||
|
||||
|
|
@ -225,7 +230,8 @@ export default {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Error in audit command', error);
|
||||
return interaction.editReply({ content: '명령 실행 중 오류가 발생했습니다.' });
|
||||
const reply = interaction.deferred ? interaction.editReply : interaction.reply;
|
||||
return (reply as any)({ content: '명령 실행 중 오류가 발생했습니다.', ephemeral: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -76,13 +76,14 @@ export default {
|
|||
if (action === 'language') {
|
||||
const newLocale = interaction.options.getString('locale') as SupportedLocale;
|
||||
if (!newLocale) {
|
||||
return interaction.editReply({
|
||||
return interaction.reply({
|
||||
content: '❌ `locale` 옵션을 선택해주세요.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!SUPPORTED_LOCALES.includes(newLocale)) {
|
||||
return interaction.editReply({ content: `Unsupported locale: ${newLocale}` });
|
||||
return interaction.reply({ content: `Unsupported locale: ${newLocale}`, ephemeral: true });
|
||||
}
|
||||
|
||||
await prisma.guildConfig.upsert({
|
||||
|
|
@ -91,8 +92,9 @@ export default {
|
|||
create: { guildId: interaction.guildId, locale: newLocale },
|
||||
});
|
||||
|
||||
return interaction.editReply({
|
||||
return interaction.reply({
|
||||
content: t(newLocale, 'commands.language.serverSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }),
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -130,7 +132,7 @@ export default {
|
|||
.setTitle(t(locale, 'commands.config.title'))
|
||||
.setDescription(`${label}: **${state}**`);
|
||||
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -201,23 +201,26 @@ export default {
|
|||
|
||||
const startsAt = parseSeoulDateTime(date, time);
|
||||
if (!startsAt) {
|
||||
await interaction.editReply({
|
||||
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.editReply({
|
||||
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.editReply({
|
||||
await interaction.reply({
|
||||
content: `${t(locale, 'commands.event.invalidReminderOffsets')}\n${t(locale, 'commands.event.invalidReminderOffsetsResolution')}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -249,7 +252,7 @@ export default {
|
|||
)
|
||||
.setTimestamp();
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -265,8 +268,9 @@ export default {
|
|||
});
|
||||
|
||||
if (events.length === 0) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: t(locale, 'commands.event.listEmpty'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -291,7 +295,7 @@ export default {
|
|||
});
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -307,8 +311,9 @@ export default {
|
|||
});
|
||||
|
||||
if (!event) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: t(locale, 'commands.event.cancelNotFound', { id }),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -318,8 +323,9 @@ export default {
|
|||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: t(locale, 'commands.event.cancelSuccess', { id }),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -334,30 +340,34 @@ export default {
|
|||
});
|
||||
|
||||
if (!event) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: t(locale, 'commands.event.cancelNotFound', { id }),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.announcementChannelId) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: t(locale, 'commands.event.announceNotAvailable'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await EventService.announceEvent(interaction.guild!, event.id);
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: t(locale, 'commands.event.announceSuccess', {
|
||||
id,
|
||||
channel: `<#${event.announcementChannelId}>`,
|
||||
}),
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: t(locale, 'commands.event.announceNotAvailable'),
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ 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'),
|
||||
|
|
@ -122,6 +124,8 @@ export default {
|
|||
}
|
||||
|
||||
if (subcommand === 'cast') {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
if (!config || !config.enabled) {
|
||||
await interaction.editReply({
|
||||
content: t(locale, 'commands.fishing.disabled'),
|
||||
|
|
@ -146,6 +150,8 @@ export default {
|
|||
}
|
||||
|
||||
if (subcommand === 'end') {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const ended = await FishingService.endThreadByUser(interaction, locale);
|
||||
if (!ended) {
|
||||
await interaction.editReply({
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export default {
|
|||
|
||||
// Validate locale (safety check)
|
||||
if (!SUPPORTED_LOCALES.includes(newLocale)) {
|
||||
await interaction.editReply({ content: `Unsupported locale: ${newLocale}` });
|
||||
await interaction.reply({ content: `Unsupported locale: ${newLocale}`, ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -38,8 +38,9 @@ export default {
|
|||
});
|
||||
|
||||
// Respond in the NEWLY selected locale
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: t(newLocale, 'commands.language.userSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }),
|
||||
ephemeral: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default {
|
|||
.setTitle('🎮 미니게임 설정 변경')
|
||||
.setDescription(`${game.name} 미니게임이 **${state}**되었습니다.`);
|
||||
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
if (subcommand === 'status') {
|
||||
|
|
@ -106,7 +106,7 @@ export default {
|
|||
});
|
||||
});
|
||||
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
if (subcommand === 'channel') {
|
||||
|
|
@ -127,7 +127,7 @@ export default {
|
|||
.setTitle('🎮 미니게임 채널 설정')
|
||||
.setDescription(`${game.name} 미니게임 전용 채널이 **${channelMsg}**로 설정되었습니다.`);
|
||||
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.editReply({ content });
|
||||
await interaction.reply({ content, ephemeral });
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
@ -136,28 +136,33 @@ export default {
|
|||
const url = interaction.options.getString('url');
|
||||
|
||||
if ((!query && !url) || (query && url)) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'addMutuallyExclusive'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const textChannel = interaction.channel as any;
|
||||
if (!textChannel?.send) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
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);
|
||||
|
|
@ -186,7 +191,7 @@ export default {
|
|||
}
|
||||
|
||||
if (subcommand === 'queue') {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
embeds: [MusicService.getQueueEmbed(interaction.guildId!, locale)],
|
||||
});
|
||||
return;
|
||||
|
|
@ -195,16 +200,18 @@ export default {
|
|||
if (subcommand === 'remove') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -212,13 +219,14 @@ export default {
|
|||
const index = interaction.options.getInteger('index', true);
|
||||
const removed = await MusicService.remove(interaction.guildId!, index);
|
||||
if (!removed) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: t(locale, 'commands.music.queueRemoved', {
|
||||
title: removed.title,
|
||||
}),
|
||||
|
|
@ -229,145 +237,160 @@ export default {
|
|||
if (subcommand === 'pause') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const paused = await MusicService.pause(interaction.guildId!, locale);
|
||||
if (!paused) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({ content: t(locale, 'commands.music.pauseSuccess') });
|
||||
await interaction.reply({ content: t(locale, 'commands.music.pauseSuccess') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'resume') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resumed = await MusicService.resume(interaction.guildId!, locale);
|
||||
if (!resumed) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({ content: t(locale, 'commands.music.resumeSuccess') });
|
||||
await interaction.reply({ content: t(locale, 'commands.music.resumeSuccess') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'skip') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const skipped = await MusicService.skip(interaction.guildId!);
|
||||
if (!skipped) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({ content: t(locale, 'commands.music.skipSuccess') });
|
||||
await interaction.reply({ content: t(locale, 'commands.music.skipSuccess') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'stop') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stopped = await MusicService.stop(interaction.guildId!, locale);
|
||||
if (!stopped) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({ content: t(locale, 'commands.music.stopSuccess') });
|
||||
await interaction.reply({ content: t(locale, 'commands.music.stopSuccess') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'leave') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const left = await MusicService.leave(interaction.guildId!, locale);
|
||||
if (!left) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({ content: t(locale, 'commands.music.leaveSuccess') });
|
||||
await interaction.reply({ content: t(locale, 'commands.music.leaveSuccess') });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in music command:', error);
|
||||
|
|
@ -394,7 +417,15 @@ export default {
|
|||
return;
|
||||
}
|
||||
|
||||
await respond(interaction, t(locale, 'errors.E3003.userMessage'), true);
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -97,11 +97,11 @@ export default {
|
|||
});
|
||||
|
||||
if (!config || !config.enabled) {
|
||||
return interaction.editReply({ content: '❌ 이 서버에서는 재련 미니게임이 비활성화되어 있습니다.' });
|
||||
return interaction.reply({ content: '❌ 이 서버에서는 재련 미니게임이 비활성화되어 있습니다.', ephemeral: true });
|
||||
}
|
||||
|
||||
if (config.channelId && config.channelId !== interaction.channelId) {
|
||||
return interaction.editReply({ content: `❌ 재련 미니게임은 <#${config.channelId}> 채널에서만 이용 가능합니다.` });
|
||||
return interaction.reply({ content: `❌ 재련 미니게임은 <#${config.channelId}> 채널에서만 이용 가능합니다.`, ephemeral: true });
|
||||
}
|
||||
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
|
@ -132,9 +132,9 @@ export default {
|
|||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(retryBtn);
|
||||
|
||||
return interaction.editReply({ embeds: [embed], components: [row] });
|
||||
return interaction.reply({ embeds: [embed], components: [row] });
|
||||
} catch (err: any) {
|
||||
return interaction.editReply({ content: `❌ 오류: ${err.message}` });
|
||||
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,10 +142,10 @@ export default {
|
|||
if (subcommand === 'battle') {
|
||||
const targetUser = interaction.options.getUser('target', true);
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
return interaction.editReply({ content: '❌ 자신을 공격할 수 없습니다.' });
|
||||
return interaction.reply({ content: '❌ 자신을 공격할 수 없습니다.', ephemeral: true });
|
||||
}
|
||||
if (targetUser.bot) {
|
||||
return interaction.editReply({ content: '❌ 봇과 전투할 수 없습니다.' });
|
||||
return interaction.reply({ content: '❌ 봇과 전투할 수 없습니다.', ephemeral: true });
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -170,9 +170,9 @@ export default {
|
|||
if (result.attackerDurability <= 0) embed.setFooter({ text: '⚠️ [경고] 무기가 전투 불능이 되었습니다. 내구도 0에서 다시 공격하면 파괴됩니다!' });
|
||||
}
|
||||
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
return interaction.reply({ embeds: [embed] });
|
||||
} catch (err: any) {
|
||||
return interaction.editReply({ content: `❌ 오류: ${err.message}` });
|
||||
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,9 +180,9 @@ export default {
|
|||
if (subcommand === 'checkin') {
|
||||
try {
|
||||
const res = await RefinementService.checkIn(interaction.user.id, interaction.guildId);
|
||||
return interaction.editReply({ content: `✅ 출석 완료! **${res.goldAdded} G**를 수령했습니다. (총: ${res.totalGold} G)` });
|
||||
return interaction.reply({ content: `✅ 출석 완료! **${res.goldAdded} G**를 수령했습니다. (총: ${res.totalGold} G)`, ephemeral: true });
|
||||
} catch (err: any) {
|
||||
return interaction.editReply({ content: `❌ 오류: ${err.message}` });
|
||||
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +208,7 @@ export default {
|
|||
{ name: '전투', value: `승리: ${profile.battleWin} | 패배: ${profile.battleLoss}`, inline: false }
|
||||
);
|
||||
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
return interaction.reply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
// --- RANKING ---
|
||||
|
|
@ -243,16 +243,16 @@ export default {
|
|||
}).join('\n') || '데이터가 없습니다.';
|
||||
|
||||
embed.setDescription(listStr);
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
return interaction.reply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
// --- SELL ---
|
||||
if (subcommand === 'sell') {
|
||||
try {
|
||||
const res = await RefinementService.sellWeapon(interaction.user.id, interaction.guildId);
|
||||
return interaction.editReply({ content: `💰 **${res.level}단계** 무기를 판매하고 **${res.price} G**를 받았습니다. (총: ${res.gold} G)` });
|
||||
return interaction.reply({ content: `💰 **${res.level}단계** 무기를 판매하고 **${res.price} G**를 받았습니다. (총: ${res.gold} G)`, ephemeral: true });
|
||||
} catch (err: any) {
|
||||
return interaction.editReply({ content: `❌ 오류: ${err.message}` });
|
||||
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +271,7 @@ export default {
|
|||
{ name: '🔥 피버 타임', value: '서버가 가장 활발한 시간대에 재련 성공 확률이 10% 증가합니다!' }
|
||||
);
|
||||
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
return interaction.reply({ embeds: [embed] });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ export default {
|
|||
if (!interaction.guildId) return;
|
||||
|
||||
const { embed, components } = await SetupWizardRenderer.renderStep(0, interaction, locale);
|
||||
return interaction.editReply({
|
||||
return interaction.reply({
|
||||
embeds: [embed],
|
||||
components,
|
||||
ephemeral: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -88,21 +88,22 @@ export default {
|
|||
|
||||
if (action === 'set') {
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
if (!channel) return interaction.editReply({ content: '❌ `channel` 옵션을 선택해주세요.' });
|
||||
if (!channel) return interaction.reply({ content: '❌ `channel` 옵션을 선택해주세요.', ephemeral: true });
|
||||
|
||||
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.editReply({
|
||||
return interaction.reply({
|
||||
content: t(locale, 'commands.voiceSetup.setSuccess', { channel: `${channel}` }),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
const name = interaction.options.getString('name');
|
||||
if (!name) return interaction.editReply({ content: '❌ `name` 옵션을 입력해주세요.' });
|
||||
if (!name) return interaction.reply({ content: '❌ `name` 옵션을 입력해주세요.', ephemeral: true });
|
||||
|
||||
const newChannel = await interaction.guild!.channels.create({
|
||||
name,
|
||||
|
|
@ -112,8 +113,9 @@ export default {
|
|||
await prisma.voiceGenerator.create({
|
||||
data: { channelId: newChannel.id, guildId, categoryId: category?.id || null }
|
||||
});
|
||||
return interaction.editReply({
|
||||
return interaction.reply({
|
||||
content: t(locale, 'commands.voiceSetup.createSuccess', { channel: `${newChannel}` }),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -124,29 +126,31 @@ export default {
|
|||
|
||||
if (action === 'name') {
|
||||
const template = interaction.options.getString('template');
|
||||
if (!template) return interaction.editReply({ content: '❌ `template` 옵션을 입력해주세요.' });
|
||||
if (!template) return interaction.reply({ content: '❌ `template` 옵션을 입력해주세요.', ephemeral: true });
|
||||
|
||||
await prisma.voiceGuildConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { defaultNameTemplate: template },
|
||||
create: { guildId, defaultNameTemplate: template }
|
||||
});
|
||||
return interaction.editReply({
|
||||
return interaction.reply({
|
||||
content: t(locale, 'commands.voiceConfig.setSuccess'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'limit') {
|
||||
const limit = interaction.options.getInteger('limit');
|
||||
if (limit === null) return interaction.editReply({ content: '❌ `limit` 옵션을 입력해주세요.' });
|
||||
if (limit === null) return interaction.reply({ content: '❌ `limit` 옵션을 입력해주세요.', ephemeral: true });
|
||||
|
||||
await prisma.voiceGuildConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { defaultUserLimit: limit },
|
||||
create: { guildId, defaultUserLimit: limit }
|
||||
});
|
||||
return interaction.editReply({
|
||||
return interaction.reply({
|
||||
content: t(locale, 'commands.voiceConfig.setSuccess'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -167,13 +171,14 @@ export default {
|
|||
inline: true
|
||||
}
|
||||
);
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in voice command', error);
|
||||
return interaction.editReply({
|
||||
return interaction.reply({
|
||||
content: t(locale, 'errors.E3003.userMessage'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { config } from 'dotenv';
|
||||
import { hostname } from 'os';
|
||||
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') });
|
||||
config();
|
||||
|
||||
const generateInstanceId = () => {
|
||||
return process.env.INSTANCE_ID || hostname() || `kord-${Math.random().toString(36).substring(2, 7)}`;
|
||||
|
|
@ -14,15 +11,9 @@ 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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +1,11 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
* `?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);
|
||||
// Prisma 7 requires a driver adapter for direct database connections.
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
adapter,
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Events } from 'discord.js';
|
||||
import { createHash } from 'crypto';
|
||||
import { KordClient } from '../client/KordClient';
|
||||
import { logger } from '../utils/logger';
|
||||
import { InviteService } from '../services/InviteService';
|
||||
|
|
@ -6,6 +7,7 @@ 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 {
|
||||
|
|
@ -20,8 +22,19 @@ export default {
|
|||
|
||||
try {
|
||||
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());
|
||||
await client.application?.commands.set(commandsData);
|
||||
logger.info(`Successfully registered ${commandsData.length} global application commands.`);
|
||||
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).`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to register global commands', e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,20 +23,21 @@ export async function getInteractionLocale(interaction: Interaction): Promise<Su
|
|||
let guildLocale: string | null = null;
|
||||
|
||||
try {
|
||||
const [userPref, guildConfig] = await Promise.all([
|
||||
prisma.userLocale.findUnique({
|
||||
where: { userId: interaction.user.id },
|
||||
select: { locale: true },
|
||||
}),
|
||||
interaction.guildId
|
||||
? prisma.guildConfig.findUnique({
|
||||
where: { guildId: interaction.guildId },
|
||||
select: { locale: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
// Fetch user locale preference
|
||||
const userPref = await prisma.userLocale.findUnique({
|
||||
where: { userId: interaction.user.id },
|
||||
select: { locale: true },
|
||||
});
|
||||
userLocale = userPref?.locale ?? null;
|
||||
guildLocale = guildConfig?.locale ?? null;
|
||||
|
||||
// Fetch guild locale preference
|
||||
if (interaction.guildId) {
|
||||
const guildConfig = await prisma.guildConfig.findUnique({
|
||||
where: { guildId: interaction.guildId },
|
||||
select: { locale: true },
|
||||
});
|
||||
guildLocale = guildConfig?.locale ?? null;
|
||||
}
|
||||
} catch {
|
||||
// If DB lookup fails, fall through to Discord locale / default
|
||||
}
|
||||
|
|
@ -60,22 +61,21 @@ export async function getContextLocale(
|
|||
let guildLocale: string | null = null;
|
||||
|
||||
try {
|
||||
const [userPref, guildConfig] = await Promise.all([
|
||||
userId
|
||||
? prisma.userLocale.findUnique({
|
||||
where: { userId },
|
||||
select: { locale: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
guildId
|
||||
? prisma.guildConfig.findUnique({
|
||||
where: { guildId },
|
||||
select: { locale: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
userLocale = userPref?.locale ?? null;
|
||||
guildLocale = guildConfig?.locale ?? null;
|
||||
if (userId) {
|
||||
const userPref = await prisma.userLocale.findUnique({
|
||||
where: { userId },
|
||||
select: { locale: true },
|
||||
});
|
||||
userLocale = userPref?.locale ?? null;
|
||||
}
|
||||
|
||||
if (guildId) {
|
||||
const guildConfig = await prisma.guildConfig.findUnique({
|
||||
where: { guildId },
|
||||
select: { locale: true },
|
||||
});
|
||||
guildLocale = guildConfig?.locale ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { TranslationSchema } from '../types';
|
||||
import { TranslationSchema } from '../types';
|
||||
|
||||
/**
|
||||
* English translations ??the DEFAULT and FALLBACK locale.
|
||||
|
|
|
|||
|
|
@ -1,259 +1,261 @@
|
|||
import { TranslationSchema } from '../types';
|
||||
import { TranslationSchema } from '../types';
|
||||
|
||||
/**
|
||||
* 한국어 번역. en.ts와 키 구조가 1:1로 대응해야 합니다.
|
||||
* ?쒓뎅??踰덉뿭 ?뚯씪.
|
||||
* 紐⑤뱺 ?ㅺ? en.ts? 1:1 ??묐릺?댁빞 ?⑸땲??
|
||||
*/
|
||||
export const ko: TranslationSchema = {
|
||||
// ?? ?먮윭 硫붿떆吏 ?????????????????????????????????????????
|
||||
errors: {
|
||||
E1001: {
|
||||
userMessage: '사용자 제한 값이 올바르지 않습니다.',
|
||||
resolution: '0에서 99 사이의 숫자를 입력해 주세요. (0 = 무제한)',
|
||||
resolution: '0?먯꽌 99 ?ъ씠???レ옄瑜??낅젰?댁<?몄슂. (0 = 臾댁젣??',
|
||||
},
|
||||
E1002: {
|
||||
userMessage: '채널 이름 형식이 올바르지 않습니다.',
|
||||
resolution: '올바른 채널 이름을 입력해 주세요. (최대 100자)',
|
||||
userMessage: '梨꾨꼸 ?대쫫 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
|
||||
resolution: '?좏슚??梨꾨꼸 ?대쫫???낅젰?댁<?몄슂. (理쒕? 100??',
|
||||
},
|
||||
E1003: {
|
||||
userMessage: '자기 자신에게는 이 작업을 수행할 수 없습니다.',
|
||||
userMessage: '?먭린 ?먯떊?먭쾶?????묒뾽???섑뻾?????놁뒿?덈떎.',
|
||||
},
|
||||
E1004: {
|
||||
userMessage: '선택한 사용자가 음성 채널에 없습니다.',
|
||||
resolution: '작업 전에 해당 사용자가 채널에 있는지 확인해 주세요.',
|
||||
userMessage: '?좏깮???좎?媛 ?뚯꽦 梨꾨꼸???놁뒿?덈떎.',
|
||||
resolution: '?묒뾽???섑뻾?섍린 ?꾩뿉 ?대떦 ?좎?媛 梨꾨꼸???덈뒗吏 ?뺤씤?댁<?몄슂.',
|
||||
},
|
||||
E2001: {
|
||||
userMessage: '봇에게 채널을 관리할 권한이 부족합니다.',
|
||||
resolution: '서버 관리자에게 봇에게 「채널 관리」 권한을 부여해 달라고 요청해 주세요.',
|
||||
userMessage: '遊뉗뿉 梨꾨꼸??愿由ы븷 沅뚰븳??遺議깊빀?덈떎.',
|
||||
resolution: '?쒕쾭 愿由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿由? 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
|
||||
},
|
||||
E2002: {
|
||||
userMessage: '봇에게 음성 채널 관련 권한이 부족합니다.',
|
||||
resolution:
|
||||
'서버 관리자에게 봇에게 「채널 관리」, 「역할 관리」, 「멤버 이동」 권한을 부여해 달라고 요청해 주세요.',
|
||||
userMessage: '遊뉗뿉 ?뚯꽦 梨꾨꼸 愿??沅뚰븳??遺議깊빀?덈떎.',
|
||||
resolution: '?쒕쾭 愿由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿由?, "??븷 愿由?, "硫ㅻ쾭 ?대룞" 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
|
||||
},
|
||||
E2003: {
|
||||
userMessage: '이 명령을 사용할 권한이 없습니다.',
|
||||
resolution: '이 명령은 관리자 권한이 필요합니다.',
|
||||
userMessage: '??紐낅졊?대? ?ъ슜??沅뚰븳???놁뒿?덈떎.',
|
||||
resolution: '??紐낅졊?대뒗 愿由ъ옄 沅뚰븳???꾩슂?⑸땲??',
|
||||
},
|
||||
E2004: {
|
||||
userMessage: '채널 소유자만 이 컨트롤을 사용할 수 있습니다.',
|
||||
userMessage: '梨꾨꼸 ?뚯쑀?먮쭔 ??湲곕뒫???ъ슜?????덉뒿?덈떎.',
|
||||
},
|
||||
E2005: {
|
||||
userMessage: '활성화된 임시 음성 채널에 있어야 이 기능을 사용할 수 있습니다.',
|
||||
resolution: '임시 음성 채널에 참가한 뒤 다시 시도해 주세요.',
|
||||
userMessage: '?쒖꽦???꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬 以묒씠?댁빞 ?ъ슜?????덉뒿?덈떎.',
|
||||
resolution: '?꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬?????ㅼ떆 ?쒕룄?댁<?몄슂.',
|
||||
},
|
||||
E3001: {
|
||||
userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.',
|
||||
resolution: '잠시 후 다시 시도해 주세요. 문제가 계속되면 봇 관리자에게 문의해 주세요.',
|
||||
userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛 諛쒖깮?덉뒿?덈떎.',
|
||||
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛 吏?띾릺硫?遊?愿由ъ옄?먭쾶 臾몄쓽?섏꽭??',
|
||||
},
|
||||
E3002: {
|
||||
userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.',
|
||||
resolution: '잠시 후 다시 시도해 주세요.',
|
||||
userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛 諛쒖깮?덉뒿?덈떎.',
|
||||
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂.',
|
||||
},
|
||||
E3003: {
|
||||
userMessage: '명령을 실행하는 중 오류가 발생했습니다.',
|
||||
resolution: '다시 시도해 주세요. 문제가 계속되면 봇 관리자에게 문의해 주세요.',
|
||||
userMessage: '紐낅졊?대? ?ㅽ뻾?섎뒗 以??ㅻ쪟媛 諛쒖깮?덉뒿?덈떎.',
|
||||
resolution: '?ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛 吏?띾릺硫?遊?愿由ъ옄?먭쾶 臾몄쓽?섏꽭??',
|
||||
},
|
||||
E3999: {
|
||||
userMessage: '예상치 못한 오류가 발생했습니다.',
|
||||
resolution: '잠시 후 다시 시도해 주세요. 문제가 계속되면 봇 관리자에게 문의해 주세요.',
|
||||
userMessage: '?덉긽移?紐삵븳 ?ㅻ쪟媛 諛쒖깮?덉뒿?덈떎.',
|
||||
resolution: '?섏쨷???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛 怨꾩냽?섎㈃ 遊?愿由ъ옄?먭쾶 臾몄쓽?섏꽭??',
|
||||
},
|
||||
E4001: {
|
||||
userMessage: 'Discord에 의해 요청이 제한되었습니다.',
|
||||
resolution: '잠시 기다린 뒤 다시 시도해 주세요.',
|
||||
userMessage: 'Discord???섑빐 ?붿껌???쒗븳?섏뿀?듬땲??',
|
||||
resolution: '?좎떆 湲곕떎由????ㅼ떆 ?쒕룄?댁<?몄슂.',
|
||||
},
|
||||
E4002: {
|
||||
userMessage: '권한 부족으로 Discord가 작업을 거부했습니다.',
|
||||
resolution: '서버 관리자에게 봇의 역할 및 채널 권한을 확인해 달라고 요청해 주세요.',
|
||||
userMessage: '沅뚰븳 遺議깆쑝濡?Discord媛 ?묒뾽??嫄곕??덉뒿?덈떎.',
|
||||
resolution: '?쒕쾭 愿由ъ옄?먭쾶 遊뉗쓽 ??븷 諛?梨꾨꼸 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
|
||||
},
|
||||
E4003: {
|
||||
userMessage: 'Discord 측 일시적인 문제가 발생했습니다.',
|
||||
resolution:
|
||||
'잠시 후 다시 시도해 주세요. 문제가 계속되면 https://discordstatus.com 에서 상태를 확인해 주세요.',
|
||||
userMessage: 'Discord???쇱떆?곸씤 臾몄젣媛 諛쒖깮?덉뒿?덈떎.',
|
||||
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛 吏?띾릺硫?https://discordstatus.com ?먯꽌 ?곹깭瑜??뺤씤?댁<?몄슂.',
|
||||
},
|
||||
},
|
||||
|
||||
// ?? ?먮윭 移댄뀒怨좊━ ??댄? ????????????????????????????????
|
||||
errorTitles: {
|
||||
USER_INPUT: '입력을 확인해주세요',
|
||||
PERMISSION: '권한이 부족합니다',
|
||||
BOT_INTERNAL: '문제가 발생했습니다',
|
||||
PERMISSION: '沅뚰븳??遺議깊빀?덈떎',
|
||||
BOT_INTERNAL: '臾몄젣媛 諛쒖깮?덉뒿?덈떎',
|
||||
DISCORD_API: '일시적인 문제입니다.',
|
||||
},
|
||||
|
||||
// ?? ?먮윭 Embed ?꾨뱶 ?쇰꺼 ????????????????????????????????
|
||||
errorFields: {
|
||||
resolution: '💡 해결 방법',
|
||||
},
|
||||
|
||||
// ?? ?뚯꽦 梨꾨꼸 ???????????????????????????????????????????
|
||||
voice: {
|
||||
channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.',
|
||||
defaultRoomName: '{{username}}의 방',
|
||||
controlPanel: {
|
||||
placeholder: '채널 설정 관리',
|
||||
rename: '채널 이름 변경',
|
||||
limit: '인원 제한 설정',
|
||||
lock: '채널 잠금 / 해제',
|
||||
kick: '유저 내보내기',
|
||||
limit: '?몄썝 ?쒗븳 ?ㅼ젙',
|
||||
lock: '梨꾨꼸 ?좉툑 / ?댁젣',
|
||||
kick: '?좎? 異붾갑',
|
||||
ban: '유저 차단 / 숨기기',
|
||||
transfer: '소유권 이전',
|
||||
transfer: '?뚯쑀沅??댁쟾',
|
||||
},
|
||||
responses: {
|
||||
channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.',
|
||||
channelUnlocked: '채널 잠금이 해제되었습니다! 누구나 참여할 수 있습니다.',
|
||||
channelRenamed: '채널 이름이 **{{name}}**(으)로 변경되었습니다!',
|
||||
limitSet: '인원 제한이 **{{limit}}**명으로 설정되었습니다!',
|
||||
channelUnlocked: '梨꾨꼸???댁젣?섏뿀?듬땲?? ?꾧뎄??李몄뿬?????덉뒿?덈떎.',
|
||||
channelRenamed: '梨꾨꼸 ?대쫫??**{{name}}**(??濡?蹂寃쎈릺?덉뒿?덈떎!',
|
||||
limitSet: '?몄썝 ?쒗븳??**{{limit}}**紐낆쑝濡??ㅼ젙?섏뿀?듬땲??',
|
||||
limitUnlimited: '무제한',
|
||||
kicked: '{{user}} 님을 채널에서 내보냈습니다.',
|
||||
banned: '{{user}} 님에게 채널이 보이지 않도록 차단했습니다.',
|
||||
transferPrompt: '채널의 새 소유자가 될 사용자를 선택하세요.',
|
||||
transferDone: '소유권이 {{user}} 님에게 이전되었습니다.',
|
||||
banPrompt: '차단하면 해당 사용자에게 채널이 보이지 않게 됩니다.',
|
||||
kicked: '{{user}}??瑜? 梨꾨꼸?먯꽌 異붾갑?덉뒿?덈떎.',
|
||||
banned: '{{user}}?먭쾶 梨꾨꼸???④린怨?李⑤떒?덉뒿?덈떎.',
|
||||
transferPrompt: '梨꾨꼸?????뚯쑀?먮? ?좏깮?섏꽭??',
|
||||
transferDone: '?뚯쑀沅뚯씠 {{user}}?먭쾶 ?댁쟾?섏뿀?듬땲??',
|
||||
banPrompt: '李⑤떒?섎㈃ ?대떦 ?좎??먭쾶 梨꾨꼸??蹂댁씠吏 ?딄쾶 ?⑸땲??',
|
||||
},
|
||||
},
|
||||
|
||||
// ?? 紐낅졊????????????????????????????????????????????????
|
||||
commands: {
|
||||
voiceSetup: {
|
||||
description: '임시 음성 채널을 위한 생성기 채널을 설정합니다.',
|
||||
description: '?꾩떆 ?뚯꽦 梨꾨꼸???꾪븳 ?앹꽦湲?梨꾨꼸???ㅼ젙?⑸땲??',
|
||||
setDescription: '기존 음성 채널을 생성기로 설정합니다.',
|
||||
createDescription: '새 음성 채널을 만들고 생성기로 설정합니다.',
|
||||
channelOptionDescription: '생성기로 사용할 음성 채널',
|
||||
categoryOptionDescription: '(선택) 임시 채널이 생성될 카테고리',
|
||||
nameOptionDescription: '새 생성기 음성 채널 이름',
|
||||
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
|
||||
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성·설정했습니다!',
|
||||
channelOptionDescription: '?앹꽦湲곕줈 ?ъ슜???뚯꽦 梨꾨꼸',
|
||||
categoryOptionDescription: '(?좏깮) ?꾩떆 梨꾨꼸???앹꽦??移댄뀒怨좊━',
|
||||
nameOptionDescription: '???앹꽦湲??뚯꽦 梨꾨꼸???대쫫',
|
||||
setSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??ㅼ젙?덉뒿?덈떎!',
|
||||
createSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??앹꽦 諛??ㅼ젙?덉뒿?덈떎!',
|
||||
},
|
||||
voiceConfig: {
|
||||
description: '서버의 임시 음성 채널 설정을 관리합니다.',
|
||||
setNameTitle: '기본 이름 템플릿 설정',
|
||||
setNameDesc: '임시 채널 생성 시 사용할 기본 이름 형식을 설정합니다. (사용자명: {{username}})',
|
||||
setLimitTitle: '기본 인원 제한 설정',
|
||||
setLimitDesc: '임시 채널 생성 시 적용할 기본 인원 제한을 설정합니다.',
|
||||
statusTitle: '현재 서버 음성 설정',
|
||||
description: '?쒕쾭???꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙??愿由ы빀?덈떎.',
|
||||
setNameTitle: '湲곕낯 ?대쫫 ?쒗뵆由??ㅼ젙',
|
||||
setNameDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???ъ슜??湲곕낯 ?대쫫 ?뺤떇???ㅼ젙?⑸땲?? (?ъ슜?먮챸: {{username}})',
|
||||
setLimitTitle: '湲곕낯 ?몄썝 ?쒗븳 ?ㅼ젙',
|
||||
setLimitDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???곸슜??湲곕낯 ?몄썝 ?쒗븳???ㅼ젙?⑸땲??',
|
||||
statusTitle: '?꾩옱 ?쒕쾭 ?뚯꽦 ?ㅼ젙',
|
||||
templateLabel: '이름 템플릿',
|
||||
limitLabel: '기본 인원 제한',
|
||||
setSuccess: '서버 임시 채널 설정이 업데이트되었습니다.',
|
||||
limitValue: '{{limit}}명 (0 = 무제한)',
|
||||
limitLabel: '湲곕낯 ?몄썝 ?쒗븳',
|
||||
setSuccess: '?쒕쾭???꾩떆 梨꾨꼸 ?ㅼ젙???낅뜲?댄듃?섏뿀?듬땲??',
|
||||
limitValue: '{{limit}}紐?(0 = 臾댁젣??',
|
||||
},
|
||||
language: {
|
||||
description: '봇의 언어를 설정합니다.',
|
||||
scopeDescription: '본인에게만 또는 서버 전체에 적용',
|
||||
localeDescription: '사용할 언어',
|
||||
scopeUser: '나만 적용',
|
||||
scopeServer: '서버 전체 (관리자 전용)',
|
||||
userSet: '개인 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
||||
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
||||
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 할 수 있습니다.',
|
||||
description: '遊뉗쓽 ?몄뼱瑜??ㅼ젙?⑸땲??',
|
||||
scopeDescription: '蹂몄씤?먭쾶留??먮뒗 ?쒕쾭 ?꾩껜???곸슜',
|
||||
localeDescription: '?ъ슜???몄뼱',
|
||||
scopeUser: '?섎쭔 ?곸슜',
|
||||
scopeServer: '?쒕쾭 ?꾩껜 (愿由ъ옄 ?꾩슜)',
|
||||
userSet: '媛쒖씤 ?몄뼱媛 **{{locale}}**(??濡??ㅼ젙?섏뿀?듬땲??',
|
||||
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: '미래 시각을 선택한 뒤 다시 시도해 주세요.',
|
||||
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: '사용',
|
||||
reminderOn: '?ъ슜',
|
||||
reminderOff: '사용 안 함',
|
||||
reminderNone: '자동 공지 없음',
|
||||
reminderNone: '?먮룞 怨듭? ?놁쓬',
|
||||
announcementChannelNone: '미설정',
|
||||
fields: {
|
||||
eventId: '이벤트 ID',
|
||||
startsAt: '시작 시각',
|
||||
reminder: '리마인더',
|
||||
announcementChannel: '공지 채널',
|
||||
status: '상태',
|
||||
eventId: '?대깽??ID',
|
||||
startsAt: '?쒖옉 ?쒓컖',
|
||||
reminder: '由щ쭏?몃뜑',
|
||||
announcementChannel: '怨듭? 梨꾨꼸',
|
||||
status: '?곹깭',
|
||||
},
|
||||
},
|
||||
music: {
|
||||
description: '음성 채널에서 YouTube 오디오를 재생합니다.',
|
||||
addDescription: 'YouTube를 검색하거나 영상 URL을 재생 목록에 추가합니다.',
|
||||
queueDescription: '현재 음악 재생 목록을 표시합니다.',
|
||||
removeDescription: '대기열에서 곡을 삭제합니다.',
|
||||
pauseDescription: '현재 재생 중인 곡을 일시정지합니다.',
|
||||
resumeDescription: '일시정지된 곡의 재생을 다시 시작합니다.',
|
||||
skipDescription: '현재 재생 중인 곡을 건너뜁니다.',
|
||||
stopDescription: '재생을 중지하고 대기열을 비웁니다.',
|
||||
leaveDescription: '봇을 음성 채널에서 내보냅니다.',
|
||||
queryDescription: 'YouTube 검색어',
|
||||
urlDescription: 'YouTube 영상 URL',
|
||||
indexDescription: '대기열에서 삭제할 인덱스',
|
||||
addMutuallyExclusive: '검색어와 YouTube URL 중 하나만 선택하세요.',
|
||||
addMutuallyExclusiveResolution: '`query` 또는 `url` 중 정확히 하나만 입력하세요.',
|
||||
notInVoice: '음악 명령을 사용하려면 음성 채널에 있어야 합니다.',
|
||||
notInVoiceResolution: '먼저 음성 채널에 참가한 뒤 다시 시도하세요.',
|
||||
differentVoiceChannel: '다른 음성 채널에서 이미 음악이 재생 중입니다.',
|
||||
differentVoiceChannelResolution: '봇과 같은 음성 채널에 들어가거나, 현재 세션이 끝날 때까지 기다리세요.',
|
||||
noSearchResults: '해당 검색어로 YouTube 결과를 찾지 못했습니다.',
|
||||
noSearchResultsResolution: '검색어를 구체적으로 바꾸거나 YouTube URL을 직접 지정하세요.',
|
||||
invalidUrl: '제공한 YouTube URL이 올바르지 않습니다.',
|
||||
invalidUrlResolution: '일반적인 `youtube.com` 또는 `youtu.be` 영상 링크를 사용하세요.',
|
||||
noActiveSession: '이 서버에 활성 음악 세션이 없습니다.',
|
||||
noActiveSessionResolution: '먼저 곡을 추가해 재생을 시작하세요.',
|
||||
queueAddedNowPlaying: '**{{title}}**을(를) 추가하고 {{channel}}에서 재생을 시작했습니다.',
|
||||
queueAddedLater: '**{{title}}**을(를) 대기열에 추가했습니다. 순번: `#{{position}}`.',
|
||||
playlistAddedNowPlaying: '플레이리스트에서 **{{count}}**곡을 추가하고 {{channel}}에서 재생을 시작했습니다.',
|
||||
playlistAddedLater: '플레이리스트에서 **{{count}}**곡을 대기열에 추가했습니다.',
|
||||
queueTitle: '음악 대기열',
|
||||
queueEmpty: '대기열이 비어 있습니다.',
|
||||
queueNowPlaying: '지금 재생 중',
|
||||
queueUpcoming: '다음 재생',
|
||||
queueMoreItems: '… 외 **{{count}}**곡 더 있음',
|
||||
queueRemoved: '대기열에서 **{{title}}**을(를) 제거했습니다.',
|
||||
queueRemoveOutOfRange: '해당 대기열 번호가 없습니다.',
|
||||
queueRemoveOutOfRangeResolution: '먼저 `/music queue`로 현재 대기열 번호를 확인하세요.',
|
||||
pauseSuccess: '현재 곡을 일시정지했습니다.',
|
||||
resumeSuccess: '재생을 재개했습니다.',
|
||||
skipSuccess: '현재 곡을 건너뛰었습니다.',
|
||||
leaveSuccess: '음성 채널에서 나가고 대기열을 비웠습니다.',
|
||||
stopSuccess: '재생을 중지하고 대기열을 비웠습니다.',
|
||||
playbackStartedTitle: '지금 재생 중',
|
||||
playbackIdleTitle: '대기열 종료',
|
||||
playbackIdleBody: '대기열에 더 이상 곡이 없습니다.',
|
||||
playbackFailed: '**{{title}}** 재생에 실패했습니다. 다음 곡으로 넘어갑니다.',
|
||||
playbackFailedResolution: 'YouTube에서 스트림을 불러오지 못했습니다.',
|
||||
streamUnavailable: '이 영상의 재생 가능한 오디오 스트림을 불러올 수 없습니다.',
|
||||
streamUnavailableResolution: '다른 영상을 시도하거나 나중에 다시 추가해 보세요.',
|
||||
requestedBy: '요청자',
|
||||
duration: '길이',
|
||||
progress: '진행',
|
||||
source: '출처',
|
||||
status: '상태',
|
||||
queueLength: '대기열 길이',
|
||||
nextTrack: '다음 곡',
|
||||
statusPlaying: '재생 중',
|
||||
statusPaused: '일시정지',
|
||||
unknownDuration: '알 수 없음',
|
||||
description: 'Play YouTube audio in voice channels.',
|
||||
addDescription: 'Search YouTube or add a video URL to the queue.',
|
||||
queueDescription: 'Show the current music queue.',
|
||||
removeDescription: 'Remove a track from the upcoming queue.',
|
||||
pauseDescription: 'Pause the currently playing track.',
|
||||
resumeDescription: 'Resume the paused track.',
|
||||
skipDescription: 'Skip the currently playing track.',
|
||||
stopDescription: 'Stop playback and clear the queue.',
|
||||
leaveDescription: 'Disconnect the bot from the voice channel.',
|
||||
queryDescription: 'Search query for YouTube',
|
||||
urlDescription: 'YouTube video URL',
|
||||
indexDescription: 'Queue index to remove',
|
||||
addMutuallyExclusive: 'Choose either a search query or a YouTube URL.',
|
||||
addMutuallyExclusiveResolution: 'Provide exactly one of `query` or `url`.',
|
||||
notInVoice: 'You must be in a voice channel to use music commands.',
|
||||
notInVoiceResolution: 'Join a voice channel first, then try again.',
|
||||
differentVoiceChannel: 'Music is already being used in another voice channel.',
|
||||
differentVoiceChannelResolution: 'Join the same voice channel as the bot or wait until the current session ends.',
|
||||
noSearchResults: 'No YouTube results were found for that query.',
|
||||
noSearchResultsResolution: 'Try a more specific search phrase or use a direct YouTube URL.',
|
||||
invalidUrl: 'The provided YouTube URL is invalid.',
|
||||
invalidUrlResolution: 'Use a standard `youtube.com` or `youtu.be` video link.',
|
||||
noActiveSession: 'There is no active music session in this server.',
|
||||
noActiveSessionResolution: 'Add a track first to start playback.',
|
||||
queueAddedNowPlaying: 'Added **{{title}}** and started playback in {{channel}}.',
|
||||
queueAddedLater: 'Added **{{title}}** to the queue. Position: `#{{position}}`.',
|
||||
playlistAddedNowPlaying: 'Added **{{count}}** tracks from the playlist and started playback in {{channel}}.',
|
||||
playlistAddedLater: 'Added **{{count}}** tracks from the playlist to the queue.',
|
||||
queueTitle: 'Music Queue',
|
||||
queueEmpty: 'The music queue is currently empty.',
|
||||
queueNowPlaying: 'Now Playing',
|
||||
queueUpcoming: 'Up Next',
|
||||
queueMoreItems: '...and **{{count}}** more track(s).',
|
||||
queueRemoved: 'Removed **{{title}}** from the queue.',
|
||||
queueRemoveOutOfRange: 'That queue index does not exist.',
|
||||
queueRemoveOutOfRangeResolution: 'Use `/music queue` to check the current queue indexes first.',
|
||||
pauseSuccess: 'Paused the current track.',
|
||||
resumeSuccess: 'Resumed playback.',
|
||||
skipSuccess: 'Skipped the current track.',
|
||||
leaveSuccess: 'Disconnected from the voice channel and cleared the queue.',
|
||||
stopSuccess: 'Stopped playback and cleared the queue.',
|
||||
playbackStartedTitle: 'Now Playing',
|
||||
playbackIdleTitle: 'Queue Finished',
|
||||
playbackIdleBody: 'There are no more tracks in the queue.',
|
||||
playbackFailed: 'Failed to play **{{title}}**. Skipping to the next track.',
|
||||
playbackFailedResolution: 'The stream could not be loaded from YouTube.',
|
||||
streamUnavailable: 'Could not load a playable audio stream for this video.',
|
||||
streamUnavailableResolution: 'Try another video or add the track again later.',
|
||||
requestedBy: 'Requested by',
|
||||
duration: 'Duration',
|
||||
progress: 'Progress',
|
||||
source: 'Source',
|
||||
status: 'Status',
|
||||
queueLength: 'Queue Length',
|
||||
nextTrack: 'Next Track',
|
||||
statusPlaying: 'Playing',
|
||||
statusPaused: 'Paused',
|
||||
unknownDuration: 'Unknown',
|
||||
buttons: {
|
||||
pause: '일시정지',
|
||||
resume: '재개',
|
||||
skip: '건너뛰기',
|
||||
stop: '중지',
|
||||
leave: '나가기',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
skip: 'Skip',
|
||||
stop: 'Stop',
|
||||
leave: 'Leave',
|
||||
},
|
||||
},
|
||||
fishing: {
|
||||
|
|
@ -315,113 +317,112 @@ export const ko: TranslationSchema = {
|
|||
},
|
||||
permissionAudit: {
|
||||
title: '봇 권한 진단 보고서',
|
||||
channel: '채널',
|
||||
noResults: '진단할 기능이 없습니다. 봇이 아직 설정되지 않았을 수 있습니다.',
|
||||
summaryLabel: '진단 결과 요약',
|
||||
summaryOk: '모든 항목 정상. 문제가 없습니다.',
|
||||
summaryIssue: '{{fail}}건 실패 · {{warn}}건 경고가 있습니다.',
|
||||
hierarchyWarning:
|
||||
"봇 역할(위치: {{botPos}})이 '{{role}}'(위치: {{targetPos}})보다 위에 있어야 해당 역할을 관리할 수 있습니다.",
|
||||
channel: '梨꾨꼸',
|
||||
noResults: '吏꾨떒??湲곕뒫???놁뒿?덈떎. 遊뉗씠 ?꾩쭅 ?ㅼ젙?섏? ?딆븯?????덉뒿?덈떎.',
|
||||
summaryLabel: '吏꾨떒 寃곌낵 ?붿빟',
|
||||
summaryOk: '??紐⑤뱺 ??ぉ ?뺤긽. 臾몄젣媛 ?놁뒿?덈떎.',
|
||||
summaryIssue: '??{{fail}}媛??ㅽ뙣 쨌 ?좑툘 {{warn}}媛?寃쎄퀬 媛먯???',
|
||||
hierarchyWarning: "遊???븷(?쒖쐞: {{botPos}})??'{{role}}'(?쒖쐞: {{targetPos}})蹂대떎 ?꾩뿉 ?덉뼱??愿由ы븷 ???덉뒿?덈떎.",
|
||||
features: {
|
||||
BASIC: '기본 봇 기능',
|
||||
VOICE_GLOBAL: '임시 음성 채널 (전역)',
|
||||
VOICE_GENERATOR_CHANNEL: '음성 생성기 채널',
|
||||
VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리',
|
||||
INVITE_TRACKING: '초대 추적',
|
||||
INVITE_ROLE_HIERARCHY: '초대 역할 부여 (계층 검사)',
|
||||
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
|
||||
BASIC: '湲곕낯 遊?湲곕뒫',
|
||||
VOICE_GLOBAL: '?꾩떆 ?뚯꽦 梨꾨꼸 (?꾩뿭)',
|
||||
VOICE_GENERATOR_CHANNEL: '?뚯꽦 ?앹꽦湲?梨꾨꼸',
|
||||
VOICE_GENERATOR_CATEGORY: '?뚯꽦 ?앹꽦湲?移댄뀒怨좊━',
|
||||
INVITE_TRACKING: '珥덈? 異붿쟻',
|
||||
INVITE_ROLE_HIERARCHY: '珥덈? ??븷 遺??(怨꾩링 寃??',
|
||||
MIMIC_WEBHOOK: '硫붿떆吏 ?됰궡 (Webhook)',
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
description: '설정 마법사를 실행해 봇의 필수 기능을 단계별로 설정합니다.',
|
||||
step0: {
|
||||
title: '봇 설정 마법사 시작',
|
||||
desc:
|
||||
'환영합니다! 이 마법사로 아래 4가지 항목을 설정합니다.\n\n1단계 **언어 설정**\n2단계 **필수 권한 확인**\n3단계 **감사 채널 설정**\n4단계 **임시 음성 채널 설정**',
|
||||
startBtn: '설정 시작하기',
|
||||
description: '?ㅼ젙 留덈쾿?щ? ?ㅽ뻾?섏뿬 遊뉗쓽 ?꾩닔 湲곕뒫?ㅼ쓣 ?④퀎蹂꾨줈 ?ㅼ젙?⑸땲??',
|
||||
step0: {
|
||||
title: '??遊??ㅼ젙 留덈쾿???쒖옉',
|
||||
desc: '?섏쁺?⑸땲?? ??留덈쾿?щ? ?듯빐 ?꾨옒 4媛吏 ??ぉ???ㅼ젙?⑸땲??\n\n1截뤴깵 **?몄뼱 ?ㅼ젙**\n2截뤴깵 **?꾩닔 沅뚰븳 ?먭?**\n3截뤴깵 **媛먯궗 梨꾨꼸 ?ㅼ젙**\n4截뤴깵 **?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙**',
|
||||
startBtn: '?ㅼ젙 ?쒖옉?섍린'
|
||||
},
|
||||
step1: {
|
||||
title: '1단계 언어 설정',
|
||||
desc: '서버 전체에 적용될 봇의 기본 언어를 선택하세요. (현재: **{{locale}}**)',
|
||||
step1: {
|
||||
title: '1截뤴깵 ?몄뼱 ?ㅼ젙',
|
||||
desc: '?쒕쾭 ?꾩껜???곸슜??遊뉗쓽 湲곕낯 ?몄뼱瑜??좏깮?섏꽭?? (?꾩옱: **{{locale}}**)',
|
||||
placeholder: '언어를 선택하세요',
|
||||
nextBtn: '다음 단계',
|
||||
skipBtn: '건너뛰기',
|
||||
nextBtn: '?ㅼ쓬 ?④퀎',
|
||||
skipBtn: '嫄대꼫?곌린'
|
||||
},
|
||||
step2: {
|
||||
title: '2단계 필수 권한 확인',
|
||||
descOk: '**필요한 권한이 모두 부여되어 있습니다.**',
|
||||
descFail:
|
||||
'**일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해 주세요.',
|
||||
step2: {
|
||||
title: '2截뤴깵 ?꾩닔 沅뚰븳 ?먭?',
|
||||
descOk: '??**紐⑤뱺 ?꾩닔 沅뚰븳???뺤긽?곸쑝濡?遺?щ릺???덉뒿?덈떎.**',
|
||||
descFail: '?좑툘 **?쇰? 沅뚰븳??遺議깊빀?덈떎.**\n寃곌낵瑜??뺤씤?섍퀬 遊???븷???꾩슂??湲곕뒫 沅뚰븳??遺?ы빐二쇱꽭??',
|
||||
recheckBtn: '다시 검사하기',
|
||||
nextBtn: '다음 단계',
|
||||
nextBtn: '?ㅼ쓬 ?④퀎'
|
||||
},
|
||||
step3: {
|
||||
title: '3단계 감사 채널 설정',
|
||||
desc: '봇 이벤트와 오류 로그를 받을 채널을 선택하세요.',
|
||||
placeholder: '감사 로그 채널 선택',
|
||||
disableBtn: '감사 로그 끄기',
|
||||
nextBtn: '다음 단계',
|
||||
step3: {
|
||||
title: '3截뤴깵 媛먯궗 梨꾨꼸 ?ㅼ젙',
|
||||
desc: '遊뉗쓽 二쇱슂 ?대깽?몄? ?먮윭 ?듬낫瑜?諛쏆쓣 梨꾨꼸???좏깮?댁<?몄슂.',
|
||||
placeholder: '媛먯궗 ?듬낫 梨꾨꼸 ?좏깮',
|
||||
disableBtn: '媛먯궗 梨꾨꼸 ?꾧린/?댁젣',
|
||||
nextBtn: '?ㅼ쓬 ?④퀎'
|
||||
},
|
||||
step4: {
|
||||
title: '3-1단계 감사 로그 카테고리',
|
||||
desc: '받을 로그 카테고리를 선택하세요. **초록**은 켜짐, **빨강**은 꺼짐입니다.',
|
||||
nextBtn: '다음 단계',
|
||||
title: '媛먯궗 濡쒓렇 移댄뀒怨좊━ ?ㅼ젙',
|
||||
desc: '濡쒓렇瑜??섏떊??移댄뀒怨좊━瑜??좏깮?댁<?몄슂.',
|
||||
nextBtn: '?ㅼ쓬 ?④퀎',
|
||||
},
|
||||
step5: {
|
||||
title: '4단계 임시 음성 채널 설정',
|
||||
desc:
|
||||
'임시 음성 채널의 「생성기 채널」을 선택하세요.\n기존 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.',
|
||||
placeholder: '생성기로 쓸 음성 채널 선택',
|
||||
autoBtn: '자동 생성하기',
|
||||
skipBtn: '임시 음성 사용 안 함',
|
||||
nextBtn: '설정 완료',
|
||||
step5: {
|
||||
title: '4截뤴깵 ?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙',
|
||||
desc: '?꾩떆 ?뚯꽦 梨꾨꼸???앹꽦??"?앹꽦湲?梨꾨꼸"???좏깮?댁<?몄슂.\n湲곗〈??梨꾨꼸??怨좊Ⅴ嫄곕굹 移댄뀒怨좊━/梨꾨꼸??遊뉗씠 **?먮룞 ?앹꽦**?섍쾶 ???섎룄 ?덉뒿?덈떎.',
|
||||
placeholder: '?앹꽦湲곕줈 ???뚯꽦 梨꾨꼸 ?좏깮',
|
||||
autoBtn: '?? ?먮룞 ?앹꽦?섍린',
|
||||
skipBtn: '?꾩떆 ?뚯꽦 ?ъ슜 ?덊븿',
|
||||
nextBtn: '?ㅼ젙 ?꾨즺'
|
||||
},
|
||||
step6: {
|
||||
title: '설정 요약',
|
||||
desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 감사 카테고리**: {{categories}}\n**4. 임시 음성 채널**: {{voice}}',
|
||||
finishBtn: '마치기',
|
||||
step6: {
|
||||
title: '?럦 ?ㅼ젙 ?꾨즺 ?붿빟',
|
||||
desc: '**1. ?몄뼱**: {{lang}}\n**2. 媛먯궗 梨꾨꼸**: {{audit}}\n**3. 媛먯궗 移댄뀒怨좊━**: {{categories}}\n**4. ?꾩떆 ?뚯꽦 梨꾨꼸**: {{voice}}',
|
||||
finishBtn: '마치기'
|
||||
},
|
||||
finished: '설정 마법사를 완료했습니다.',
|
||||
expired: '시간이 만료되었습니다. `/setup`을 다시 실행해 주세요.',
|
||||
defaultCategoryName: '음성 채널',
|
||||
defaultGeneratorName: '채널 생성하기',
|
||||
finished: '???ㅼ젙 留덈쾿?щ? 醫낅즺?덉뒿?덈떎.',
|
||||
expired: '???쒓컙??留뚮즺?섏뿀?듬땲?? `/setup`???ㅼ떆 ?ㅽ뻾?댁<?몄슂.',
|
||||
defaultCategoryName: '?뚯꽦 梨꾨꼸',
|
||||
defaultGeneratorName: '??梨꾨꼸 ?앹꽦?섍린',
|
||||
auditCategories: {
|
||||
SYSTEM: '시스템',
|
||||
BOOT: '부팅',
|
||||
VOICE: '음성',
|
||||
PERMISSION: '권한',
|
||||
INVITE: '초대',
|
||||
VOICE: '?뚯꽦',
|
||||
PERMISSION: '沅뚰븳',
|
||||
INVITE: '珥덈?',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
title: '기능 설정 변경 결과',
|
||||
noOptions: '변경할 옵션을 하나 이상 선택해 주세요.',
|
||||
title: '湲곕뒫 ?ㅼ젙 蹂寃?寃곌낵',
|
||||
noOptions: '蹂寃쏀븷 ?듭뀡???섎굹 ?댁긽 ?좏깮?댁<?몄슂.',
|
||||
mimic: {
|
||||
label: '흉내(Mimic)',
|
||||
label: '誘몃?(Mimic)',
|
||||
enabled: '활성화',
|
||||
disabled: '비활성화',
|
||||
disabled: '鍮꾪솢?깊솕',
|
||||
},
|
||||
emoji: {
|
||||
label: '큰 이모지(Big Emoji)',
|
||||
label: '?대え吏 ?뺣?(Big Emoji)',
|
||||
enabled: '활성화',
|
||||
disabled: '비활성화',
|
||||
disabled: '鍮꾪솢?깊솕',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ?? 紐⑤떖 ????????????????????????????????????????????????
|
||||
modals: {
|
||||
renameTitle: '음성 채널 이름 변경',
|
||||
renameLabel: '새 채널 이름',
|
||||
limitTitle: '인원 제한 설정',
|
||||
limitLabel: '인원 제한 (0 = 무제한, 1–99)',
|
||||
renameLabel: '??梨꾨꼸 ?대쫫',
|
||||
limitTitle: '?몄썝 ?쒗븳 ?ㅼ젙',
|
||||
limitLabel: '?몄썝 ?쒗븳 (0 = 臾댁젣?? 1-99)',
|
||||
},
|
||||
|
||||
// ?? ??됲듃 硫붾돱 ?뚮젅?댁뒪???????????????????????????????
|
||||
selects: {
|
||||
kickUser: '추방할 유저를 선택하세요',
|
||||
banUser: '차단할 유저를 선택하세요',
|
||||
transferOwner: '소유권을 이전할 유저를 선택하세요',
|
||||
},
|
||||
|
||||
// ?? ?곹깭 硫붿떆吏 ??????????????????????????????????????????
|
||||
presence: {
|
||||
servers: '{{guildCount}}개의 서버에서 작동 중',
|
||||
help: '/help 명령어를 확인하세요',
|
||||
|
|
@ -429,3 +430,6 @@ export const ko: TranslationSchema = {
|
|||
version: 'Kord v1.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import {
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
AttachmentBuilder,
|
||||
ButtonBuilder,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { Client, Guild, Invite, GuildMember } from 'discord.js';
|
||||
import { redis } from '../cache';
|
||||
import { prisma } from '../database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class InviteService {
|
||||
/** In-process invite snapshot per guild for join attribution. */
|
||||
private static readonly inviteCache = new Map<string, string>();
|
||||
|
||||
public static async cacheAllInvites(client: Client) {
|
||||
for (const [, guild] of client.guilds.cache) {
|
||||
await this.cacheGuildInvites(guild);
|
||||
|
|
@ -20,7 +18,7 @@ export class InviteService {
|
|||
code: inv.code,
|
||||
uses: inv.uses || 0
|
||||
}));
|
||||
this.inviteCache.set(guild.id, JSON.stringify(inviteData));
|
||||
await redis.set(`invites:${guild.id}`, JSON.stringify(inviteData));
|
||||
} catch (error) {
|
||||
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
|
||||
}
|
||||
|
|
@ -43,7 +41,7 @@ export class InviteService {
|
|||
try {
|
||||
// Fetch current active invites
|
||||
const newInvites = await guild.invites.fetch();
|
||||
const cachedData = this.inviteCache.get(guild.id);
|
||||
const cachedData = await redis.get(`invites:${guild.id}`);
|
||||
|
||||
let usedInvite: Invite | undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import {
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonInteraction,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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';
|
||||
|
|
@ -8,43 +9,56 @@ import { auditLogService } from './AuditLogService';
|
|||
|
||||
export class VoiceService {
|
||||
public static async syncChannels(client: Client) {
|
||||
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;
|
||||
|
||||
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 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 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;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
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<WebhookClient | null> {
|
||||
try {
|
||||
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 });
|
||||
// 1. Check cache
|
||||
const cachedData = await redis.get(`webhook:${channel.id}`);
|
||||
if (cachedData) {
|
||||
const { id, token } = JSON.parse(cachedData);
|
||||
return new WebhookClient({ id, token });
|
||||
}
|
||||
|
||||
// 2. Fetch from Discord API
|
||||
|
|
@ -43,12 +40,14 @@ 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) {
|
||||
this.webhookCache.set(channel.id, {
|
||||
id: kordWebhook.id,
|
||||
token: kordWebhook.token,
|
||||
expiresAt: Date.now() + this.WEBHOOK_CACHE_TTL_MS,
|
||||
});
|
||||
await redis.set(
|
||||
`webhook:${channel.id}`,
|
||||
JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }),
|
||||
'EX',
|
||||
86400
|
||||
);
|
||||
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,65 +1,6 @@
|
|||
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();
|
||||
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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
142
yarn.lock
142
yarn.lock
|
|
@ -1120,6 +1120,13 @@ __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"
|
||||
|
|
@ -3010,6 +3017,13 @@ __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"
|
||||
|
|
@ -3107,13 +3121,6 @@ __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"
|
||||
|
|
@ -3720,7 +3727,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"flatted@npm:^3.2.7, flatted@npm:^3.2.9":
|
||||
"flatted@npm:^3.2.9":
|
||||
version: 3.4.2
|
||||
resolution: "flatted@npm:3.4.2"
|
||||
checksum: 10c0/a65b67aae7172d6cdf63691be7de6c5cd5adbdfdfe2e9da1a09b617c9512ed794037741ee53d93114276bff3f93cd3b0d97d54f9b316e1e4885dde6e9ffdf7ed
|
||||
|
|
@ -3737,17 +3744,6 @@ __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"
|
||||
|
|
@ -3928,7 +3924,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"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":
|
||||
"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
|
||||
|
|
@ -4114,6 +4110,23 @@ __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"
|
||||
|
|
@ -4769,18 +4782,6 @@ __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"
|
||||
|
|
@ -4807,8 +4808,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"
|
||||
|
|
@ -4863,6 +4864,20 @@ __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"
|
||||
|
|
@ -4884,19 +4899,6 @@ __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"
|
||||
|
|
@ -5845,6 +5847,22 @@ __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"
|
||||
|
|
@ -5896,13 +5914,6 @@ __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"
|
||||
|
|
@ -6172,6 +6183,13 @@ __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"
|
||||
|
|
@ -6179,17 +6197,6 @@ __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"
|
||||
|
|
@ -6532,13 +6539,6 @@ __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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue