Kord/src/services/VoiceService.ts

279 lines
11 KiB
TypeScript

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;
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) return;
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);
} catch (e) {
logger.error(`${ErrorDefs.DISCORD_API_ERROR.code}: Could not move user to existing voice channel`, e);
}
return;
}
// 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) {
logger.error(`${ErrorDefs.DISCORD_MISSING_PERMISSIONS.code}: Failed to delete channel ${channel.name}`, 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.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<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);
}
}
}