feat: Implement voice channel synchronization on startup to prevent ghost channels and refine empty channel detection by excluding bots.

This commit is contained in:
이정수 2026-03-27 13:30:58 +09:00
parent b5b6405d0c
commit 2eedde7611
7 changed files with 97 additions and 8 deletions

View File

@ -26,6 +26,11 @@ description: Kord 프로젝트 개발 및 테스트 작업 루틴
- 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다. - 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다.
### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing) ### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing)
- 내/외부 테스트를 거쳐 정상 구동이 확정된 버전을 사용자에게 보고합니다. - 내/외부 테스트를 거쳐 정상 구동이 확정된 버전을 사용자에게 보고하기 전에, 다음의 5단계를 수행합니다.
- 사용자는 실질적으로 자신의 디스코드 서버에 봇을 초대하여 인게임/인앱 시나리오를 수동 테스트합니다. - 사용자는 실질적으로 자신의 디스코드 서버에 봇을 초대하여 인게임/인앱 시나리오를 수동 테스트합니다.
- **사후 검토**: 이 과정에서 사용자가 버그나 오류를 보고할 경우, 에이전트는 로컬 터미널의 로그(Log)나 DB의 상태를 검토(조회 명령어 사용 등)하여 무엇이 문제였는지 면밀히 파악하고 수정안을 제시해야 합니다. - **사후 검토**: 이 과정에서 사용자가 버그나 오류를 보고할 경우, 에이전트는 로컬 터미널의 로그(Log)나 DB의 상태를 검토(조회 명령어 사용 등)하여 무엇이 문제였는지 면밀히 파악하고 수정안을 제시해야 합니다.
### 5단계: 자동 문서화 및 인덱싱 (Documentation Phase)
- 3단계 구현 및 테스트가 성공적으로 완료되면, 사용자에게 최종 보고하기 **전에 반드시 먼저** `<PROJECT_ROOT>/Docs/` 디렉토리에 작업 완료(Work done), 트러블슈팅(Troubleshooting), 의사 결정(Decisions made) 내역을 문서화해야 합니다.
- 새 문서가 생성되거나 수정되면 자동으로 `Docs/index.md`에 문서의 색인(링크)을 추가합니다.
- 모든 코드 작업 내역과 의사 결정이 완전히 로컬 `Docs/`에 기록 및 정리된 후에만 비로소 "작업을 완료했다"고 사용자에게 알립니다.

View File

@ -3,6 +3,7 @@
## Documentation Rules ## Documentation Rules
- 프로젝트와 관련된 진행/완료 내역(Work done), 기술/기획적 결정(Decisions made), 트러블슈팅(Troubleshooting) 내역은 `<PROJECT_ROOT>/Docs/` 디렉토리에 카테고리별로 작성합니다. - 프로젝트와 관련된 진행/완료 내역(Work done), 기술/기획적 결정(Decisions made), 트러블슈팅(Troubleshooting) 내역은 `<PROJECT_ROOT>/Docs/` 디렉토리에 카테고리별로 작성합니다.
- 새로운 문서를 추가하거나 수정하면 반드시 `Docs/index.md`에 색인(링크)을 추가합니다. - 새로운 문서를 추가하거나 수정하면 반드시 `Docs/index.md`에 색인(링크)을 추가합니다.
- **모든 코드 수정, 버그 수정, 기능 추가 작업이 완료되면, 사용자의 명시적인 요청이 없더라도 작업 내역을 자동으로 `Docs/` 디렉토리에 문서화하고 색인(`index.md`)을 업데이트한 뒤에 작업 완료를 보고해야 합니다.**
- 문서에는 `체인지로그(Changelog)`와 `본문(Body)` 구조를 포함합니다. - 문서에는 `체인지로그(Changelog)`와 `본문(Body)` 구조를 포함합니다.
- 다른 문서에 있는 내용을 중복해서 적거나 허위 사실, "향후 추가 예정" 같은 Placeholder를 작성하지 마십시오. - 다른 문서에 있는 내용을 중복해서 적거나 허위 사실, "향후 추가 예정" 같은 Placeholder를 작성하지 마십시오.

View File

@ -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`)를 중단하고 터미널에 에러 로그를 명확히 남겨 무결성을 유지하도록 예외 처리.

View File

@ -35,3 +35,9 @@
- **채널 생성 권한 충돌 (50013: Missing Permissions)**: - **채널 생성 권한 충돌 (50013: Missing Permissions)**:
- *문제*: 봇이 임시 채널을 생성할 때 방장에게 `ManageRoles`, `ManageChannels` 등의 오버라이드 권한을 주려 했으나, 봇 자신에게 해당 권한이 서버 수준에서 부족하여 생성 실패 에러 발생. - *문제*: 봇이 임시 채널을 생성할 때 방장에게 `ManageRoles`, `ManageChannels` 등의 오버라이드 권한을 주려 했으나, 봇 자신에게 해당 권한이 서버 수준에서 부족하여 생성 실패 에러 발생.
- *해결*: 권한 덮어쓰기 로직에 try-catch 래핑을 추가하여 실패 시 권한 오버라이드를 제외한 순정 채널로 생성하도록 우회 해결. 자세한 사항은 [50013_Missing_Permissions.md](../Troubleshooting/50013_Missing_Permissions.md) 참고. - *해결*: 권한 덮어쓰기 로직에 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)하도록 반영.

View File

@ -16,6 +16,7 @@
## 트러블슈팅 (Troubleshooting) ## 트러블슈팅 (Troubleshooting)
- [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md) - [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md)
- [Temp Voice 유령 채널 미삭제 버그 해결건](Troubleshooting/handleLeave_ghost_channel.md)
## 진행/완료 내역 (Work Done) ## 진행/완료 내역 (Work Done)
- [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md) - [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md)

View File

@ -2,6 +2,7 @@ import { Events } from 'discord.js';
import { KordClient } from '../client/KordClient'; import { KordClient } from '../client/KordClient';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { InviteService } from '../services/InviteService'; import { InviteService } from '../services/InviteService';
import { VoiceService } from '../services/VoiceService';
export default { export default {
name: Events.ClientReady, name: Events.ClientReady,
@ -9,6 +10,7 @@ export default {
async execute(client: KordClient) { async execute(client: KordClient) {
logger.info(`Ready! Logged in as ${client.user?.tag}`); logger.info(`Ready! Logged in as ${client.user?.tag}`);
await InviteService.cacheAllInvites(client); await InviteService.cacheAllInvites(client);
await VoiceService.syncChannels(client);
try { try {
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON()); const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());

View File

@ -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 { prisma } from '../database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { redis } from '../cache';
export class VoiceService { 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) { public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
const member = newState.member; const member = newState.member;
if (!member) return; if (!member) return;
@ -130,23 +183,27 @@ export class VoiceService {
let shouldDelete = false; let shouldDelete = false;
if (tempChannel.deleteWhen === 'EMPTY') { 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') { } else if (tempChannel.deleteWhen === 'OWNER_LEAVE') {
shouldDelete = tempChannel.ownerId === member.id; shouldDelete = tempChannel.ownerId === member.id;
logger.info(`VoiceService: [handleLeave] ${channel.name} OWNER_LEAVE check -> isOwner: ${shouldDelete}`);
} }
if (shouldDelete) { if (shouldDelete) {
try { try {
await channel.delete(); await channel.delete();
await prisma.tempVoiceChannel.delete({ where: { channelId } }); 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) { } 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 // Auto Transfer Ownership
const newOwner = channel.members.first(); const newOwner = channel.members.filter(m => !m.user.bot).first();
if (newOwner) { if (newOwner) {
try { try {
await prisma.tempVoiceChannel.update({ await prisma.tempVoiceChannel.update({
@ -157,7 +214,7 @@ export class VoiceService {
await this.applyOwnershipTransfer(channel, tempChannel.ownerId, newOwner.id); await this.applyOwnershipTransfer(channel, tempChannel.ownerId, newOwner.id);
logger.info(`VoiceService: Auto-transferred ownership to ${newOwner.user.tag}`); logger.info(`VoiceService: Auto-transferred ownership to ${newOwner.user.tag}`);
} catch (error) { } catch (error) {
logger.error('Failed to auto-transfer ownership', error); logger.error(`VoiceService: Failed to auto-transfer ownership`, error);
} }
} }
} }