diff --git a/.agents/workflows/kord_routine.md b/.agents/workflows/kord_routine.md index c923dc6..d997fb6 100644 --- a/.agents/workflows/kord_routine.md +++ b/.agents/workflows/kord_routine.md @@ -26,6 +26,11 @@ description: Kord 프로젝트 개발 및 테스트 작업 루틴 - 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다. ### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing) -- 내/외부 테스트를 거쳐 정상 구동이 확정된 버전을 사용자에게 보고합니다. +- 내/외부 테스트를 거쳐 정상 구동이 확정된 버전을 사용자에게 보고하기 전에, 다음의 5단계를 수행합니다. - 사용자는 실질적으로 자신의 디스코드 서버에 봇을 초대하여 인게임/인앱 시나리오를 수동 테스트합니다. - **사후 검토**: 이 과정에서 사용자가 버그나 오류를 보고할 경우, 에이전트는 로컬 터미널의 로그(Log)나 DB의 상태를 검토(조회 명령어 사용 등)하여 무엇이 문제였는지 면밀히 파악하고 수정안을 제시해야 합니다. + +### 5단계: 자동 문서화 및 인덱싱 (Documentation Phase) +- 3단계 구현 및 테스트가 성공적으로 완료되면, 사용자에게 최종 보고하기 **전에 반드시 먼저** `/Docs/` 디렉토리에 작업 완료(Work done), 트러블슈팅(Troubleshooting), 의사 결정(Decisions made) 내역을 문서화해야 합니다. +- 새 문서가 생성되거나 수정되면 자동으로 `Docs/index.md`에 문서의 색인(링크)을 추가합니다. +- 모든 코드 작업 내역과 의사 결정이 완전히 로컬 `Docs/`에 기록 및 정리된 후에만 비로소 "작업을 완료했다"고 사용자에게 알립니다. diff --git a/.windsurfrules b/.windsurfrules index 38adfbc..c4c6e73 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -3,6 +3,7 @@ ## Documentation Rules - 프로젝트와 관련된 진행/완료 내역(Work done), 기술/기획적 결정(Decisions made), 트러블슈팅(Troubleshooting) 내역은 `/Docs/` 디렉토리에 카테고리별로 작성합니다. - 새로운 문서를 추가하거나 수정하면 반드시 `Docs/index.md`에 색인(링크)을 추가합니다. +- **모든 코드 수정, 버그 수정, 기능 추가 작업이 완료되면, 사용자의 명시적인 요청이 없더라도 작업 내역을 자동으로 `Docs/` 디렉토리에 문서화하고 색인(`index.md`)을 업데이트한 뒤에 작업 완료를 보고해야 합니다.** - 문서에는 `체인지로그(Changelog)`와 `본문(Body)` 구조를 포함합니다. - 다른 문서에 있는 내용을 중복해서 적거나 허위 사실, "향후 추가 예정" 같은 Placeholder를 작성하지 마십시오. diff --git a/Docs/Troubleshooting/handleLeave_ghost_channel.md b/Docs/Troubleshooting/handleLeave_ghost_channel.md new file mode 100644 index 0000000..87d5869 --- /dev/null +++ b/Docs/Troubleshooting/handleLeave_ghost_channel.md @@ -0,0 +1,17 @@ +# 임시 음성 채널 미삭제 및 유령 방 버그 (handleLeave) + +## 체인지로그 (Changelog) +- **2026-03-27**: 문서 작성 + +## 본문 (Body) + +### 현상 (Symptom) +사용자가 임시 채널에서 퇴장해도 채널이 지워지지 않고 그대로 남는 현상 발생. + +### 원인 (Cause) +1. **음악 봇 잔류 문제**: 디스코드 채널에 봇(Bot) 멤버가 남아있을 경우 `channel.members.size === 0` 조건을 충족하지 못해 삭제 로직이 작동하지 않음. +2. **에러 핸들링 누락으로 인한 DB 고립**: 50013 권한 부족 등의 이유로 디스코드 서버에서 채널 삭제(`channel.delete()`)가 실패했을 때, 에러를 무시하고 DB 레코드만 삭제해버림. 이로 인해 디스코드상에는 삭제 불가능한 유령 방이 남게 되고 봇은 이를 영원히 추적하지 못하게 됨. + +### 해결 (Solution) +1. `humanCount` 변수를 도입하여 `!user.bot`인 멤버(실제 사용자)가 0명일 때만 지워지도록 로직 수정. +2. `try-catch` 스코프 내부에서 디스코드 API 삭제 명령이 실패할 경우, DB 삭제 처리(`prisma.tempVoiceChannel.delete`)를 중단하고 터미널에 에러 로그를 명확히 남겨 무결성을 유지하도록 예외 처리. diff --git a/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md b/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md index ddad0ba..67c9553 100644 --- a/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md +++ b/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md @@ -35,3 +35,9 @@ - **채널 생성 권한 충돌 (50013: Missing Permissions)**: - *문제*: 봇이 임시 채널을 생성할 때 방장에게 `ManageRoles`, `ManageChannels` 등의 오버라이드 권한을 주려 했으나, 봇 자신에게 해당 권한이 서버 수준에서 부족하여 생성 실패 에러 발생. - *해결*: 권한 덮어쓰기 로직에 try-catch 래핑을 추가하여 실패 시 권한 오버라이드를 제외한 순정 채널로 생성하도록 우회 해결. 자세한 사항은 [50013_Missing_Permissions.md](../Troubleshooting/50013_Missing_Permissions.md) 참고. +- **퇴장 시 채널 미삭제 및 유령 방 버그**: + - *문제*: 채널 내에 음악봇 등 봇만 남았을 때 삭제 조건이 작동하지 않고, 권한 문제로 삭제가 실패했을 때 DB 무결성이 깨지는 버그. + - *해결*: 휴먼 카운트(`humanCount`) 도입 및 삭제 롤백 검증 처리. 자세한 사항은 [handleLeave_ghost_channel.md](../Troubleshooting/handleLeave_ghost_channel.md) 참조. +- **멀티 인스턴스 대응 및 봇 재부팅 복구 (Boot Recovery)**: + - *사유*: 봇 재시작, 크래시, 혹은 다중 노드(Multi-Instance) 환경에서 DB와 디스코드 실제 채널 상태가 어긋나게 될 경우를 방지. + - *해결*: `ioredis` 분산 락(Distributed Lock)과 결합된 `VoiceService.syncChannels` 메서드를 구현하여, 부팅 시 딱 하나의 인스턴스만 DB를 훑으며 삭제되어야 할 유령 방들을 크로스체크 및 안전하게 청소(Garbage Collection)하도록 반영. diff --git a/Docs/index.md b/Docs/index.md index 12d1c08..759369f 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -16,6 +16,7 @@ ## 트러블슈팅 (Troubleshooting) - [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md) +- [Temp Voice 유령 채널 미삭제 버그 해결건](Troubleshooting/handleLeave_ghost_channel.md) ## 진행/완료 내역 (Work Done) - [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md) diff --git a/src/events/ready.ts b/src/events/ready.ts index 9374ca5..f692c8d 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -2,6 +2,7 @@ import { Events } from 'discord.js'; import { KordClient } from '../client/KordClient'; import { logger } from '../utils/logger'; import { InviteService } from '../services/InviteService'; +import { VoiceService } from '../services/VoiceService'; export default { name: Events.ClientReady, @@ -9,6 +10,7 @@ export default { async execute(client: KordClient) { logger.info(`Ready! Logged in as ${client.user?.tag}`); await InviteService.cacheAllInvites(client); + await VoiceService.syncChannels(client); try { const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON()); diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index 2371619..ca121b2 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -1,8 +1,61 @@ -import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from 'discord.js'; +import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client } from 'discord.js'; import { prisma } from '../database'; import { logger } from '../utils/logger'; +import { redis } from '../cache'; export class VoiceService { + public static async syncChannels(client: Client) { + const lockKey = 'voice:sync:lock'; + // NX = only set if not exists, EX = expire in 60s + const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX'); + if (!acquired) { + logger.info('VoiceService: Another instance is already syncing channels. Skipping.'); + return; + } + + 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(() => {}); + } + } public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { const member = newState.member; if (!member) return; @@ -130,23 +183,27 @@ export class VoiceService { let shouldDelete = false; if (tempChannel.deleteWhen === 'EMPTY') { - shouldDelete = channel.members.size === 0; + const humanCount = channel.members.filter(m => !m.user.bot).size; + shouldDelete = humanCount === 0; + logger.info(`VoiceService: [handleLeave] ${channel.name} EMPTY check -> Human Count: ${humanCount}, shouldDelete: ${shouldDelete}`); } else if (tempChannel.deleteWhen === 'OWNER_LEAVE') { shouldDelete = tempChannel.ownerId === member.id; + logger.info(`VoiceService: [handleLeave] ${channel.name} OWNER_LEAVE check -> isOwner: ${shouldDelete}`); } if (shouldDelete) { try { await channel.delete(); await prisma.tempVoiceChannel.delete({ where: { channelId } }); - logger.info(`VoiceService: Deleted temp channel ${channel.name}`); + logger.info(`VoiceService: Successfully deleted temp channel ${channel.name}`); } catch (error) { - await prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => {}); + logger.error(`VoiceService: Discord API failed to delete channel ${channel.name} (Missing Manage Channels?)`, error); + // deliberately NOT deleting the database entry so it remains synchronized with Discord's state. } } - else if (tempChannel.deleteWhen === 'EMPTY' && tempChannel.ownerId === member.id && channel.members.size > 0) { + else if (tempChannel.deleteWhen === 'EMPTY' && tempChannel.ownerId === member.id && channel.members.filter(m => !m.user.bot).size > 0) { // Auto Transfer Ownership - const newOwner = channel.members.first(); + const newOwner = channel.members.filter(m => !m.user.bot).first(); if (newOwner) { try { await prisma.tempVoiceChannel.update({ @@ -157,7 +214,7 @@ export class VoiceService { await this.applyOwnershipTransfer(channel, tempChannel.ownerId, newOwner.id); logger.info(`VoiceService: Auto-transferred ownership to ${newOwner.user.tag}`); } catch (error) { - logger.error('Failed to auto-transfer ownership', error); + logger.error(`VoiceService: Failed to auto-transfer ownership`, error); } } }