1832 lines
58 KiB
JavaScript
1832 lines
58 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 { 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 ? "✓" : "Couldn’t 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);
|
||
}
|
||
}
|
||
}
|