Kord/src/services/VoiceService.ts

354 lines
14 KiB
TypeScript

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';
import { auditLogService } from './AuditLogService';
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;
logger.debug(`VoiceService: handleVoiceStateUpdate - old: ${oldState.channelId}, new: ${newState.channelId}`);
if (!oldState.channelId && newState.channelId) {
await this.handleJoin(newState);
} else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
await this.handleLeave(oldState);
await this.handleJoin(newState);
} else if (oldState.channelId && !newState.channelId) {
await this.handleLeave(oldState);
}
}
private static async handleJoin(state: VoiceState) {
const guild = state.guild;
const member = state.member!;
const channelId = state.channelId!;
const generator = await prisma.voiceGenerator.findUnique({
where: { channelId }
});
if (!generator) {
logger.debug(`VoiceService: handleJoin - channel ${channelId} is NOT a generator`);
return;
}
logger.info(`VoiceService: handleJoin - detected generator join for ${member.user.tag} in ${channelId}`);
const botMember = guild.members.me;
if (!botMember?.permissions.has([
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.ManageRoles,
PermissionFlagsBits.MoveMembers
])) {
logger.error(`${ErrorDefs.BOT_MISSING_VOICE_PERMISSIONS.code}: Bot lacks required Voice permissions in guild ${guild.id}`);
auditLogService.log(guild, {
category: 'PERMISSION',
severity: 'ERROR',
title: '음성 채널 생성 권한 부족',
description: `임시 음성 채널 생성에 필요한 권한이 부족하여 생성을 중단했습니다.`,
fields: [{ name: '누락된 필수 권한', value: '`ManageChannels`, `ManageRoles`, `MoveMembers` 중 하나 이상' }],
errorCode: ErrorDefs.BOT_MISSING_VOICE_PERMISSIONS.code
}).catch(() => {});
return;
}
const existingTemp = await prisma.tempVoiceChannel.findFirst({
where: { guildId: guild.id, ownerId: member.id }
});
if (existingTemp) {
try {
await member.voice.setChannel(existingTemp.channelId);
return; // Success, moved to existing channel
} catch (e: any) {
// If the channel no longer exists in Discord, clean up DB and proceed to create new one
if (e.code === 10003 || e.status === 404) {
logger.warn(`VoiceService: Found ghost channel ${existingTemp.channelId} in DB. Cleaning up and creating fresh one.`);
await prisma.tempVoiceChannel.delete({ where: { channelId: existingTemp.channelId } }).catch(() => {});
// FALL THROUGH to channel creation logic below
} else {
logger.error(`${ErrorDefs.DISCORD_API_ERROR.code}: Unexpected error moving user to existing voice channel`, e);
return; // Stop for other errors to prevent spamming creations
}
}
}
// Resolve locale for this context
const locale = await getContextLocale(guild.id, member.id);
// Fetch guild-specific config
const guildConfig = await prisma.voiceGuildConfig.findUnique({ where: { guildId: guild.id } });
const profile = await prisma.userVoiceProfile.findUnique({
where: { userId_guildId: { userId: member.id, guildId: guild.id } }
});
// Fallback logic for name resolution
const effectiveName = this.getEffectiveName(member);
// Naming priority: User Custom Profile > Guild Template > Default I18n
let channelName: string;
const profileName = profile?.customName;
if (profileName) {
channelName = profileName;
} else {
const template = guildConfig?.defaultNameTemplate || t(locale, 'voice.defaultRoomName');
channelName = template.replace('{{username}}', effectiveName);
}
// Final safety fallback to avoid undefined
if (!channelName) channelName = `${effectiveName}'s Room`;
const userLimit = profile?.userLimit ?? guildConfig?.defaultUserLimit ?? 0;
try {
const parentId = generator.categoryId || state.channel?.parentId || undefined;
let newChannel;
try {
newChannel = await guild.channels.create({
name: channelName,
type: ChannelType.GuildVoice,
parent: parentId,
userLimit: userLimit,
permissionOverwrites: [
{
id: guild.roles.everyone.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect],
},
{
id: member.id,
allow: [
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.ManageRoles,
PermissionFlagsBits.MoveMembers,
],
},
],
});
} catch (firstError: any) {
if (firstError.code === 50013) {
logger.warn(`${ErrorDefs.DISCORD_MISSING_PERMISSIONS.code}: Missing Permissions when creating with overwrites. Bot perms: ${botMember?.permissions.bitfield.toString()}. Retrying without overwrites...`);
newChannel = await guild.channels.create({
name: channelName,
type: ChannelType.GuildVoice,
parent: parentId,
userLimit: userLimit,
});
logger.info(`Channel created WITHOUT overwrites successfully.`);
} else {
throw firstError;
}
}
await prisma.tempVoiceChannel.create({
data: {
channelId: newChannel.id,
guildId: guild.id,
ownerId: member.id,
}
});
await member.voice.setChannel(newChannel);
logger.info(`VoiceService: Created ${newChannel.name} for ${member.user.tag}`);
auditLogService.log(guild, {
category: 'VOICE',
severity: 'INFO',
title: '임시 음성 채널 생성',
description: `유저 <@${member.id}> 님이 임시 채널을 생성했습니다.`,
fields: [{ name: '채널', value: `<#${newChannel.id}>`, inline: true }]
}).catch(() => {});
await this.sendControlPanel(newChannel, member.id, locale);
} catch (error) {
logger.error(`${ErrorDefs.UNKNOWN_ERROR.code}: VoiceService Join Error`, error);
}
}
private static async handleLeave(state: VoiceState) {
const channelId = state.channelId!;
const member = state.member!;
const tempChannel = await prisma.tempVoiceChannel.findUnique({
where: { channelId }
});
if (!tempChannel) return;
const channel = state.channel as VoiceChannel;
if (!channel) {
await prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => {});
return;
}
let shouldDelete = false;
if (tempChannel.deleteWhen === 'EMPTY') {
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: Successfully deleted temp channel ${channel.name}`);
auditLogService.log(channel.guild, {
category: 'VOICE',
severity: 'INFO',
title: '임시 음성 채널 삭제',
description: `조건 충족으로 임시 채널이 삭제되었습니다.\n채널 이름: **${channel.name}**`
}).catch(() => {});
} catch (error: any) {
// If already deleted in Discord, just clean up DB
if (error.code === 10003 || error.status === 404) {
await prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => {});
logger.info(`VoiceService: Purged ghost channel ${channelId} from DB`);
} else {
logger.error(`${ErrorDefs.DISCORD_MISSING_PERMISSIONS.code}: Failed to delete channel ${channelId}`, error);
}
}
}
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.filter(m => !m.user.bot).first();
if (newOwner) {
try {
await prisma.tempVoiceChannel.update({
where: { channelId },
data: { ownerId: newOwner.id }
});
await this.applyOwnershipTransfer(channel, tempChannel.ownerId, newOwner.id);
logger.info(`VoiceService: Auto-transferred ownership to ${newOwner.user.tag}`);
} catch (error) {
logger.error(`${ErrorDefs.UNKNOWN_ERROR.code}: VoiceService failed to auto-transfer ownership`, error);
}
}
}
}
public static async applyOwnershipTransfer(channel: VoiceChannel, oldOwnerId: string, newOwnerId: string) {
const locale = await getContextLocale(channel.guildId, newOwnerId);
const profile = await prisma.userVoiceProfile.findUnique({
where: { userId_guildId: { userId: newOwnerId, guildId: channel.guildId } }
});
const newMember = await channel.guild.members.fetch(newOwnerId);
const finalName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: newMember.user.username });
await channel.setName(finalName).catch(() => {});
// Give Manage Channels permission to new owner, remove from old
await channel.permissionOverwrites.edit(newOwnerId, {
ManageChannels: true,
ManageRoles: true,
MoveMembers: true
}).catch(() => {});
await channel.permissionOverwrites.delete(oldOwnerId).catch(() => {});
// Send new control panel
await this.sendControlPanel(channel, newOwnerId, locale);
}
public static async sendControlPanel(channel: VoiceChannel, ownerId: string, locale?: SupportedLocale) {
// Resolve locale if not provided
const resolvedLocale = locale ?? await getContextLocale(channel.guildId, ownerId);
const selectMenu = new StringSelectMenuBuilder()
.setCustomId(`vc_control_${ownerId}`)
.setPlaceholder(t(resolvedLocale, 'voice.controlPanel.placeholder'))
.addOptions(
new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.rename')).setValue('rename').setEmoji('✏️'),
new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.limit')).setValue('limit').setEmoji('👥'),
new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.lock')).setValue('lock').setEmoji('🔒'),
new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.kick')).setValue('kick').setEmoji('👢'),
new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.ban')).setValue('ban').setEmoji('🚫'),
new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.transfer')).setValue('transfer').setEmoji('👑')
);
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu);
try {
await channel.send({
content: t(resolvedLocale, 'voice.channelReady', { owner: `<@${ownerId}>` }),
components: [row]
}).catch(() => {});
} catch (e) {
logger.error('Failed to send control panel UI', e);
}
}
/**
* Resolves the member's name based on hierarchy:
* 1. Server Nickname
* 2. Global Display Name
* 3. Username
* 4. User ID (Fallback)
*/
public static getEffectiveName(member: GuildMember): string {
return member.nickname || member.user.globalName || member.user.username || member.id;
}
}