Lumi/plugins/auto-vc/index.js

1832 lines
58 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.

const path = require("path");
const fs = require("fs");
const ejs = require("ejs");
const { Permissions } = require("discord.js");
const { ensureUserForIdentity } = require("../../src/services/users");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("plugin:auto-vc", { category: "plugin" });
const PLUGIN_ID = "auto-vc";
const DEFAULT_TEMPLATE = "[username]'s room";
const DEFAULT_TIMEOUT = 30;
const DEFAULT_ROOM_MESSAGE = "Welcome {{plugins.auto_vc.channel.owner_username}}! Use 🔒 to lock this room or 🔓 to unlock it. If the owner is away long enough, 👑 appears so someone in the room can claim it.";
const ROOM_MESSAGE_FIELD_ID = "plugins.auto_vc.room_message";
const LOCK_EMOJI = "🔒";
const UNLOCK_EMOJI = "🔓";
const CLAIM_EMOJI = "👑";
const REPORT_LIFETIME_MS = 30 * 1000;
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, placeholders }) {
ensureTables(db);
registerPlaceholderSupport(placeholders);
const state = {
rooms: new Map(),
cleanupTimers: new Map(),
claimTimers: new Map(),
emptySince: new Map(),
reactionActions: new Map(),
placeholders,
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);
});
const saveSettings = (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);
for (const channelId of state.claimTimers.keys()) clearClaimTimer(state, channelId);
state.rooms.clear();
req.session.flash = {
type: "success",
message: "Auto VC settings saved."
};
res.redirect("/plugins/auto-vc");
};
router.post("/settings", saveSettings);
router.post("/settings/remove", saveSettings);
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) => {
logger.error("Auto VC bootstrap failed", error, { event: "bootstrap_failed" });
});
discordClient.on("voiceStateUpdate", (oldState, newState) => {
handleVoiceStateUpdate(oldState, newState, db, settings, state);
});
discordClient.on("messageCreate", (message) => {
handleMessage(message, db, settings, state);
});
discordClient.on("messageReactionAdd", (reaction, user) => {
queueControlReaction(reaction, user, 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",
"Post room controls",
"Manage room reactions"
];
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)
);
const canSendMessages = Boolean(
categoryPerms?.has(Permissions.FLAGS.SEND_MESSAGES) ||
guildPerms?.has(Permissions.FLAGS.SEND_MESSAGES)
);
const canAddReactions = Boolean(
categoryPerms?.has(Permissions.FLAGS.ADD_REACTIONS) ||
guildPerms?.has(Permissions.FLAGS.ADD_REACTIONS)
);
const canReadHistory = Boolean(
categoryPerms?.has(Permissions.FLAGS.READ_MESSAGE_HISTORY) ||
guildPerms?.has(Permissions.FLAGS.READ_MESSAGE_HISTORY)
);
const canManageMessages = Boolean(
categoryPerms?.has(Permissions.FLAGS.MANAGE_MESSAGES) ||
guildPerms?.has(Permissions.FLAGS.MANAGE_MESSAGES)
);
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."
),
buildPermissionCheck(
"Post room controls",
canSendMessages && canAddReactions && canReadHistory,
"Allow Send Messages, Add Reactions, and Read Message History on the target category."
),
buildPermissionCheck(
"Manage room reactions",
canManageMessages,
"Allow Manage Messages on the target category so reaction controls remain reusable."
)
];
}
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,
control_message_id TEXT,
owner_absent_since INTEGER,
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
);
`);
ensureColumn(db, "auto_vc_rooms", "control_message_id", "TEXT");
ensureColumn(db, "auto_vc_rooms", "owner_absent_since", "INTEGER");
}
function ensureColumn(db, table, column, definition) {
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
if (!columns.some((entry) => entry.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
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(),
roomMessage: (lobby?.roomMessage ?? DEFAULT_ROOM_MESSAGE).toString().slice(0, 2000),
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 roomMessages = toArray(body.lobby_room_message);
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,
roomMessage: roomMessages[index] ?? DEFAULT_ROOM_MESSAGE,
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 config = getConfig(db);
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;
}
const normalized = normalizeRoom(room);
state.rooms.set(room.channel_id, normalized);
await syncOwnerAbsence(channel, normalized, db, state, config);
}
}
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,
control_message_id: room.control_message_id || null,
owner_absent_since: Number(room.owner_absent_since) || null,
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) => {
logger.error("Auto VC creation failed", error, { event: "room_creation_failed" });
});
}
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);
syncOwnerAbsence(channel, room, db, state, config).catch((error) => {
logger.warn("Auto VC owner absence update failed", error, { event: "owner_absence_sync_failed" });
});
}
}
}
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);
const channel = newState.guild.channels.cache.get(newState.channelId);
if (channel) {
syncOwnerAbsence(channel, room, db, state, config).catch((error) => {
logger.warn("Auto VC owner absence update failed", error, { event: "owner_absence_sync_failed" });
});
}
}
}
}
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),
control_message_id: null,
owner_absent_since: null,
created_at: Date.now()
};
saveRoom(db, room);
const normalizedRoom = normalizeRoom(room);
state.rooms.set(channel.id, normalizedRoom);
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."
);
}
await createRoomControlMessage(channel, normalizedRoom, lobby, member, db, state);
}
function registerPlaceholderSupport(placeholders) {
if (!placeholders?.registerFieldPolicy || !placeholders?.registerPlaceholders) return;
placeholders.registerFieldPolicy({
field_id: ROOM_MESSAGE_FIELD_ID,
label: "Auto VC room message",
field_type: "chat_message",
output_audience: "public",
min_editor_role: "admin",
allowed_namespaces: ["plugins.auto_vc.channel"],
max_sensitivity: "public_safe"
});
placeholders.registerPlaceholders([{
id: "plugins.auto_vc.channel.owner_username",
namespace: "plugins.auto_vc.channel",
label: "Room owner username",
description: "The display name of the member who created the Auto VC.",
value_type: "string",
sensitivity: "public_safe",
min_editor_role: "admin",
min_viewer_role: "public",
allowed_field_types: ["chat_message"],
plugin_id: PLUGIN_ID,
resolver: ({ runtimeContext }) => runtimeContext?.autoVc?.owner_username || ""
}]);
}
async function renderRoomMessage(lobby, member, placeholders) {
const ownerUsername = member?.displayName || member?.user?.username || "Room owner";
const template = String(lobby?.roomMessage || DEFAULT_ROOM_MESSAGE).slice(0, 2000);
if (!placeholders?.renderTemplate) {
return template.replace(/\{\{\s*plugins\.auto_vc\.channel\.owner_username\s*\}\}/gi, ownerUsername).slice(0, 2000);
}
const result = await placeholders.renderTemplate({
fieldId: ROOM_MESSAGE_FIELD_ID,
template,
user: { isAdmin: true },
outputAudience: "public",
runtimeContext: { runtime: true, autoVc: { owner_username: ownerUsername } },
fallback: "[unavailable]"
});
if (!result.ok) {
logger.warn("Auto VC room message contained unavailable placeholders", {
error_count: result.errors?.length || 0
}, { event: "room_message_placeholder_failed" });
}
return String(result.rendered || DEFAULT_ROOM_MESSAGE).trim().slice(0, 2000);
}
async function createRoomControlMessage(channel, room, lobby, member, db, state) {
if (typeof channel?.send !== "function") {
logger.warn("Created Auto VC does not support room messages", {
channel_id: channel?.id || null
}, { event: "room_controls_unavailable" });
return null;
}
let message = null;
try {
const content = await renderRoomMessage(lobby, member, state.placeholders);
message = await channel.send(content || DEFAULT_ROOM_MESSAGE);
await message.react(LOCK_EMOJI);
await message.react(UNLOCK_EMOJI);
room.control_message_id = message.id;
room.owner_absent_since = null;
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
return message;
} catch (error) {
if (message) await message.delete().catch(() => null);
logger.warn("Failed to create Auto VC room controls", error, {
event: "room_controls_create_failed",
channel_id: channel?.id || null
});
return null;
}
}
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, control_message_id, owner_absent_since, 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, control_message_id = excluded.control_message_id, owner_absent_since = excluded.owner_absent_since"
).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.control_message_id || null,
room.owner_absent_since || null,
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 = ?, control_message_id = ?, owner_absent_since = ? 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.control_message_id || null,
room.owner_absent_since || null,
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);
clearClaimTimer(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;
}
function clearClaimTimer(state, channelId) {
const timer = state.claimTimers.get(channelId);
if (timer) {
clearTimeout(timer);
state.claimTimers.delete(channelId);
}
}
function hasClaimCandidate(channel, room) {
return [...(channel?.members?.values?.() || [])].some((member) =>
!member?.user?.bot && member.id !== room.owner_discord_id
);
}
async function fetchControlMessage(channel, room) {
if (!room.control_message_id || typeof channel?.messages?.fetch !== "function") return null;
return channel.messages.fetch(room.control_message_id).catch(() => null);
}
async function setClaimReaction(channel, room, available) {
const message = await fetchControlMessage(channel, room);
if (!message) return;
const reaction = message.reactions?.cache?.find((entry) => entry.emoji?.name === CLAIM_EMOJI);
if (available) {
if (!reaction?.me) await message.react(CLAIM_EMOJI);
return;
}
if (reaction?.me && message.client?.user?.id) {
await reaction.users.remove(message.client.user.id).catch(() => null);
}
}
async function syncOwnerAbsence(channel, room, db, state, config) {
clearClaimTimer(state, channel.id);
const ownerPresent = Boolean(channel.members?.has(room.owner_discord_id));
const claimCandidatePresent = hasClaimCandidate(channel, room);
if (ownerPresent || !claimCandidatePresent) {
if (room.owner_absent_since) {
room.owner_absent_since = null;
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
}
await setClaimReaction(channel, room, false);
return;
}
if (!room.owner_absent_since) {
room.owner_absent_since = Date.now();
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
}
const timeoutMs = getLobbyTimeout(config, room.lobby_id) * 1000;
const remaining = timeoutMs - (Date.now() - room.owner_absent_since);
if (remaining <= 0) {
await setClaimReaction(channel, room, true);
return;
}
await setClaimReaction(channel, room, false);
const timer = setTimeout(() => {
state.claimTimers.delete(channel.id);
const refreshedRoom = state.rooms.get(channel.id) || getRoomById(db, channel.id, state);
const refreshedChannel = channel.guild?.channels?.cache?.get(channel.id);
if (!refreshedRoom || !refreshedChannel) return;
syncOwnerAbsence(refreshedChannel, refreshedRoom, db, state, getConfig(db)).catch((error) => {
logger.warn("Auto VC claim reaction update failed", error, { event: "claim_reaction_update_failed" });
});
}, remaining);
timer.unref?.();
state.claimTimers.set(channel.id, timer);
}
function queueControlReaction(reaction, user, db, settings, state) {
if (!reaction?.message?.id || user?.bot) return;
const key = reaction.message.id;
const previous = state.reactionActions.get(key) || Promise.resolve();
const current = previous
.catch(() => null)
.then(() => handleControlReaction(reaction, user, db, settings, state))
.catch((error) => {
logger.warn("Auto VC reaction action failed", error, { event: "room_reaction_failed" });
})
.finally(() => {
if (state.reactionActions.get(key) === current) state.reactionActions.delete(key);
});
state.reactionActions.set(key, current);
}
async function handleControlReaction(reaction, user, db, settings, state) {
if (reaction.partial && typeof reaction.fetch === "function") {
reaction = await reaction.fetch().catch(() => null);
}
if (!reaction?.message) return;
let message = reaction.message;
if (message.partial && typeof message.fetch === "function") {
message = await message.fetch().catch(() => null);
}
const emoji = reaction.emoji?.name;
if (!message || ![LOCK_EMOJI, UNLOCK_EMOJI, CLAIM_EMOJI].includes(emoji)) return;
const channel = message.channel;
const room = state.rooms.get(channel?.id) || getRoomById(db, channel?.id, state);
if (!room || room.control_message_id !== message.id) return;
try {
const member = await message.guild?.members?.fetch(user.id).catch(() => null);
let result;
if (!member) {
result = { ok: false, message: "Could not verify that member." };
} else {
const rateLimit = consumeRateLimit(state, getConfig(db), "action", member.id);
if (!rateLimit.ok) {
result = { ok: false, message: `Try again in ${formatCooldown(rateLimit.retryAfter)}.` };
} else if (emoji === LOCK_EMOJI || emoji === UNLOCK_EMOJI) {
if (member.id !== room.owner_discord_id) {
result = { ok: false, message: "Only the room owner can use that control." };
} else {
const locked = emoji === LOCK_EMOJI;
const applied = await setRoomLocked(channel, room, locked, db, settings, state);
result = applied
? { ok: true, message: locked ? "Room locked." : "Room unlocked." }
: { ok: false, message: "Discord did not allow the room permission change." };
}
} else {
const eligibility = claimEligibility(channel, room, member, getConfig(db), Date.now());
if (!eligibility.ok) {
result = eligibility;
} else {
const claimed = await claimRoomOwnership(channel, room, member, db, settings, state);
if (claimed) {
await syncOwnerAbsence(channel, room, db, state, getConfig(db));
result = { ok: true, message: `${member.displayName} now owns this room.` };
} else {
result = { ok: false, message: "Discord did not allow the ownership change." };
}
}
}
}
await sendTemporaryReport(channel, `${result.ok ? "✓" : "Couldnt do that:"} ${result.message}`);
} finally {
await reaction.users?.remove(user.id).catch(() => null);
}
}
function claimEligibility(channel, room, member, config, now = Date.now()) {
if (member.id === room.owner_discord_id) return { ok: false, message: "You already own this room." };
if (!channel.members?.has(member.id)) return { ok: false, message: "Join this voice room before claiming it." };
if (channel.members?.has(room.owner_discord_id)) return { ok: false, message: "The current owner is still here." };
const absentSince = Number(room.owner_absent_since) || now;
const remaining = getLobbyTimeout(config, room.lobby_id) * 1000 - (now - absentSince);
if (remaining > 0) return { ok: false, message: `The room can be claimed in ${formatCooldown(Math.ceil(remaining / 1000))}.` };
return { ok: true };
}
async function sendTemporaryReport(channel, content) {
if (typeof channel?.send !== "function") return;
const report = await channel.send(String(content).slice(0, 500)).catch(() => null);
if (!report) return;
const timer = setTimeout(() => {
report.delete().catch(() => null);
}, REPORT_LIFETIME_MS);
timer.unref?.();
}
async function deleteChannel(channel) {
try {
await channel.delete("Auto VC cleanup");
} catch (error) {
logger.error("Failed to delete Auto VC channel", error, { event: "channel_delete_failed" });
}
}
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;
}
if (room.locked) {
message.reply("Room is already locked.").catch(() => null);
return;
}
const applied = await setRoomLocked(channel, room, true, db, settings, state);
message.reply(applied ? "Room locked." : "I couldn't update this room's permissions.").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;
}
const applied = await setRoomLocked(channel, room, false, db, settings, state);
message.reply(applied ? "Room unlocked." : "I couldn't update this room's permissions.").catch(() => null);
}
async function setRoomLocked(channel, room, locked, db, settings, state) {
const previous = room.locked;
room.locked = Boolean(locked);
if (!await applyRoomPermissions(channel, room, db, settings)) {
room.locked = previous;
return false;
}
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
return true;
}
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;
room.owner_absent_since = null;
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);
clearClaimTimer(state, room.channel_id);
if (channel) await setClaimReaction(channel, room, false);
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;
}
const claimed = await claimRoomOwnership(channel, room, member, db, settings, state);
message.reply(claimed ? "You are now the owner of this room." : "I couldn't update this room's permissions.").catch(() => null);
}
async function claimRoomOwnership(channel, room, member, db, settings, state) {
const previousOwnerDiscordId = room.owner_discord_id;
const previousOwnerUserId = room.owner_user_id;
const previousOwnerAbsentSince = room.owner_absent_since;
room.owner_discord_id = member.id;
const profile = ensureUserForIdentity({
provider: "discord",
providerUserId: member.id,
displayName: member.displayName
});
room.owner_user_id = profile.id;
room.owner_absent_since = null;
if (!await applyRoomPermissions(channel, room, db, settings)) {
room.owner_discord_id = previousOwnerDiscordId;
room.owner_user_id = previousOwnerUserId;
room.owner_absent_since = previousOwnerAbsentSince;
return false;
}
updateRoom(db, room);
state.rooms.set(room.channel_id, room);
clearClaimTimer(state, room.channel_id);
await setClaimReaction(channel, room, false);
return true;
}
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
});
}
}
try {
await channel.permissionOverwrites.set(Array.from(overrides.values()));
return true;
} catch (error) {
logger.warn("Failed to update Auto VC permissions", error, {
event: "room_permissions_update_failed",
channel_id: channel?.id || null
});
return false;
}
}
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();
}
module.exports._test = {
CLAIM_EMOJI,
DEFAULT_ROOM_MESSAGE,
LOCK_EMOJI,
UNLOCK_EMOJI,
claimEligibility,
ensureTables,
normalizeConfig,
normalizeLobby,
parseConfigFromForm,
registerPlaceholderSupport,
renderRoomMessage,
setRoomLocked
};
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) {
logger.error("Failed to move member", error, { event: "member_move_failed" });
try {
await member.edit({ channel: channel.id });
return true;
} catch (fallbackError) {
logger.error("Fallback move failed", fallbackError, { event: "member_move_fallback_failed" });
return false;
}
}
}
function startNameRefreshTimer(discordClient, db, settings, state) {
if (state.nameSweepTimer) {
clearInterval(state.nameSweepTimer);
}
const runSweep = () => {
refreshRoomNames(discordClient, db, settings, state).catch((error) => {
logger.error("Auto VC name refresh failed", error, { event: "name_refresh_failed" });
});
};
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) => {
logger.error("Failed to update Auto VC name", error, { event: "channel_rename_failed" });
});
}
}
}
function startSweepTimer(discordClient, db, settings, state) {
if (state.sweepTimer) {
clearInterval(state.sweepTimer);
}
const runSweep = () => {
sweepRooms(discordClient, db, state).catch((error) => {
logger.error("Auto VC sweep failed", error, { event: "room_sweep_failed" });
});
};
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;
}
await syncOwnerAbsence(channel, room, db, state, config);
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);
}
}
}