ClientX/_opt/tempvc.js
2025-05-04 18:41:46 +00:00

613 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <voice_channel> <category>
* - remove <voice_channel>
* - list
*
* User commands (/vc):
* Access Control:
* • invite <user>
* • kick <user>
* • role <role>
* • mode <whitelist|blacklist>
* • limit <0-99>
* Presets:
* • save <name>
* • restore <name>
* • reset
* Utilities:
* • rename <new_name>
* • 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 (099)')
.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 <user> — Invite a user to this channel\n' +
'• /vc kick <user> — Kick a user from this channel\n' +
'• /vc role <role> — Set a role to allow/deny access\n' +
'• /vc mode <whitelist|blacklist> — Switch role mode\n' +
'• /vc limit <number> — Set user limit (099)',
},
{
name: 'Presets',
value:
'• /vc save <name> — Save current settings as a preset\n' +
'• /vc restore <name> — Restore settings from a preset\n' +
'• /vc reset — Reset channel to default settings',
},
{
name: 'Utilities',
value:
'• /vc rename <new_name> — 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');
}