Lumi/plugins/auto-vc/index.js
2026-05-30 20:37:42 +02:00

1457 lines
43 KiB
JavaScript

const path = require("path");
const fs = require("fs");
const ejs = require("ejs");
const { Permissions } = require("discord.js");
const { ensureUserForIdentity } = require("../../src/services/users");
const PLUGIN_ID = "auto-vc";
const DEFAULT_TEMPLATE = "[username]'s room";
const DEFAULT_TIMEOUT = 30;
const GAME_NAME_TOKEN = "[game_name]";
const NAME_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
const ALLOW_CONNECT_VIEW =
Permissions.FLAGS.CONNECT | Permissions.FLAGS.VIEW_CHANNEL;
const DEFAULT_CREATE_LIMIT = { max: 3, windowSeconds: 600 };
const DEFAULT_ACTION_LIMIT = { max: 8, windowSeconds: 60 };
module.exports = {
id: PLUGIN_ID,
init({ web, discordClient, db, settings }) {
ensureTables(db);
const state = {
rooms: new Map(),
cleanupTimers: new Map(),
emptySince: new Map(),
sweepTimer: null,
nameSweepTimer: null,
rateLimits: {
create: new Map(),
action: new Map()
}
};
const router = web.createRouter();
router.get("/", async (req, res) => {
const config = getConfig(db);
const user = req.session.user || null;
const stats = getStats(db);
const bans = getBans(db);
const channelOptions = getChannelOptions(discordClient);
const lobbies = await enrichLobbiesWithPermissions(discordClient, config.lobbies);
const locals = {
...res.locals,
title: "Auto VC",
lobbies,
stats,
bans,
limits: config.limits,
voiceChannels: channelOptions.voiceChannels,
categoryChannels: channelOptions.categoryChannels,
isAdmin: Boolean(user?.isAdmin),
canModerate: Boolean(user?.isAdmin || user?.isMod)
};
const html = await renderPage(locals);
res.send(html);
});
router.post("/settings", (req, res) => {
if (!req.session.user || !req.session.user.isAdmin) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
const config = parseConfigFromForm(req.body);
saveConfig(db, config);
state.rooms.clear();
req.session.flash = {
type: "success",
message: "Auto VC settings saved."
};
res.redirect("/plugins/auto-vc");
});
router.post("/bans", (req, res) => {
if (!req.session.user || !(req.session.user.isAdmin || req.session.user.isMod)) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
const input = (req.body.ban_input || "").trim();
const reason = (req.body.ban_reason || "").trim();
const id = parseDiscordId(input);
if (!id) {
req.session.flash = {
type: "error",
message: "Enter a valid user ID or mention."
};
return res.redirect("/plugins/auto-vc");
}
banUser(db, id, reason);
req.session.flash = {
type: "success",
message: "User banned from Auto VC creation."
};
res.redirect("/plugins/auto-vc");
});
router.post("/unban", (req, res) => {
if (!req.session.user || !(req.session.user.isAdmin || req.session.user.isMod)) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
const ids = toArray(req.body.unban_ids).filter(Boolean);
ids.forEach((id) => unbanUser(db, id));
req.session.flash = {
type: "success",
message: ids.length ? "User(s) unbanned." : "No users selected."
};
res.redirect("/plugins/auto-vc");
});
web.mount("/plugins/auto-vc", router, {
label: "Auto VC",
role: "public",
section: "plugins"
});
if (!discordClient) {
return;
}
const attach = () => {
bootstrapRooms(discordClient, db, state, settings).catch((error) => {
console.error("Auto VC bootstrap failed", error);
});
discordClient.on("voiceStateUpdate", (oldState, newState) => {
handleVoiceStateUpdate(oldState, newState, db, settings, state);
});
discordClient.on("messageCreate", (message) => {
handleMessage(message, db, settings, state);
});
discordClient.on("channelDelete", (channel) => {
if (channel && channel.id) {
removeRoom(db, state, channel.id);
}
});
startSweepTimer(discordClient, db, settings, state);
startNameRefreshTimer(discordClient, db, settings, state);
};
if (discordClient.readyAt) {
attach();
} else {
discordClient.once("ready", attach);
}
}
};
async function renderPage(locals) {
const viewsRoot = path.join(__dirname, "..", "..", "src", "web", "views");
const layoutTop = path.join(viewsRoot, "partials", "layout-top.ejs");
const layoutBottom = path.join(viewsRoot, "partials", "layout-bottom.ejs");
const pagePath = path.join(__dirname, "views", "auto-vc.ejs");
const bodyTemplate = fs.readFileSync(pagePath, "utf8");
const body = ejs.render(bodyTemplate, locals, { filename: pagePath });
const top = await ejs.renderFile(layoutTop, locals);
const bottom = await ejs.renderFile(layoutBottom, locals);
return `${top}${body}${bottom}`;
}
function getChannelOptions(discordClient) {
const voiceChannels = [];
const categoryChannels = [];
if (!discordClient || !discordClient.guilds?.cache) {
return { voiceChannels, categoryChannels };
}
for (const guild of discordClient.guilds.cache.values()) {
const channels = guild.channels?.cache;
if (!channels) {
continue;
}
channels.forEach((channel) => {
if (channel.type === "GUILD_VOICE") {
voiceChannels.push({
id: channel.id,
label: `${guild.name}${channel.name}`
});
}
if (channel.type === "GUILD_CATEGORY") {
categoryChannels.push({
id: channel.id,
label: `${guild.name}${channel.name}`
});
}
});
}
voiceChannels.sort((a, b) => a.label.localeCompare(b.label));
categoryChannels.sort((a, b) => a.label.localeCompare(b.label));
return { voiceChannels, categoryChannels };
}
async function enrichLobbiesWithPermissions(discordClient, lobbies) {
if (!Array.isArray(lobbies)) {
return [];
}
const enriched = [];
for (const lobby of lobbies) {
const permissions = await buildLobbyPermissionChecks(discordClient, lobby);
enriched.push({ ...lobby, permissions });
}
return enriched;
}
async function buildLobbyPermissionChecks(discordClient, lobby) {
const baseChecks = [
"Bot in guild",
"Lobby channel exists",
"View lobby channel",
"Connect to lobby",
"Move members",
"Target category visible",
"Manage rooms"
];
if (!discordClient || !discordClient.user) {
return baseChecks.map((label) =>
buildPermissionCheck(
label,
false,
"Start the Discord bot so it can evaluate permissions."
)
);
}
const lobbyChannel = await resolveChannel(discordClient, lobby?.lobbyChannelId);
const categoryChannel =
(await resolveChannel(discordClient, lobby?.categoryId)) ||
(lobbyChannel?.parentId
? await resolveChannel(discordClient, lobbyChannel.parentId)
: null);
const guild = lobbyChannel?.guild || categoryChannel?.guild || null;
const botMember = guild ? await resolveBotMember(guild, discordClient) : null;
if (!guild || !botMember) {
return baseChecks.map((label) =>
buildPermissionCheck(
label,
false,
"Ensure the bot is in this server and has access to the lobby."
)
);
}
const lobbyPerms = lobbyChannel ? lobbyChannel.permissionsFor(botMember) : null;
const categoryPerms = categoryChannel
? categoryChannel.permissionsFor(botMember)
: null;
const guildPerms = botMember.permissions || null;
const lobbyIsVoice = lobbyChannel?.type === "GUILD_VOICE";
const canViewLobby = Boolean(lobbyPerms?.has(Permissions.FLAGS.VIEW_CHANNEL));
const canConnectLobby = Boolean(lobbyPerms?.has(Permissions.FLAGS.CONNECT));
const canMoveMembers = Boolean(
lobbyPerms?.has(Permissions.FLAGS.MOVE_MEMBERS) ||
guildPerms?.has(Permissions.FLAGS.MOVE_MEMBERS)
);
const canViewCategory = Boolean(
categoryChannel && categoryPerms?.has(Permissions.FLAGS.VIEW_CHANNEL)
);
const canManageChannels = Boolean(
categoryPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) ||
lobbyPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) ||
guildPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS)
);
return [
buildPermissionCheck("Bot in guild", true, ""),
buildPermissionCheck(
"Lobby channel exists",
Boolean(lobbyChannel) && lobbyIsVoice,
lobbyChannel
? "The lobby must be a voice channel."
: "Set a valid lobby voice channel ID and save."
),
buildPermissionCheck(
"View lobby channel",
Boolean(lobbyChannel) && lobbyIsVoice && canViewLobby,
"Allow View Channel for the bot on the lobby channel or its category."
),
buildPermissionCheck(
"Connect to lobby",
Boolean(lobbyChannel) && lobbyIsVoice && canConnectLobby,
"Allow Connect for the bot on the lobby channel or its category."
),
buildPermissionCheck(
"Move members",
canMoveMembers,
"Allow Move Members for the bot role on the server or lobby channel."
),
buildPermissionCheck(
"Target category visible",
Boolean(categoryChannel) && canViewCategory,
categoryChannel
? "Allow View Channel for the bot on the target category."
: "Set a valid target category ID or place the lobby inside a category."
),
buildPermissionCheck(
"Manage rooms",
canManageChannels && (categoryChannel ? canViewCategory : true),
"Allow Manage Channels (and View Channel) on the target category so the bot can create, rename, and delete rooms."
)
];
}
function buildPermissionCheck(label, granted, help) {
return {
label,
granted: Boolean(granted),
help: granted ? "" : help
};
}
async function resolveChannel(discordClient, channelId) {
if (!discordClient || !channelId) {
return null;
}
const cached = discordClient.channels?.cache?.get(channelId) || null;
if (cached) {
return cached;
}
if (typeof discordClient.channels?.fetch === "function") {
return discordClient.channels.fetch(channelId).catch(() => null);
}
return null;
}
async function resolveBotMember(guild, discordClient) {
if (!guild || !discordClient?.user?.id) {
return null;
}
const cached = guild.members?.cache?.get(discordClient.user.id) || null;
if (cached) {
return cached;
}
if (typeof guild.members?.fetch === "function") {
return guild.members.fetch(discordClient.user.id).catch(() => null);
}
return null;
}
function ensureTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS auto_vc_rooms (
channel_id TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
lobby_id TEXT NOT NULL,
category_id TEXT NOT NULL,
owner_discord_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
room_number INTEGER NOT NULL,
name_template TEXT NOT NULL,
locked INTEGER NOT NULL DEFAULT 0,
allowed_user_ids TEXT NOT NULL DEFAULT '[]',
base_overwrites TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auto_vc_stats (
user_id TEXT PRIMARY KEY,
created_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auto_vc_bans (
discord_user_id TEXT PRIMARY KEY,
reason TEXT,
created_at INTEGER NOT NULL
);
`);
}
function getConfig(db) {
const row = db
.prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?")
.get(PLUGIN_ID, "config");
if (!row) {
return normalizeConfig({});
}
try {
const parsed = JSON.parse(row.value);
return normalizeConfig(parsed);
} catch {
return normalizeConfig({});
}
}
function normalizeConfig(config) {
const lobbies = Array.isArray(config?.lobbies) ? config.lobbies : [];
return {
lobbies: lobbies.map((lobby) => normalizeLobby(lobby)),
limits: normalizeRateLimits(config?.limits)
};
}
function normalizeLobby(lobby) {
const timeout = Number(lobby?.emptyTimeoutSeconds || DEFAULT_TIMEOUT);
return {
id: lobby?.id || cryptoRandomId(),
lobbyChannelId: (lobby?.lobbyChannelId || "").toString().trim(),
categoryId: (lobby?.categoryId || "").toString().trim(),
nameTemplate: (lobby?.nameTemplate || DEFAULT_TEMPLATE).toString(),
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : Math.max(5, timeout)
};
}
function normalizeRateLimits(limits) {
return {
create: normalizeLimit(limits?.create, DEFAULT_CREATE_LIMIT),
action: normalizeLimit(limits?.action, DEFAULT_ACTION_LIMIT)
};
}
function normalizeLimit(limit, defaults) {
const max = clampNumber(limit?.max, defaults.max, 1, 100);
const windowSeconds = clampNumber(
limit?.windowSeconds,
defaults.windowSeconds,
10,
3600
);
return { max, windowSeconds };
}
function clampNumber(value, fallback, min, max) {
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return fallback;
}
return Math.min(Math.max(parsed, min), max);
}
function saveConfig(db, config) {
const now = Date.now();
db.prepare(
"INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " +
"ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
).run(PLUGIN_ID, "config", JSON.stringify(config), now);
}
function parseConfigFromForm(body) {
const ids = toArray(body.lobby_id);
const lobbyChannelIds = toArray(body.lobby_channel_id);
const categoryIds = toArray(body.lobby_category_id);
const templates = toArray(body.lobby_name_template);
const timeouts = toArray(body.lobby_empty_timeout);
const removeIds = new Set(toArray(body.lobby_remove));
const lobbies = ids.map((id, index) => {
const timeout = Number(timeouts[index] || DEFAULT_TIMEOUT);
return normalizeLobby({
id,
lobbyChannelId: lobbyChannelIds[index] || "",
categoryId: categoryIds[index] || "",
nameTemplate: templates[index] || DEFAULT_TEMPLATE,
emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : timeout
});
});
const createLimit = normalizeLimit(
{
max: body.rate_create_count,
windowSeconds: body.rate_create_window
},
DEFAULT_CREATE_LIMIT
);
const actionLimit = normalizeLimit(
{
max: body.rate_action_count,
windowSeconds: body.rate_action_window
},
DEFAULT_ACTION_LIMIT
);
return {
lobbies: lobbies.filter((lobby) => !removeIds.has(lobby.id)),
limits: {
create: createLimit,
action: actionLimit
}
};
}
function getStats(db) {
const rows = db
.prepare(
"SELECT auto_vc_stats.user_id AS user_id, auto_vc_stats.created_count AS created_count, user_profiles.internal_username AS username " +
"FROM auto_vc_stats " +
"LEFT JOIN user_profiles ON user_profiles.id = auto_vc_stats.user_id " +
"ORDER BY auto_vc_stats.created_count DESC LIMIT 10"
)
.all();
return rows.map((row) => ({
label: row.username || row.user_id,
count: row.created_count
}));
}
function getBans(db) {
const rows = db
.prepare("SELECT discord_user_id, reason FROM auto_vc_bans ORDER BY created_at DESC")
.all();
return rows.map((row) => ({
discord_user_id: row.discord_user_id,
reason: row.reason,
label: row.discord_user_id
}));
}
function banUser(db, discordUserId, reason) {
db.prepare(
"INSERT INTO auto_vc_bans (discord_user_id, reason, created_at) VALUES (?, ?, ?) " +
"ON CONFLICT(discord_user_id) DO UPDATE SET reason = excluded.reason, created_at = excluded.created_at"
).run(discordUserId, reason || "", Date.now());
}
function unbanUser(db, discordUserId) {
db.prepare("DELETE FROM auto_vc_bans WHERE discord_user_id = ?").run(discordUserId);
}
function isBanned(db, discordUserId) {
const row = db
.prepare("SELECT discord_user_id FROM auto_vc_bans WHERE discord_user_id = ?")
.get(discordUserId);
return Boolean(row);
}
async function bootstrapRooms(discordClient, db, state, settings) {
const rooms = db
.prepare("SELECT * FROM auto_vc_rooms")
.all();
for (const room of rooms) {
const guild = discordClient.guilds.cache.get(room.guild_id);
if (!guild) {
removeRoom(db, state, room.channel_id);
continue;
}
const channel = await guild.channels.fetch(room.channel_id).catch(() => null);
if (!channel || channel.type !== "GUILD_VOICE") {
removeRoom(db, state, room.channel_id);
continue;
}
if (channel.members.size === 0) {
await deleteChannel(channel);
removeRoom(db, state, room.channel_id);
continue;
}
state.rooms.set(room.channel_id, normalizeRoom(room));
}
}
function normalizeRoom(room) {
return {
channel_id: room.channel_id,
guild_id: room.guild_id,
lobby_id: room.lobby_id,
category_id: room.category_id,
owner_discord_id: room.owner_discord_id,
owner_user_id: room.owner_user_id,
room_number: Number(room.room_number || 1),
name_template: room.name_template,
locked: Boolean(room.locked),
allowed_user_ids: parseJsonArray(room.allowed_user_ids),
base_overwrites: room.base_overwrites,
created_at: room.created_at
};
}
function handleVoiceStateUpdate(oldState, newState, db, settings, state) {
const config = getConfig(db);
const lobby = config.lobbies.find(
(item) => item.lobbyChannelId && item.lobbyChannelId === newState.channelId
);
if (lobby && newState.channelId !== oldState.channelId) {
createRoomFromLobby(newState, lobby, db, settings, state, config).catch((error) => {
console.error("Auto VC creation failed", error);
});
}
if (oldState.channelId && oldState.channelId !== newState.channelId) {
const room = state.rooms.get(oldState.channelId) || getRoomById(db, oldState.channelId, state);
if (room) {
const channel = oldState.guild.channels.cache.get(oldState.channelId);
if (channel) {
scheduleCleanup(channel, room, db, state, config);
}
}
}
if (newState.channelId) {
const room = state.rooms.get(newState.channelId) || getRoomById(db, newState.channelId, state);
if (room) {
clearCleanupTimer(state, newState.channelId);
clearEmpty(state, newState.channelId);
}
}
}
async function createRoomFromLobby(newState, lobby, db, settings, state, config) {
const member = newState.member;
if (!member || !newState.guild) {
return;
}
if (isBanned(db, member.id)) {
await safeNotify(member, "You are banned from creating Auto VCs.");
return;
}
const lobbyChannel = newState.guild.channels.cache.get(lobby.lobbyChannelId);
if (!lobbyChannel || lobbyChannel.type !== "GUILD_VOICE") {
return;
}
const createLimit = consumeRateLimit(state, config, "create", member.id);
if (!createLimit.ok) {
await safeNotify(
member,
`You're creating rooms too quickly. Try again in ${formatCooldown(
createLimit.retryAfter
)}.`
);
return;
}
const roomNumber = getNextAvailableRoomNumber(db, lobby.id);
const gameName = resolveGameName(member);
const roomName = buildRoomName(lobby.nameTemplate, member, roomNumber, gameName);
const baseOverwrites = extractOverwrites(lobbyChannel);
const channel = await newState.guild.channels.create(roomName, {
type: "GUILD_VOICE",
parent: lobby.categoryId || lobbyChannel.parentId || null,
permissionOverwrites: baseOverwrites
});
const profile = ensureUserForIdentity({
provider: "discord",
providerUserId: member.id,
displayName: member.displayName
});
const room = {
channel_id: channel.id,
guild_id: newState.guild.id,
lobby_id: lobby.id,
category_id: lobby.categoryId || lobbyChannel.parentId || "",
owner_discord_id: member.id,
owner_user_id: profile.id,
room_number: roomNumber,
name_template: lobby.nameTemplate,
locked: false,
allowed_user_ids: [],
base_overwrites: JSON.stringify(baseOverwrites),
created_at: Date.now()
};
saveRoom(db, room);
state.rooms.set(channel.id, normalizeRoom(room));
incrementUserStat(db, profile.id);
const moved = await moveMemberToChannel(member, channel);
if (!moved) {
await safeNotify(
member,
"I couldn't move you to the new VC. Please make sure the bot has Move Members permission."
);
}
}
function buildRoomName(template, member, roomNumber, gameName) {
const safeTemplate = (template || DEFAULT_TEMPLATE).toString();
const hasGameToken = templateHasGameName(safeTemplate);
const shouldFallback = hasGameToken && !gameName;
const templateToUse = shouldFallback ? DEFAULT_TEMPLATE : safeTemplate;
const username = member.displayName || member.user?.username || "user";
const replacements = [
["[username]", username],
["[room_number]", String(roomNumber)],
["[game_name]", gameName || ""]
];
let name = templateToUse;
for (const [token, value] of replacements) {
name = replaceToken(name, token, value);
}
name = name.replace(/\s{2,}/g, " ").trim();
if (!name) {
name = `${username}'s room`;
}
return name.slice(0, 100);
}
function templateHasGameName(template) {
if (!template) {
return false;
}
return template.toLowerCase().includes(GAME_NAME_TOKEN);
}
function replaceToken(text, token, value) {
if (!text) {
return text;
}
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return text.replace(new RegExp(escaped, "gi"), value);
}
function resolveGameName(member) {
const activities = member?.presence?.activities || [];
const activity = activities.find((item) => isGameActivity(item));
return activity?.name || "";
}
function isGameActivity(activity) {
if (!activity || !activity.name) {
return false;
}
const type = activity.type;
if (type === "PLAYING" || type === "STREAMING" || type === "COMPETING") {
return true;
}
if (type === 0 || type === 1 || type === 5) {
return true;
}
return false;
}
function getNextAvailableRoomNumber(db, lobbyId) {
const rows = db
.prepare("SELECT room_number FROM auto_vc_rooms WHERE lobby_id = ?")
.all(lobbyId);
const used = new Set();
for (const row of rows) {
const value = Number(row.room_number);
if (!Number.isNaN(value) && value > 0) {
used.add(value);
}
}
let candidate = 1;
while (used.has(candidate)) {
candidate += 1;
}
return candidate;
}
function extractOverwrites(channel) {
return channel.permissionOverwrites.cache.map((overwrite) => ({
id: overwrite.id,
type: overwrite.type,
allow: serializeBitfield(overwrite.allow.bitfield),
deny: serializeBitfield(overwrite.deny.bitfield)
}));
}
function saveRoom(db, room) {
db.prepare(
"INSERT INTO auto_vc_rooms (channel_id, guild_id, lobby_id, category_id, owner_discord_id, owner_user_id, room_number, name_template, locked, allowed_user_ids, base_overwrites, created_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(channel_id) DO UPDATE SET owner_discord_id = excluded.owner_discord_id, owner_user_id = excluded.owner_user_id, room_number = excluded.room_number, name_template = excluded.name_template, locked = excluded.locked, allowed_user_ids = excluded.allowed_user_ids, base_overwrites = excluded.base_overwrites"
).run(
room.channel_id,
room.guild_id,
room.lobby_id,
room.category_id,
room.owner_discord_id,
room.owner_user_id,
room.room_number,
room.name_template,
room.locked ? 1 : 0,
JSON.stringify(room.allowed_user_ids || []),
room.base_overwrites,
room.created_at
);
}
function updateRoom(db, room) {
db.prepare(
"UPDATE auto_vc_rooms SET owner_discord_id = ?, owner_user_id = ?, room_number = ?, name_template = ?, locked = ?, allowed_user_ids = ? WHERE channel_id = ?"
).run(
room.owner_discord_id,
room.owner_user_id,
room.room_number,
room.name_template,
room.locked ? 1 : 0,
JSON.stringify(room.allowed_user_ids || []),
room.channel_id
);
}
function getRoomById(db, channelId, state) {
const row = db
.prepare("SELECT * FROM auto_vc_rooms WHERE channel_id = ?")
.get(channelId);
if (!row) {
return null;
}
const room = normalizeRoom(row);
state.rooms.set(channelId, room);
return room;
}
function removeRoom(db, state, channelId) {
clearCleanupTimer(state, channelId);
clearEmpty(state, channelId);
state.rooms.delete(channelId);
db.prepare("DELETE FROM auto_vc_rooms WHERE channel_id = ?").run(channelId);
}
function incrementUserStat(db, userId) {
const now = Date.now();
db.prepare(
"INSERT INTO auto_vc_stats (user_id, created_count, updated_at) VALUES (?, 1, ?) " +
"ON CONFLICT(user_id) DO UPDATE SET created_count = created_count + 1, updated_at = excluded.updated_at"
).run(userId, now);
}
function consumeRateLimit(state, config, type, userId) {
const limit = config?.limits?.[type];
const buckets = state?.rateLimits?.[type];
if (!limit || !buckets || !userId) {
return { ok: true };
}
const now = Date.now();
const windowMs = Math.max(1, Number(limit.windowSeconds || 0)) * 1000;
if (!windowMs || limit.max <= 0) {
return { ok: true };
}
const current = buckets.get(userId) || {
count: 0,
resetAt: now + windowMs
};
if (now >= current.resetAt) {
current.count = 0;
current.resetAt = now + windowMs;
}
if (current.count >= limit.max) {
const retryAfter = Math.max(1, Math.ceil((current.resetAt - now) / 1000));
return { ok: false, retryAfter };
}
current.count += 1;
buckets.set(userId, current);
return {
ok: true,
remaining: Math.max(0, limit.max - current.count),
resetAt: current.resetAt
};
}
function formatCooldown(seconds) {
const safeSeconds = Math.max(1, Math.round(seconds || 0));
if (safeSeconds < 60) {
return `${safeSeconds}s`;
}
const minutes = Math.ceil(safeSeconds / 60);
return `${minutes}m`;
}
function enforceActionRateLimit(message, state, config) {
const userId = message?.member?.id;
if (!userId) {
return true;
}
const result = consumeRateLimit(state, config, "action", userId);
if (result.ok) {
return true;
}
const wait = formatCooldown(result.retryAfter);
message.reply(`Slow down a bit. Try again in ${wait}.`).catch(() => null);
return false;
}
function scheduleCleanup(channel, room, db, state, config) {
if (!channel || channel.members.size > 0) {
clearEmpty(state, channel?.id);
return;
}
const timeout = getLobbyTimeout(config, room.lobby_id);
markEmpty(state, channel.id);
clearCleanupTimer(state, channel.id);
const timer = setTimeout(async () => {
const refreshed = channel.guild.channels.cache.get(channel.id);
if (!refreshed || refreshed.members.size > 0) {
clearEmpty(state, channel.id);
return;
}
await deleteChannel(refreshed);
removeRoom(db, state, channel.id);
}, timeout * 1000);
state.cleanupTimers.set(channel.id, timer);
}
function clearCleanupTimer(state, channelId) {
const timer = state.cleanupTimers.get(channelId);
if (timer) {
clearTimeout(timer);
state.cleanupTimers.delete(channelId);
}
}
function markEmpty(state, channelId) {
if (!state.emptySince.has(channelId)) {
state.emptySince.set(channelId, Date.now());
}
}
function clearEmpty(state, channelId) {
state.emptySince.delete(channelId);
}
function getLobbyTimeout(config, lobbyId) {
const lobby = config.lobbies.find((item) => item.id === lobbyId);
return lobby ? lobby.emptyTimeoutSeconds : DEFAULT_TIMEOUT;
}
async function deleteChannel(channel) {
try {
await channel.delete("Auto VC cleanup");
} catch (error) {
console.error("Failed to delete Auto VC channel", error);
}
}
function handleMessage(message, db, settings, state) {
if (!message.guild || message.author.bot) {
return;
}
const config = getConfig(db);
const prefix = settings.getSetting("command_prefix", "!");
const content = message.content.trim();
if (!content.toLowerCase().startsWith(`${prefix}vc`)) {
return;
}
const tokens = content.slice(prefix.length).trim().split(/\s+/);
if (tokens[0].toLowerCase() !== "vc") {
return;
}
const command = (tokens[1] || "").toLowerCase();
const args = tokens.slice(2);
const member = message.member;
if (!member) {
return;
}
if (command === "ban" || command === "unban") {
handleBanCommands(message, command, args, db, settings);
return;
}
const voiceChannel = member.voice?.channel;
if (!voiceChannel) {
message.reply("You must be in an Auto VC to use that command.").catch(() => null);
return;
}
const room = state.rooms.get(voiceChannel.id) || getRoomById(db, voiceChannel.id, state);
if (!room) {
message.reply("That voice channel is not managed by Auto VC.").catch(() => null);
return;
}
const isAdminOrMod = checkIsModerator(member, settings);
const isOwner = room.owner_discord_id === member.id;
if (command === "claim") {
handleClaim(message, room, member, db, settings, state, config);
return;
}
if (!isOwner && !isAdminOrMod) {
message.reply("Only the room owner or moderators can use that command.").catch(() => null);
return;
}
switch (command) {
case "rename":
handleRename(message, room, args, db, settings, state, config);
break;
case "lock":
handleLock(message, room, db, settings, state, config);
break;
case "unlock":
handleUnlock(message, room, db, settings, state, config);
break;
case "allow":
handleAllow(message, room, args, db, settings, state, config);
break;
case "disallow":
handleDisallow(message, room, args, db, settings, state, config);
break;
case "transfer":
handleTransfer(message, room, args, db, settings, state, config);
break;
default:
message
.reply(
"Available commands: rename, lock, unlock, allow, disallow, transfer, claim."
)
.catch(() => null);
}
}
function handleBanCommands(message, command, args, db, settings) {
const member = message.member;
if (!member || !checkIsModerator(member, settings)) {
message.reply("Only moderators and admins can manage bans.").catch(() => null);
return;
}
const targetToken = args[0] || "";
const id = parseDiscordId(targetToken);
if (!id) {
message.reply("Provide a valid user ID or mention.").catch(() => null);
return;
}
if (command === "ban") {
banUser(db, id, "");
message.reply("User banned from Auto VC creation.").catch(() => null);
} else {
unbanUser(db, id);
message.reply("User unbanned from Auto VC creation.").catch(() => null);
}
}
async function handleRename(message, room, args, db, settings, state, config) {
const template = args.join(" ").trim();
if (!template) {
message.reply("Usage: !vc rename <new_name>").catch(() => null);
return;
}
const channel = message.guild.channels.cache.get(room.channel_id);
if (!channel) {
message.reply("Channel not found.").catch(() => null);
return;
}
if (!enforceActionRateLimit(message, state, config)) {
return;
}
const gameName = resolveGameName(message.member);
const name = buildRoomName(template, message.member, room.room_number, gameName);
await channel.setName(name).catch(() => null);
room.name_template = template;
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message.reply(`Renamed this room to "${name}".`).catch(() => null);
}
async function handleLock(message, room, db, settings, state, config) {
const channel = message.guild.channels.cache.get(room.channel_id);
if (!channel) {
message.reply("Channel not found.").catch(() => null);
return;
}
if (!enforceActionRateLimit(message, state, config)) {
return;
}
room.locked = !room.locked;
await applyRoomPermissions(channel, room, db, settings);
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message
.reply(room.locked ? "Room locked." : "Room unlocked.")
.catch(() => null);
}
async function handleUnlock(message, room, db, settings, state, config) {
const channel = message.guild.channels.cache.get(room.channel_id);
if (!channel) {
message.reply("Channel not found.").catch(() => null);
return;
}
if (!enforceActionRateLimit(message, state, config)) {
return;
}
if (!room.locked) {
message.reply("Room is already unlocked.").catch(() => null);
return;
}
room.locked = false;
await applyRoomPermissions(channel, room, db, settings);
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message.reply("Room unlocked.").catch(() => null);
}
async function handleAllow(message, room, args, db, settings, state, config) {
const token = args[0];
if (!token) {
message.reply("Usage: !vc allow <username>").catch(() => null);
return;
}
const target = await resolveMember(message.guild, token);
if (!target) {
message.reply("User not found.").catch(() => null);
return;
}
if (!enforceActionRateLimit(message, state, config)) {
return;
}
if (!room.allowed_user_ids.includes(target.id)) {
room.allowed_user_ids.push(target.id);
}
const channel = message.guild.channels.cache.get(room.channel_id);
if (channel) {
await applyRoomPermissions(channel, room, db, settings);
}
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message.reply(`Allowed ${target.displayName} to join.`).catch(() => null);
}
async function handleDisallow(message, room, args, db, settings, state, config) {
const token = (args[0] || "").toLowerCase();
if (!token) {
message.reply("Usage: !vc disallow <username|all>").catch(() => null);
return;
}
if (!enforceActionRateLimit(message, state, config)) {
return;
}
if (token === "all") {
room.allowed_user_ids = [];
} else {
const target = await resolveMember(message.guild, token);
if (!target) {
message.reply("User not found.").catch(() => null);
return;
}
room.allowed_user_ids = room.allowed_user_ids.filter((id) => id !== target.id);
}
const channel = message.guild.channels.cache.get(room.channel_id);
if (channel) {
await applyRoomPermissions(channel, room, db, settings);
}
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message.reply("Access updated.").catch(() => null);
}
async function handleTransfer(message, room, args, db, settings, state, config) {
const token = args[0];
if (!token) {
message.reply("Usage: !vc transfer <username>").catch(() => null);
return;
}
const target = await resolveMember(message.guild, token);
if (!target || !message.member.voice?.channel?.members?.has(target.id)) {
message.reply("Target must be in the voice channel.").catch(() => null);
return;
}
if (!enforceActionRateLimit(message, state, config)) {
return;
}
room.owner_discord_id = target.id;
const profile = ensureUserForIdentity({
provider: "discord",
providerUserId: target.id,
displayName: target.displayName
});
room.owner_user_id = profile.id;
const channel = message.guild.channels.cache.get(room.channel_id);
if (channel) {
await applyRoomPermissions(channel, room, db, settings);
}
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message.reply(`Ownership transferred to ${target.displayName}.`).catch(() => null);
}
async function handleClaim(message, room, member, db, settings, state, config) {
const channel = message.guild.channels.cache.get(room.channel_id);
if (!channel) {
message.reply("Channel not found.").catch(() => null);
return;
}
if (channel.members.has(room.owner_discord_id)) {
message.reply("The current owner is still here.").catch(() => null);
return;
}
if (!enforceActionRateLimit(message, state, config)) {
return;
}
room.owner_discord_id = member.id;
const profile = ensureUserForIdentity({
provider: "discord",
providerUserId: member.id,
displayName: member.displayName
});
room.owner_user_id = profile.id;
await applyRoomPermissions(channel, room, db, settings);
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
message.reply("You are now the owner of this room.").catch(() => null);
}
async function applyRoomPermissions(channel, room, db, settings) {
const base = parseJsonArray(room.base_overwrites);
const overrides = new Map();
for (const overwrite of base) {
overrides.set(overwrite.id, {
...overwrite,
allow: parseBitfield(overwrite.allow),
deny: parseBitfield(overwrite.deny)
});
}
overrides.set(room.owner_discord_id, {
id: room.owner_discord_id,
type: 1,
allow: ALLOW_CONNECT_VIEW,
deny: 0n
});
for (const userId of room.allowed_user_ids) {
overrides.set(userId, {
id: userId,
type: 1,
allow: ALLOW_CONNECT_VIEW,
deny: 0n
});
}
if (room.locked) {
const everyoneId = channel.guild.roles.everyone.id;
overrides.set(everyoneId, {
id: everyoneId,
type: 0,
allow: 0n,
deny: Permissions.FLAGS.CONNECT
});
const { adminRoleIds, modRoleIds } = getRoleIds(settings);
for (const roleId of [...adminRoleIds, ...modRoleIds]) {
overrides.set(roleId, {
id: roleId,
type: 0,
allow: ALLOW_CONNECT_VIEW,
deny: 0n
});
}
}
await channel.permissionOverwrites.set(Array.from(overrides.values())).catch(() => null);
}
function checkIsModerator(member, settings) {
const roles = member.roles.cache.map((role) => role.id);
const { adminRoleIds, modRoleIds } = getRoleIds(settings);
const isAdmin = roles.some((roleId) => adminRoleIds.includes(roleId));
const isMod = roles.some((roleId) => modRoleIds.includes(roleId));
return isAdmin || isMod;
}
function getRoleIds(settings) {
const adminRoleIds = parseRoleList(settings.getSetting("discord_admin_role_id"));
const modRoleIds = parseRoleList(settings.getSetting("discord_mod_role_id"));
return { adminRoleIds, modRoleIds };
}
function parseRoleList(value) {
return (value || "")
.toString()
.split(/[,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
async function resolveMember(guild, token) {
const id = parseDiscordId(token);
if (id) {
return guild.members.fetch(id).catch(() => null);
}
const needle = token.toLowerCase();
return guild.members.cache.find((member) => {
const name = (member.displayName || member.user.username || "").toLowerCase();
return name === needle;
});
}
function parseDiscordId(value) {
if (!value) {
return null;
}
const match = value.match(/^<@!?(\d+)>$/) || value.match(/^(\d{15,})$/);
return match ? match[1] : null;
}
function parseJsonArray(value) {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function serializeBitfield(value) {
if (typeof value === "bigint") {
return value.toString();
}
if (typeof value === "number") {
return String(value);
}
if (!value) {
return "0";
}
return value.toString();
}
function parseBitfield(value) {
if (typeof value === "bigint") {
return value;
}
if (typeof value === "number") {
return BigInt(value);
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return 0n;
}
try {
return BigInt(trimmed);
} catch {
return 0n;
}
}
return 0n;
}
function toArray(value) {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function cryptoRandomId() {
return require("crypto").randomUUID();
}
async function safeNotify(member, message) {
try {
await member.send(message);
} catch {
// ignore
}
}
async function moveMemberToChannel(member, channel) {
try {
await member.voice.setChannel(channel);
return true;
} catch (error) {
console.error("Failed to move member", error);
try {
await member.edit({ channel: channel.id });
return true;
} catch (fallbackError) {
console.error("Fallback move failed", fallbackError);
return false;
}
}
}
function startNameRefreshTimer(discordClient, db, settings, state) {
if (state.nameSweepTimer) {
clearInterval(state.nameSweepTimer);
}
const runSweep = () => {
refreshRoomNames(discordClient, db, settings, state).catch((error) => {
console.error("Auto VC name refresh failed", error);
});
};
runSweep();
state.nameSweepTimer = setInterval(runSweep, NAME_REFRESH_INTERVAL_MS);
}
async function refreshRoomNames(discordClient, db, settings, state) {
const rooms = db.prepare("SELECT * FROM auto_vc_rooms").all();
for (const row of rooms) {
const room = normalizeRoom(row);
if (!templateHasGameName(room.name_template)) {
continue;
}
const guild = discordClient.guilds.cache.get(room.guild_id);
if (!guild) {
continue;
}
const channel = await guild.channels.fetch(room.channel_id).catch(() => null);
if (!channel || channel.type !== "GUILD_VOICE") {
continue;
}
const member = await guild.members.fetch(room.owner_discord_id).catch(() => null);
if (!member) {
continue;
}
const gameName = resolveGameName(member);
const desiredName = buildRoomName(room.name_template, member, room.room_number, gameName);
if (desiredName && desiredName !== channel.name) {
await channel.setName(desiredName).catch((error) => {
console.error("Failed to update Auto VC name", error);
});
}
}
}
function startSweepTimer(discordClient, db, settings, state) {
if (state.sweepTimer) {
clearInterval(state.sweepTimer);
}
const runSweep = () => {
sweepRooms(discordClient, db, state).catch((error) => {
console.error("Auto VC sweep failed", error);
});
};
runSweep();
state.sweepTimer = setInterval(runSweep, 15000);
}
async function sweepRooms(discordClient, db, state) {
const config = getConfig(db);
const rooms = db.prepare("SELECT * FROM auto_vc_rooms").all();
for (const row of rooms) {
const room = normalizeRoom(row);
state.rooms.set(room.channel_id, room);
const guild = discordClient.guilds.cache.get(room.guild_id);
if (!guild) {
removeRoom(db, state, room.channel_id);
continue;
}
const channel = await guild.channels.fetch(room.channel_id).catch(() => null);
if (!channel || channel.type !== "GUILD_VOICE") {
removeRoom(db, state, room.channel_id);
continue;
}
if (channel.members.size === 0) {
markEmpty(state, channel.id);
const timeout = getLobbyTimeout(config, room.lobby_id);
const emptyAt = state.emptySince.get(channel.id) || Date.now();
if (Date.now() - emptyAt >= timeout * 1000) {
await deleteChannel(channel);
removeRoom(db, state, channel.id);
}
} else {
clearCleanupTimer(state, channel.id);
clearEmpty(state, channel.id);
}
}
}