1457 lines
43 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|