585 lines
26 KiB
JavaScript
585 lines
26 KiB
JavaScript
|
|
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 (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);
|
|||
|
|
await voice.permissionOverwrites.edit(u.id, { 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 except owner, default allow @everyone
|
|||
|
|
await voice.permissionOverwrites.set([
|
|||
|
|
{ id: guild.roles.everyone.id, allow: [PermissionFlagsBits.Connect] },
|
|||
|
|
{ id: sess.ownerId, allow: [PermissionFlagsBits.Connect, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.ManageChannels] }
|
|||
|
|
]);
|
|||
|
|
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 + owner + invites
|
|||
|
|
await voice.permissionOverwrites.edit(guild.roles.everyone.id, { Connect: false });
|
|||
|
|
if (sess.roleId) await voice.permissionOverwrites.edit(sess.roleId, { Connect: true });
|
|||
|
|
} else {
|
|||
|
|
// blacklist: allow everyone, then deny the specified role
|
|||
|
|
await voice.permissionOverwrites.edit(guild.roles.everyone.id, { Connect: true });
|
|||
|
|
if (sess.roleId) await voice.permissionOverwrites.edit(sess.roleId, { Connect: 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;
|
|||
|
|
// clear existing overwrites for everyone and role
|
|||
|
|
await voice.permissionOverwrites.edit(guild.roles.everyone.id, { Connect: mode === 'blacklist' });
|
|||
|
|
if (preset.roleId) {
|
|||
|
|
await voice.permissionOverwrites.edit(preset.roleId, { Connect: mode === 'whitelist' ? true : false });
|
|||
|
|
}
|
|||
|
|
// 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)
|
|||
|
|
const ch = await guild.channels.create({
|
|||
|
|
name,
|
|||
|
|
type: ChannelType.GuildVoice,
|
|||
|
|
parent: catId,
|
|||
|
|
permissionOverwrites: [
|
|||
|
|
{ id: owner.id, allow: [PermissionFlagsBits.Connect, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.ManageChannels] }
|
|||
|
|
]
|
|||
|
|
});
|
|||
|
|
// 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 (0–99)',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
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');
|
|||
|
|
}
|