import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder } from 'discord.js'; import { MessageFlags } from 'discord-api-types/v10'; /** * tempvc module: temporary voice channel manager * * Admin commands (/vcadmin): * - add * - remove * - list * * User commands (/vc): * Access Control: * • invite * • kick * • role * • mode * • limit <0-99> * Presets: * • save * • restore * • reset * Utilities: * • rename * • info * • delete * * PocketBase collections required: * tempvc_masters (guildId, masterChannelId, categoryId) * tempvc_sessions (guildId, masterChannelId, channelId, ownerId, roleId, mode) * tempvc_presets (guildId, userId, name, channelName, userLimit, roleId, invitedUserIds, mode) */ // Temporary Voice Channel module // - /vcadmin: admin commands to add/remove/list spawn channels // - /vc: user commands to manage own temp VC, presets save/restore /** * Slash commands for vcadmin and vc */ export const commands = [ // Administrator: manage spawn points { data: new SlashCommandBuilder() .setName('vcadmin') .setDescription('Configure temporary voice-channel spawn points (Admin only)') .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDMPermission(false) .addSubcommand(sub => sub.setName('add') .setDescription('Add a spawn voice channel and its temp category') .addChannelOption(opt => opt.setName('voice_channel') .setDescription('Voice channel to spawn from') .setRequired(true) .addChannelTypes(ChannelType.GuildVoice) ) .addChannelOption(opt => opt.setName('category') .setDescription('Category for new temp channels') .setRequired(true) .addChannelTypes(ChannelType.GuildCategory) ) ) .addSubcommand(sub => sub.setName('remove') .setDescription('Remove a spawn voice channel') .addChannelOption(opt => opt.setName('voice_channel') .setDescription('Voice channel to remove') .setRequired(true) .addChannelTypes(ChannelType.GuildVoice) ) ) .addSubcommand(sub => sub.setName('list') .setDescription('List all spawn voice channels and categories') ), async execute(interaction, client) { const guildId = interaction.guildId; const sub = interaction.options.getSubcommand(); // ensure in-guild if (!guildId) { return interaction.reply({ content: 'This command can only be used in a server.', flags: MessageFlags.Ephemeral }); } // init memory map for this guild client.tempvc = client.tempvc || { masters: new Map(), sessions: new Map() }; if (!client.tempvc.masters.has(guildId)) { client.tempvc.masters.set(guildId, new Map()); } const guildMasters = client.tempvc.masters.get(guildId); try { if (sub === 'add') { const vc = interaction.options.getChannel('voice_channel', true); const cat = interaction.options.getChannel('category', true); // persist const existing = await client.pb.getFirst( 'tempvc_masters', `guildId = "${guildId}" && masterChannelId = "${vc.id}"` ); if (existing) { await client.pb.updateOne('tempvc_masters', existing.id, { guildId, masterChannelId: vc.id, categoryId: cat.id }); } else { await client.pb.createOne('tempvc_masters', { guildId, masterChannelId: vc.id, categoryId: cat.id }); } // update memory guildMasters.set(vc.id, cat.id); await interaction.reply({ content: `Spawn channel <#${vc.id}> will now create temp VCs in <#${cat.id}>.`, flags: MessageFlags.Ephemeral }); } else if (sub === 'remove') { const vc = interaction.options.getChannel('voice_channel', true); if (!guildMasters.has(vc.id)) { return interaction.reply({ content: 'That channel is not configured as a spawn point.', flags: MessageFlags.Ephemeral }); } // remove from PB const existing = await client.pb.getFirst( 'tempvc_masters', `guildId = "${guildId}" && masterChannelId = "${vc.id}"` ); if (existing) { await client.pb.deleteOne('tempvc_masters', existing.id); } // update memory guildMasters.delete(vc.id); await interaction.reply({ content: `Removed spawn channel <#${vc.id}>.`, flags: MessageFlags.Ephemeral }); } else if (sub === 'list') { if (guildMasters.size === 0) { return interaction.reply({ content: 'No spawn channels configured.', flags: MessageFlags.Ephemeral }); } const lines = []; for (const [mId, cId] of guildMasters.entries()) { lines.push(`<#${mId}> → <#${cId}>`); } await interaction.reply({ content: '**Spawn channels:**\n' + lines.join('\n'), flags: MessageFlags.Ephemeral }); } } catch (err) { client.logger.error(`[module:tempvc][vcadmin] ${err.message}`); await interaction.reply({ content: 'Operation failed, see logs.', flags: MessageFlags.Ephemeral }); } } }, // User: manage own temp VC and presets { data: new SlashCommandBuilder() .setName('vc') .setDescription('Manage your temporary voice channel') .setDMPermission(false) // Access Control .addSubcommand(sub => sub.setName('invite') .setDescription('Invite a user to this channel') .addUserOption(opt => opt.setName('user').setDescription('User to invite').setRequired(true)) ) .addSubcommand(sub => sub.setName('kick') .setDescription('Kick a user from this channel') .addUserOption(opt => opt.setName('user').setDescription('User to kick').setRequired(true)) ) .addSubcommand(sub => sub.setName('role') .setDescription('Set role to allow/deny access') .addRoleOption(opt => opt.setName('role').setDescription('Role to allow/deny').setRequired(true)) ) .addSubcommand(sub => sub.setName('mode') .setDescription('Switch role mode') .addStringOption(opt => opt.setName('mode') .setDescription('Mode: whitelist or blacklist') .setRequired(true) .addChoices( { name: 'whitelist', value: 'whitelist' }, { name: 'blacklist', value: 'blacklist' } ) ) ) .addSubcommand(sub => sub.setName('limit') .setDescription('Set user limit (0–99)') .addIntegerOption(opt => opt.setName('number').setDescription('Max users').setRequired(true)) ) // Presets .addSubcommand(sub => sub.setName('save') .setDescription('Save current settings as a preset') .addStringOption(opt => opt.setName('name').setDescription('Preset name').setRequired(true).setAutocomplete(true)) ) .addSubcommand(sub => sub.setName('restore') .setDescription('Restore settings from a preset') .addStringOption(opt => opt.setName('name').setDescription('Preset name').setRequired(true).setAutocomplete(true)) ) .addSubcommand(sub => sub.setName('reset').setDescription('Reset channel to default settings')) // Utilities .addSubcommand(sub => sub.setName('rename').setDescription('Rename this channel').addStringOption(opt => opt.setName('new_name').setDescription('New channel name').setRequired(true))) .addSubcommand(sub => sub.setName('info').setDescription('Show channel info')) .addSubcommand(sub => sub.setName('delete').setDescription('Delete this channel')), async execute(interaction, client) { const guild = interaction.guild; const member = interaction.member; const sub = interaction.options.getSubcommand(); // must be in guild and in voice if (!guild || !member || !member.voice.channel) { return interaction.reply({ content: 'You must be in a temp voice channel to use this.', flags: MessageFlags.Ephemeral }); } const voice = member.voice.channel; client.tempvc = client.tempvc || { masters: new Map(), sessions: new Map() }; const sess = client.tempvc.sessions.get(voice.id); if (!sess) { return interaction.reply({ content: 'This is not one of my temporary channels.', flags: MessageFlags.Ephemeral }); } if (sess.ownerId !== interaction.user.id) { return interaction.reply({ content: 'Only the room owner can do that.', flags: MessageFlags.Ephemeral }); } try { if (sub === 'rename') { const name = interaction.options.getString('new_name', true); await voice.setName(name); await interaction.reply({ content: `Channel renamed to **${name}**.`, flags: MessageFlags.Ephemeral }); } else if (sub === 'invite') { const u = interaction.options.getUser('user', true); // grant view and connect await voice.permissionOverwrites.edit(u.id, { ViewChannel: true, Connect: true }); await interaction.reply({ content: `Invited <@${u.id}>.`, flags: MessageFlags.Ephemeral }); } else if (sub === 'kick') { const u = interaction.options.getUser('user', true); const gm = await guild.members.fetch(u.id); // move them out if in this channel if (gm.voice.channelId === voice.id) { await gm.voice.setChannel(null); } // remove any previous invite allow try { await voice.permissionOverwrites.delete(u.id); } catch {} await interaction.reply({ content: `Kicked <@${u.id}>.`, flags: MessageFlags.Ephemeral }); } else if (sub === 'limit') { let num = interaction.options.getInteger('number', true); // enforce range 0-99 if (num < 0 || num > 99) { return interaction.reply({ content: 'User limit must be between 0 (no limit) and 99.', flags: MessageFlags.Ephemeral }); } await voice.setUserLimit(num); await interaction.reply({ content: `User limit set to ${num}.`, flags: MessageFlags.Ephemeral }); } else if (sub === 'role') { const newRole = interaction.options.getRole('role', true); const oldRoleId = sess.roleId; // remove old role overwrite if any if (oldRoleId && oldRoleId !== guild.roles.everyone.id) { await voice.permissionOverwrites.delete(oldRoleId).catch(() => {}); } // selecting @everyone resets all if (newRole.id === guild.roles.everyone.id) { // clear all overwrites await voice.permissionOverwrites.set([ { id: guild.roles.everyone.id, allow: [PermissionFlagsBits.Connect] }, { id: sess.ownerId, allow: [PermissionFlagsBits.Connect, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.ManageChannels] } ]); sess.roleId = ''; await client.pb.updateOne('tempvc_sessions', sess.pbId, { roleId: '', mode: sess.mode }); return interaction.reply({ content: '@everyone can now connect.', flags: MessageFlags.Ephemeral }); } if (sess.mode === 'whitelist') { // whitelist: lock everyone, allow role await voice.permissionOverwrites.edit(guild.roles.everyone.id, { Connect: false }); await voice.permissionOverwrites.edit(newRole.id, { Connect: true }); sess.roleId = newRole.id; await client.pb.updateOne('tempvc_sessions', sess.pbId, { roleId: newRole.id, mode: sess.mode }); await interaction.reply({ content: `Whitelisted role <@&${newRole.id}>.`, flags: MessageFlags.Ephemeral }); } else { // blacklist: allow everyone, deny role await voice.permissionOverwrites.edit(guild.roles.everyone.id, { Connect: true }); await voice.permissionOverwrites.edit(newRole.id, { Connect: false }); sess.roleId = newRole.id; await client.pb.updateOne('tempvc_sessions', sess.pbId, { roleId: newRole.id, mode: sess.mode }); await interaction.reply({ content: `Blacklisted role <@&${newRole.id}>.`, flags: MessageFlags.Ephemeral }); } } else if (sub === 'delete') { await interaction.reply({ content: 'Deleting your channel...', flags: MessageFlags.Ephemeral }); await client.pb.deleteOne('tempvc_sessions', sess.pbId); client.tempvc.sessions.delete(voice.id); await voice.delete('Owner deleted temp VC'); } else if (sub === 'info') { const invites = voice.permissionOverwrites.cache .filter(po => po.allow.has(PermissionFlagsBits.Connect) && ![guild.roles.everyone.id, sess.roleId].includes(po.id)) .map(po => `<@${po.id}>`); const everyoneId = guild.roles.everyone.id; const roleLine = (!sess.roleId || sess.roleId === everyoneId) ? '@everyone' : `<@&${sess.roleId}>`; const modeLine = sess.mode || 'whitelist'; const lines = [ `Owner: <@${sess.ownerId}>`, `Name: ${voice.name}`, `Role: ${roleLine} (${modeLine})`, `User limit: ${voice.userLimit}`, `Invites: ${invites.length ? invites.join(', ') : 'none'}` ]; await interaction.reply({ content: lines.join('\n'), flags: MessageFlags.Ephemeral }); } else if (sub === 'save') { const name = interaction.options.getString('name', true); // gather invites const invited = voice.permissionOverwrites.cache .filter(po => po.allow.has(PermissionFlagsBits.Connect) && ![guild.roles.everyone.id, sess.roleId].includes(po.id)) .map(po => po.id); // upsert preset const existing = await client.pb.getFirst( 'tempvc_presets', `guildId = "${guild.id}" && userId = "${interaction.user.id}" && name = "${name}"` ); const data = { guildId: guild.id, userId: interaction.user.id, name, channelName: voice.name, userLimit: voice.userLimit, roleId: sess.roleId || '', invitedUserIds: invited, mode: sess.mode || 'whitelist' }; if (existing) { await client.pb.updateOne('tempvc_presets', existing.id, data); } else { await client.pb.createOne('tempvc_presets', data); } await interaction.reply({ content: `Preset **${name}** saved.`, flags: MessageFlags.Ephemeral }); } else if (sub === 'reset') { // reset channel to default parameters const owner = interaction.member; const display = owner.displayName || owner.user.username; const defaultName = `TempVC: ${display}`; await voice.setName(defaultName); await voice.setUserLimit(0); // clear all overwrites: allow everyone, owner elevated perms await voice.permissionOverwrites.set([ { id: guild.roles.everyone.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect] }, { id: sess.ownerId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.ManageChannels, PermissionFlagsBits.PrioritySpeaker, PermissionFlagsBits.MuteMembers, PermissionFlagsBits.DeafenMembers ] } ]); sess.roleId = guild.roles.everyone.id; await client.pb.updateOne('tempvc_sessions', sess.pbId, { roleId: guild.roles.everyone.id, invitedUserIds: [] }); await interaction.reply({ content: 'Channel has been reset to default settings.', flags: MessageFlags.Ephemeral }); } else if (sub === 'mode') { const mode = interaction.options.getString('mode', true); sess.mode = mode; // apply mode overwrites if (mode === 'whitelist') { // only allow whitelisted role await voice.permissionOverwrites.edit(guild.roles.everyone.id, { ViewChannel: false }); if (sess.roleId) await voice.permissionOverwrites.edit(sess.roleId, { ViewChannel: true }); } else { // blacklist: allow everyone, then deny the specified role await voice.permissionOverwrites.edit(guild.roles.everyone.id, { ViewChannel: true }); if (sess.roleId) await voice.permissionOverwrites.edit(sess.roleId, { ViewChannel: false }); } // persist mode await client.pb.updateOne('tempvc_sessions', sess.pbId, { mode }); await interaction.reply({ content: `Channel mode set to **${mode}**.`, flags: MessageFlags.Ephemeral }); } else if (sub === 'restore') { const name = interaction.options.getString('name', true); const preset = await client.pb.getFirst( 'tempvc_presets', `guildId = "${guild.id}" && userId = "${interaction.user.id}" && name = "${name}"` ); if (!preset) { return interaction.reply({ content: `Preset **${name}** not found.`, flags: MessageFlags.Ephemeral }); } // apply settings await voice.setName(preset.channelName); await voice.setUserLimit(preset.userLimit); // apply mode-based permissions const mode = preset.mode || 'whitelist'; sess.mode = mode; // adjust view/connect for @everyone await voice.permissionOverwrites.edit( guild.roles.everyone.id, { ViewChannel: mode === 'blacklist', Connect: mode === 'blacklist' } ); // adjust view/connect for role if (preset.roleId) { await voice.permissionOverwrites.edit( preset.roleId, { ViewChannel: mode === 'whitelist', Connect: mode === 'whitelist' } ); } // invite users explicitly for (const uid of preset.invitedUserIds || []) { await voice.permissionOverwrites.edit(uid, { Connect: true }).catch(() => {}); } // persist session changes await client.pb.updateOne( 'tempvc_sessions', sess.pbId, { roleId: preset.roleId || '', mode } ); sess.roleId = preset.roleId || ''; await interaction.reply({ content: `Preset **${name}** restored (mode: ${mode}).`, flags: MessageFlags.Ephemeral }); } } catch (err) { client.logger.error(`[module:tempvc][vc] ${err.message}`); await interaction.reply({ content: 'Operation failed, see logs.', flags: MessageFlags.Ephemeral }); } } } ]; /** * Initialize module: load PB state and hook events */ export async function init(client) { // tempvc state: masters per guild, sessions map client.tempvc = { masters: new Map(), sessions: new Map() }; // hook voice state updates client.on('voiceStateUpdate', async (oldState, newState) => { client.logger.debug( `[module:tempvc] voiceStateUpdate: user=${newState.id} oldChannel=${oldState.channelId} newChannel=${newState.channelId}` ); // cleanup on leave if (oldState.channelId && oldState.channelId !== newState.channelId) { const sess = client.tempvc.sessions.get(oldState.channelId); const ch = oldState.guild.channels.cache.get(oldState.channelId); if (sess && (!ch || ch.members.size === 0)) { await client.pb.deleteOne('tempvc_sessions', sess.pbId).catch(()=>{}); client.tempvc.sessions.delete(oldState.channelId); await ch?.delete('Empty temp VC cleanup').catch(()=>{}); } } // spawn on join if (newState.channelId && newState.channelId !== oldState.channelId) { const masters = client.tempvc.masters.get(newState.guild.id) || new Map(); client.logger.debug( `[module:tempvc] Guild ${newState.guild.id} masters: ${[...masters.keys()].join(',')}` ); client.logger.debug( `[module:tempvc] Checking spawn for channel ${newState.channelId}: ${masters.has(newState.channelId)}` ); if (masters.has(newState.channelId)) { const catId = masters.get(newState.channelId); const owner = newState.member; const guild = newState.guild; // default channel name const displayName = owner.displayName || owner.user.username; const name = `TempVC: ${displayName}`; // create channel // create voice channel, default permissions inherited from category (allow everyone) // create voice channel; default allow everyone view/join, owner elevated perms const ch = await guild.channels.create({ name, type: ChannelType.GuildVoice, parent: catId, permissionOverwrites: [ { id: guild.roles.everyone.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect] }, { id: owner.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.ManageChannels, PermissionFlagsBits.PrioritySpeaker, PermissionFlagsBits.MuteMembers, PermissionFlagsBits.DeafenMembers ] } ] }); // move member await owner.voice.setChannel(ch); // persist session const rec = await client.pb.createOne('tempvc_sessions', { guildId: guild.id, masterChannelId: newState.channelId, channelId: ch.id, ownerId: owner.id, roleId: guild.roles.everyone.id, mode: 'whitelist' }); client.tempvc.sessions.set(ch.id, { pbId: rec.id, guildId: guild.id, masterChannelId: newState.channelId, ownerId: owner.id, roleId: guild.roles.everyone.id, mode: 'whitelist' }); // send instructions to the voice channel itself try { const helpEmbed = new EmbedBuilder() .setTitle('👋 Welcome to Your Temporary Voice Channel!') .setColor('Blue') .addFields( { name: 'Access Control', value: '• /vc invite — Invite a user to this channel\n' + '• /vc kick — Kick a user from this channel\n' + '• /vc role — Set a role to allow/deny access\n' + '• /vc mode — Switch role mode\n' + '• /vc limit — Set user limit (0–99)', }, { name: 'Presets', value: '• /vc save — Save current settings as a preset\n' + '• /vc restore — Restore settings from a preset\n' + '• /vc reset — Reset channel to default settings', }, { name: 'Utilities', value: '• /vc rename — Rename this channel\n' + '• /vc info — Show channel info\n' + '• /vc delete — Delete this channel', } ); await ch.send({ embeds: [helpEmbed] }); } catch (err) { client.logger.error(`[module:tempvc] Error sending help message: ${err.message}`); } } } }); // autocomplete for /vc save & restore presets client.on('interactionCreate', async interaction => { if (!interaction.isAutocomplete() || interaction.commandName !== 'vc') return; const sub = interaction.options.getSubcommand(false); if (!['save', 'restore'].includes(sub)) return; const focused = interaction.options.getFocused(true); if (focused.name !== 'name') return; const guildId = interaction.guildId; const userId = interaction.user.id; try { const recs = await client.pb.getAll('tempvc_presets', { filter: `guildId = "${guildId}" && userId = "${userId}"` }); const choices = recs .filter(r => r.name.toLowerCase().startsWith(focused.value.toLowerCase())) .slice(0, 25) .map(r => ({ name: r.name, value: r.name })); await interaction.respond(choices); } catch (err) { client.logger.error(`[module:tempvc][autocomplete] ${err.message}`); await interaction.respond([]); } }); // On ready: load masters/sessions, then check required permissions client.on('ready', async () => { // Load persistent spawn masters and active sessions for (const guild of client.guilds.cache.values()) { const gid = guild.id; try { const masters = await client.pb.getAll('tempvc_masters', { filter: `guildId = "${gid}"` }); // guildId = "X" works, but escaped quotes are allowed const gm = new Map(); for (const rec of masters) gm.set(rec.masterChannelId, rec.categoryId); client.tempvc.masters.set(gid, gm); client.logger.info(`[module:tempvc] Loaded spawn masters for guild ${gid}: ${[...gm.keys()].join(', ')}`); } catch (err) { client.logger.error(`[module:tempvc] Error loading masters for guild ${gid}: ${err.message}`); } try { const sessions = await client.pb.getAll('tempvc_sessions', { filter: `guildId = "${gid}"` }); for (const rec of sessions) { const ch = guild.channels.cache.get(rec.channelId); if (ch && ch.isVoiceBased()) { client.tempvc.sessions.set(rec.channelId, { pbId: rec.id, guildId: gid, masterChannelId: rec.masterChannelId, ownerId: rec.ownerId, roleId: rec.roleId || '', mode: rec.mode || 'whitelist' }); if (rec.roleId) await ch.permissionOverwrites.edit(rec.roleId, { Connect: true }).catch(()=>{}); await ch.permissionOverwrites.edit(rec.ownerId, { Connect: true, ManageChannels: true, MoveMembers: true }); } else { await client.pb.deleteOne('tempvc_sessions', rec.id).catch(()=>{}); } } } catch (err) { client.logger.error(`[module:tempvc] Error loading sessions for guild ${gid}: ${err.message}`); } } // Verify necessary permissions for (const guild of client.guilds.cache.values()) { // get bot's member in this guild let me = guild.members.me; if (!me) { try { me = await guild.members.fetch(client.user.id); } catch { /* ignore */ } } if (!me) continue; const missing = []; if (!me.permissions.has(PermissionFlagsBits.ManageChannels)) missing.push('ManageChannels'); if (!me.permissions.has(PermissionFlagsBits.MoveMembers)) missing.push('MoveMembers'); if (missing.length) { client.logger.warn( `[module:tempvc] Missing permissions in guild ${guild.id} (${guild.name}): ${missing.join(', ')}` ); } } }); client.logger.info('[module:tempvc] Module initialized'); }