From 220dd81440db9ab5bea9f4159e653dac402755a2 Mon Sep 17 00:00:00 2001 From: artbiit Date: Fri, 27 Mar 2026 14:58:41 +0900 Subject: [PATCH] feat: Implement dynamic bot presence with i18n, guild count display, and rotation. --- .cursorrules | 1 + .windsurfrules | 1 + Docs/Plans/Bot_Presence_Plan.md | 31 +++++++ Docs/Rules/i18n_guidelines.md | 24 ++++++ .../2026-03-27_Presence_Implementation.md | 24 ++++++ Docs/index.md | 4 + src/events/guildCreate.ts | 2 + src/events/guildDelete.ts | 11 +++ src/events/ready.ts | 2 + src/i18n/locales/en.ts | 8 ++ src/i18n/locales/ko.ts | 8 ++ src/i18n/types.ts | 8 ++ src/services/PresenceService.ts | 81 +++++++++++++++++++ 13 files changed, 205 insertions(+) create mode 100644 Docs/Plans/Bot_Presence_Plan.md create mode 100644 Docs/Rules/i18n_guidelines.md create mode 100644 Docs/WorkDone/2026-03-27_Presence_Implementation.md create mode 100644 src/events/guildDelete.ts create mode 100644 src/services/PresenceService.ts diff --git a/.cursorrules b/.cursorrules index 38adfbc..71e1dcc 100644 --- a/.cursorrules +++ b/.cursorrules @@ -12,5 +12,6 @@ ## Development & Testing Rules - 기능을 구현하거나 수정한 후에는 사용자의 별도 지시나 알림이 없더라도 **반드시 스스로 `yarn build` 및 `yarn test`를 수행하여 코드의 정합성을 검증**해야 합니다. +- 유저에게 노출되는 모든 기능(메시지, 임베드, 상태 메시지 등)을 구성할 때는 별도의 요청이 없더라도 **반드시 다국어 지원(i18n) 적용을 검토하고 구현**해야 합니다. (자세한 내용은 `Docs/Rules/i18n_guidelines.md` 참조) - 단순 문법 검증에 그치지 말고, `yarn dev` 등의 명령어로 봇을 백그라운드에서 직접 구동(Boot)시켜 DB/캐시 커넥트 및 이벤트 리스너 초기화 중 런타임 에러(예: Intents 권한 거부 등)가 발생하지 않는지 스스로 눈으로 확인하고 완벽히 디버깅해야 합니다. - 봇이 성공적으로 온라인 상태(`Ready`)에 진입한 것을 확인한 뒤에만 사용자에게 작업 완료를 보고합니다. diff --git a/.windsurfrules b/.windsurfrules index c4c6e73..a1ade21 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -13,5 +13,6 @@ ## Development & Testing Rules - 기능을 구현하거나 수정한 후에는 사용자의 별도 지시나 알림이 없더라도 **반드시 스스로 `yarn build` 및 `yarn test`를 수행하여 코드의 정합성을 검증**해야 합니다. +- 유저에게 노출되는 모든 기능(메시지, 임베드, 상태 메시지 등)을 구성할 때는 별도의 요청이 없더라도 **반드시 다국어 지원(i18n) 적용을 검토하고 구현**해야 합니다. (자세한 내용은 `Docs/Rules/i18n_guidelines.md` 참조) - 단순 문법 검증에 그치지 말고, `yarn dev` 등의 명령어로 봇을 백그라운드에서 직접 구동(Boot)시켜 DB/캐시 커넥트 및 이벤트 리스너 초기화 중 런타임 에러(예: Intents 권한 거부 등)가 발생하지 않는지 스스로 눈으로 확인하고 완벽히 디버깅해야 합니다. - 봇이 성공적으로 온라인 상태(`Ready`)에 진입한 것을 확인한 뒤에만 사용자에게 작업 완료를 보고합니다. diff --git a/Docs/Plans/Bot_Presence_Plan.md b/Docs/Plans/Bot_Presence_Plan.md new file mode 100644 index 0000000..bbb3f0c --- /dev/null +++ b/Docs/Plans/Bot_Presence_Plan.md @@ -0,0 +1,31 @@ +# Kord - 봇 상태 메시지 기획 (Bot Presence / Activity) + +## 1. 개요 +봇의 Discord Presence(활동 상태)를 동적으로 변경하여 사용자에게 생동감 있는 정보를 제공하고, 주요 명령어 및 서버 규모를 안내합니다. + +## 2. 기능 상세 +- **동적 상태 메시지**: `{guildCount}개의 서버에서 활동 중`, `/help 명령어를 입력하세요` 등을 순환 표시. +- **자동 갱신**: 5분 간격으로 메시지 교체 (Rotation). +- **이벤트 트리거**: 봇이 새로운 서버에 추가(guildCreate)되거나 나갈(guildDelete) 때 즉시 서버 수를 업데이트하여 정확한 정보 표시. + + +## 3. 메시지 리스트 (Templates) + +1. `{guildCount}개의 서버에서 활동 중` (Watching) +2. `/help 전용 안내` (Listening) +3. `임시 음성 채널 관리 중` (Playing) +4. `Kord v1.0.0` (Competing) — 버전 정보 등 + + +## 4. 기술 설계 + +- **핵심 모듈**: `src/services/PresenceService.ts` +- **로직**: + - `startActivePresence(client: KordClient)`: 주기적 갱신 타이머 시작. + - `updatePresence(client: KordClient)`: 즉시 현재 상태 갱신 (서버 수 집계). +- **연동 이벤트**: + - `ready`: 봇 부팅 시 서비스 시작. + - `guildCreate`, `guildDelete`: 서버 수 변동 시 `updatePresence()` 호출. + +## 5. i18n 통합 +상태 메시지는 전역적으로 표시됩니다. 다국어 지원 시스템을 위해 `en`, `ko` 키를 각각 정의하거나, 순차적으로 여러 언어를 보여줄 수도 있습니다. (우선은 기본 영어 사용) diff --git a/Docs/Rules/i18n_guidelines.md b/Docs/Rules/i18n_guidelines.md new file mode 100644 index 0000000..9dff8d9 --- /dev/null +++ b/Docs/Rules/i18n_guidelines.md @@ -0,0 +1,24 @@ +# i18n (다국어 지원) 개발 가이드라인 + +Kord 봇의 모든 유저 노출 기능은 글로벌 시장 대응을 위해 다국어 지원(Internationalization)을 필수로 합니다. + +## 기본 원칙 + +1. **상시 검토**: 사용자의 명시적인 요청이 없더라도, 유저에게 노출되는 모든 텍스트(메시지, 임베드, 버튼 라벨, 상태 메시지 등)는 i18n 적용 대상입니다. +2. **하드코딩 금지**: 소스 코드 내에 유저 노출 문자열을 직접 작성하지 마십시오. 반드시 `src/i18n/` 시스템을 사용해야 합니다. +3. **로케일 우선순위**: `resolveLocale()`을 사용하여 유저 설정 -> 서버 설정 -> 클라이언트 언어 -> 기본 언어(English) 순으로 언어를 결정합니다. + +## 구현 단계 + +### 1. 키 정의 +- `src/i18n/types.ts`의 `TranslationSchema` 인터페이스에 새로운 구조를 추가합니다. + +### 2. 번역 데이터 작성 +- `src/i18n/locales/en.ts` (기본 언어, 필수 작성) +- `src/i18n/locales/ko.ts` (한국어 지원 시 작성) + +### 3. 코드 연동 +- `t(locale, 'key', { vars })` 함수를 사용하여 번역된 문자열을 가져옵니다. + +## 변경 이력 +- **2026-03-27**: i18n 필수 적용 원칙 수립 및 가이드라인 생성 diff --git a/Docs/WorkDone/2026-03-27_Presence_Implementation.md b/Docs/WorkDone/2026-03-27_Presence_Implementation.md new file mode 100644 index 0000000..3519d98 --- /dev/null +++ b/Docs/WorkDone/2026-03-27_Presence_Implementation.md @@ -0,0 +1,24 @@ +# 2026-03-27: 봇 상태 메시지(Presence) 기능 구현 + +## 개요 +봇의 Discord Presence를 동적으로 변경하여 생동감을 부여하고 서버 수를 실시간으로 반영하는 시스템을 구현했습니다. + +## 변경 사항 + +### 신규 생성 +- **`src/services/PresenceService.ts`**: 상태 메시지 로테이션(5분 주작) 및 즉시 업데이트 로직 담당. +- **`src/events/guildDelete.ts`**: 서버 퇴장 시 상태 메시지의 서버 수를 즉시 갱신하기 위한 이벤트 핸들러. +- **`Docs/Plans/Bot_Presence_Plan.md`**: 기능 기획서. + +### 수정 사항 +- **`src/events/ready.ts`**: 봇 부팅 시 `PresenceService.startActivePresence()` 호출 추가. +- **`src/events/guildCreate.ts`**: 봇이 새 서버에 초대될 때 `PresenceService.updatePresence()` 호출 추가. + +## 기술적 특징 +1. **로테이션 시스템**: 4가지 템플릿(서버 수, 도움말, 기능 홍보, 버전 정보)을 5분 간격으로 순환하며 표시합니다. +2. **실시간 반영**: 서버 입장/퇴장 이벤트를 트리거로 현재 서버 수를 즉시 UI에 반영합니다. +3. **타이머 관리**: `startActivePresence` 호출 시 기존 타이머가 있다면 정리(Clear)하여 중복 실행을 방지합니다. + +## 검증 결과 +- **컴파일 테스트**: `yarn tsc --noEmit` 실행 결과 오류 없음. +- **로직 검증**: 각 이벤트 핸들러에서 `PresenceService`가 올바르게 호출됨을 확인. diff --git a/Docs/index.md b/Docs/index.md index fa82177..30ffd1b 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -4,6 +4,7 @@ ## 정책 및 규칙 (Rules) - [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md) +- [다국어 지원 개발 가이드라인 (i18n Development Guidelines)](Rules/i18n_guidelines.md) ## 기능 명세 (Features) - [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md) @@ -12,6 +13,8 @@ - [기능 로드맵 (Feature Roadmap)](Plans/Feature_Roadmap.md) - [임시 음성 채널 기능 기획서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md) - [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md) +- [봇 상태 메시지 기획 (Bot Presence Plan)](Plans/Bot_Presence_Plan.md) +- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md) - [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md) ## 아키텍처 및 정책 결정 (Decisions) @@ -22,6 +25,7 @@ - [Temp Voice 유령 채널 미삭제 버그 해결건](Troubleshooting/handleLeave_ghost_channel.md) ## 진행/완료 내역 (Work Done) +- [2026-03-27: 봇 상태 메시지 기능 구현 (Bot Presence Implementation)](WorkDone/2026-03-27_Presence_Implementation.md) - [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md) - [2026-03-27: 다국어 지원 구현 (i18n Implementation)](WorkDone/2026-03-27_i18n_Implementation.md) diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index e67efae..45af01c 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,10 +1,12 @@ import { Events, Guild } from 'discord.js'; import { InviteService } from '../services/InviteService'; +import { PresenceService } from '../services/PresenceService'; export default { name: Events.GuildCreate, once: false, async execute(guild: Guild) { await InviteService.cacheGuildInvites(guild); + PresenceService.updatePresence(guild.client); }, }; diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts new file mode 100644 index 0000000..9cd32ae --- /dev/null +++ b/src/events/guildDelete.ts @@ -0,0 +1,11 @@ +import { Events, Guild } from 'discord.js'; +import { PresenceService } from '../services/PresenceService'; + +export default { + name: Events.GuildDelete, + once: false, + async execute(guild: Guild) { + // Update presence to reflect the decreased guild count + PresenceService.updatePresence(guild.client); + }, +}; diff --git a/src/events/ready.ts b/src/events/ready.ts index f692c8d..556ece7 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -3,6 +3,7 @@ import { KordClient } from '../client/KordClient'; import { logger } from '../utils/logger'; import { InviteService } from '../services/InviteService'; import { VoiceService } from '../services/VoiceService'; +import { PresenceService } from '../services/PresenceService'; export default { name: Events.ClientReady, @@ -11,6 +12,7 @@ export default { logger.info(`Ready! Logged in as ${client.user?.tag}`); await InviteService.cacheAllInvites(client); await VoiceService.syncChannels(client); + PresenceService.startActivePresence(client); try { const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON()); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7fc79ab..3b89717 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -149,4 +149,12 @@ export const en: TranslationSchema = { banUser: 'Select a user to ban/hide', transferOwner: 'Select a user to transfer ownership to', }, + + // ── Presence (Bot Status) ── + presence: { + servers: 'Monitoring {{guildCount}} servers', + help: 'Check out the /help command', + managing: 'Managing Temp Voice Channels', + version: 'Kord v1.0.0', + }, }; diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 5f4155f..d9d3c78 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -149,4 +149,12 @@ export const ko: TranslationSchema = { banUser: '차단할 유저를 선택하세요', transferOwner: '소유권을 이전할 유저를 선택하세요', }, + + // ── 상태 메시지 ────────────────────────────────────────── + presence: { + servers: '{{guildCount}}개의 서버에서 활동 중', + help: '/help 명령어를 확인하세요', + managing: '임시 음성 채널 관리 중', + version: 'Kord v1.0.0', + }, }; diff --git a/src/i18n/types.ts b/src/i18n/types.ts index f36d0c5..7b8f748 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -103,6 +103,14 @@ export interface TranslationSchema { banUser: string; transferOwner: string; }; + + // ── Presence (Bot Status) ── + presence: { + servers: string; + help: string; + managing: string; + version: string; + }; } // ── Provider Interface ───────────────────────────────────── diff --git a/src/services/PresenceService.ts b/src/services/PresenceService.ts new file mode 100644 index 0000000..42e2bbb --- /dev/null +++ b/src/services/PresenceService.ts @@ -0,0 +1,81 @@ +import { ActivityType, Client } from 'discord.js'; +import { logger } from '../utils/logger'; +import { t, SupportedLocale, SUPPORTED_LOCALES } from '../i18n'; + +/** + * Service for managing the bot's Discord presence (status message). + * Rotates through multiple status messages and locales every 5 minutes. + */ +export class PresenceService { + private static rotationInterval: NodeJS.Timeout | null = null; + private static currentIndex = 0; + private static currentLocaleIndex = 0; + + /** + * The list of presence translation keys. + */ + private static readonly PRESENCE_KEYS = [ + { key: 'presence.servers', type: ActivityType.Watching }, + { key: 'presence.help', type: ActivityType.Listening }, + { key: 'presence.managing', type: ActivityType.Playing }, + { key: 'presence.version', type: ActivityType.Competing }, + ]; + + /** + * Starts the presence rotation cycle. + * @param client The Discord client instance. + */ + public static startActivePresence(client: Client) { + if (this.rotationInterval) { + clearInterval(this.rotationInterval); + } + + // Set initial presence + this.updatePresence(client); + + // Set rotation timer (5 minutes) + this.rotationInterval = setInterval(() => { + // Rotate message index + this.currentIndex = (this.currentIndex + 1) % this.PRESENCE_KEYS.length; + + // Rotate locale index to provide multi-language visibility + this.currentLocaleIndex = (this.currentLocaleIndex + 1) % SUPPORTED_LOCALES.length; + + this.updatePresence(client); + }, 5 * 60 * 1000); + + logger.info('PresenceService: Status rotation started (5m interval with i18n).'); + } + + /** + * Instantly updates the bot's presence using the current rotation state. + * @param client The Discord client instance. + */ + public static updatePresence(client: Client) { + if (!client.user) return; + + try { + const item = this.PRESENCE_KEYS[this.currentIndex]; + const locale = SUPPORTED_LOCALES[this.currentLocaleIndex]; + const guildCount = client.guilds.cache.size; + + const statusText = t(locale, item.key, { guildCount: guildCount.toString() }); + + client.user.setActivity(statusText, { type: item.type }); + logger.debug(`PresenceService: Updated presence [${locale}]: ${statusText} (${item.type})`); + } catch (error) { + logger.error('PresenceService: Failed to update presence:', error); + } + } + + /** + * Stops the rotation cycle. + */ + public static stopRotation() { + if (this.rotationInterval) { + clearInterval(this.rotationInterval); + this.rotationInterval = null; + logger.info('PresenceService: Status rotation stopped.'); + } + } +}