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'; import { ErrorDefs } from '../errors/ErrorCodes'; import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n'; import { getContextLocale } from '../i18n/localeHelper'; 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}`); 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); const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: member.id }}); const channelName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: member.user.username }); const userLimit = profile?.userLimit || 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}`); 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}`); } 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: newOwnerId }}); 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().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); } } }