feat: Implement voice channel synchronization on startup to prevent ghost channels and refine empty channel detection by excluding bots.
This commit is contained in:
parent
b5b6405d0c
commit
2eedde7611
|
|
@ -26,6 +26,11 @@ description: Kord 프로젝트 개발 및 테스트 작업 루틴
|
|||
- 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다.
|
||||
|
||||
### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing)
|
||||
- 내/외부 테스트를 거쳐 정상 구동이 확정된 버전을 사용자에게 보고합니다.
|
||||
- 내/외부 테스트를 거쳐 정상 구동이 확정된 버전을 사용자에게 보고하기 전에, 다음의 5단계를 수행합니다.
|
||||
- 사용자는 실질적으로 자신의 디스코드 서버에 봇을 초대하여 인게임/인앱 시나리오를 수동 테스트합니다.
|
||||
- **사후 검토**: 이 과정에서 사용자가 버그나 오류를 보고할 경우, 에이전트는 로컬 터미널의 로그(Log)나 DB의 상태를 검토(조회 명령어 사용 등)하여 무엇이 문제였는지 면밀히 파악하고 수정안을 제시해야 합니다.
|
||||
|
||||
### 5단계: 자동 문서화 및 인덱싱 (Documentation Phase)
|
||||
- 3단계 구현 및 테스트가 성공적으로 완료되면, 사용자에게 최종 보고하기 **전에 반드시 먼저** `<PROJECT_ROOT>/Docs/` 디렉토리에 작업 완료(Work done), 트러블슈팅(Troubleshooting), 의사 결정(Decisions made) 내역을 문서화해야 합니다.
|
||||
- 새 문서가 생성되거나 수정되면 자동으로 `Docs/index.md`에 문서의 색인(링크)을 추가합니다.
|
||||
- 모든 코드 작업 내역과 의사 결정이 완전히 로컬 `Docs/`에 기록 및 정리된 후에만 비로소 "작업을 완료했다"고 사용자에게 알립니다.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
## Documentation Rules
|
||||
- 프로젝트와 관련된 진행/완료 내역(Work done), 기술/기획적 결정(Decisions made), 트러블슈팅(Troubleshooting) 내역은 `<PROJECT_ROOT>/Docs/` 디렉토리에 카테고리별로 작성합니다.
|
||||
- 새로운 문서를 추가하거나 수정하면 반드시 `Docs/index.md`에 색인(링크)을 추가합니다.
|
||||
- **모든 코드 수정, 버그 수정, 기능 추가 작업이 완료되면, 사용자의 명시적인 요청이 없더라도 작업 내역을 자동으로 `Docs/` 디렉토리에 문서화하고 색인(`index.md`)을 업데이트한 뒤에 작업 완료를 보고해야 합니다.**
|
||||
- 문서에는 `체인지로그(Changelog)`와 `본문(Body)` 구조를 포함합니다.
|
||||
- 다른 문서에 있는 내용을 중복해서 적거나 허위 사실, "향후 추가 예정" 같은 Placeholder를 작성하지 마십시오.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)를 중단하고 터미널에 에러 로그를 명확히 남겨 무결성을 유지하도록 예외 처리.
|
||||
|
|
@ -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)하도록 반영.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue