299 lines
12 KiB
TypeScript
299 lines
12 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;
|
|
|
|
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<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);
|
|
}
|
|
}
|
|
}
|