const path = require("path"); const fs = require("fs"); const crypto = require("crypto"); const multer = require("multer"); const PLUGIN_ID = "moderation"; const EVIDENCE_DIR = path.join(__dirname, "..", "..", "data", "moderation", "evidence"); const PRESET_DURATIONS = [ { label: "1 hour", seconds: 60 * 60 }, { label: "3 hours", seconds: 3 * 60 * 60 }, { label: "6 hours", seconds: 6 * 60 * 60 }, { label: "12 hours", seconds: 12 * 60 * 60 }, { label: "1 day", seconds: 24 * 60 * 60 }, { label: "7 days", seconds: 7 * 24 * 60 * 60 }, { label: "14 days", seconds: 14 * 24 * 60 * 60 }, { label: "1 month", seconds: 30 * 24 * 60 * 60 }, { label: "3 months", seconds: 90 * 24 * 60 * 60 }, { label: "6 months", seconds: 180 * 24 * 60 * 60 }, { label: "9 months", seconds: 270 * 24 * 60 * 60 }, { label: "1 year", seconds: 365 * 24 * 60 * 60 } ]; module.exports = { id: PLUGIN_ID, init({ app, web, db, settings, discordClient, twitchClient, youtubeClient }) { ensureTables(db); ensureBanPot(db); ensureEvidenceDir(); const upload = multer({ storage: multer.diskStorage({ destination: (_req, _file, cb) => cb(null, EVIDENCE_DIR), filename: (_req, file, cb) => { const ext = path.extname(file.originalname || ".png").slice(0, 10); cb(null, `${crypto.randomUUID()}${ext}`); } }) }); installGlobalGate(app, (req, res, next) => { if (!req.session?.user) { return next(); } if (req.path.startsWith("/auth")) { return next(); } if (req.path.startsWith("/moderation/status")) { return next(); } linkSubjectToUser(db, req.session.user.id); const sanction = getActiveSanctionForUser(db, req.session.user.id); if (!sanction) { return next(); } res.status(403).render(path.join(__dirname, "views", "status.ejs"), { title: "Account restricted", sanction }); }); const router = web.createRouter(); router.get("/status", (req, res) => { if (!req.session?.user) { return res.redirect("/"); } linkSubjectToUser(db, req.session.user.id); const sanction = getActiveSanctionForUser(db, req.session.user.id); if (!sanction) { return res.redirect("/"); } res.status(403).render(path.join(__dirname, "views", "status.ejs"), { title: "Account restricted", sanction }); }); router.get("/", (req, res) => { if (!req.session.user) { return res.redirect("/"); } const isAdmin = Boolean(req.session.user?.isAdmin); const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); const userDirectory = listUserDirectory(db); const actions = listActions(db, { limit: 500 }); const actionEvidence = listEvidenceForActions( db, actions.map((action) => action.id) ); const notes = listNotes(db, { limit: 1000 }); const activeSanctions = listActiveSanctions(db); const banPot = getBanPot(db); res.render(path.join(__dirname, "views", "moderation.ejs"), { title: "Moderation Center", isAdmin, isMod, userDirectory, actions, actionEvidence, notes, activeSanctions, banPot, presets: PRESET_DURATIONS }); }); router.get("/tos-bans", (req, res) => { if (!req.session.user) { return res.redirect("/"); } const isAdmin = Boolean(req.session.user?.isAdmin); const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); if (!isMod) { return deny(res); } const actions = listActions(db, { limit: 500 }); const actionEvidence = listEvidenceForActions( db, actions.map((action) => action.id) ); const activeSanctions = listActiveSanctions(db); res.render(path.join(__dirname, "views", "tos-bans.ejs"), { title: "TOs & Bans", isAdmin, isMod, actions, actionEvidence, activeSanctions, presets: PRESET_DURATIONS }); }); router.get("/evidence/:id", (req, res) => { if (!req.session.user) { return res.redirect("/"); } const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); if (!isMod) { return deny(res); } const row = db .prepare("SELECT file_path, file_name FROM moderation_evidence WHERE id = ?") .get(req.params.id); if (!row?.file_path) { return res.status(404).render("error", { title: "Not found", message: "Evidence file not found." }); } res.download(row.file_path, row.file_name || path.basename(row.file_path)); }); router.post("/actions", upload.array("evidence_files", 4), async (req, res) => { if (!req.session.user) { return res.redirect("/"); } const isAdmin = Boolean(req.session.user?.isAdmin); const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); if (!isMod) { return deny(res); } const actionType = (req.body.action_type || "").toLowerCase(); if (actionType === "kick") { req.session.flash = { type: "info", message: "Kick actions are coming soon." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const target = resolveTarget(db, req.body); if (!target) { req.session.flash = { type: "error", message: "Target not found." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const reasonShort = (req.body.reason_short || "").trim(); const reasonDetail = (req.body.reason_detail || "").trim(); if (!reasonShort || !reasonDetail) { req.session.flash = { type: "error", message: "Both summary and detailed reasons are required." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const durationSeconds = actionType === "timeout" ? buildDurationSeconds(req.body, isAdmin, isMod) : null; const createdBy = req.session.user.username || "Moderator"; const createdById = req.session.user.id; const action = createAction(db, { subjectId: target.subjectId, actionType, scope: "global", platform: "global", reasonShort, reasonDetail, durationSeconds, createdById, createdByName: createdBy, source: "manual" }); const evidenceFiles = (req.files || []).map((file) => ({ path: file.path, name: file.originalname })); evidenceFiles.forEach((file) => { addEvidence(db, action.id, file.path, file.name, createdById); }); const identities = listSubjectIdentities(db, target.subjectId); await enforceAction({ action, identities, settings, discordClient, twitchClient, youtubeClient, reasonShort, reasonDetail }); if (actionType === "ban") { distributeBanAssets(db, target.subjectId, { reason: reasonShort }); } req.session.flash = { type: "success", message: "Moderation action recorded." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/actions/:id/update-timeout", (req, res) => { if (!req.session.user) { return res.redirect("/"); } const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); if (!isMod) { return deny(res); } const action = getAction(db, req.params.id); if (!action || action.action_type !== "timeout") { req.session.flash = { type: "error", message: "Timeout not found." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const durationSeconds = buildDurationSeconds(req.body, req.session.user?.isAdmin, true); const expiresAt = durationSeconds ? Date.now() + durationSeconds * 1000 : null; updateActionDuration(db, action.id, durationSeconds, expiresAt); req.session.flash = { type: "success", message: "Timeout updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/actions/:id/revoke", async (req, res) => { if (!req.session.user) { return res.redirect("/"); } const action = getAction(db, req.params.id); if (!action) { req.session.flash = { type: "error", message: "Action not found." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const isAdmin = Boolean(req.session.user?.isAdmin); const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); if (action.action_type === "ban" && !isAdmin) { return deny(res); } if (action.action_type === "timeout" && !isMod) { return deny(res); } setActionStatus(db, action.id, "revoked"); const identities = listSubjectIdentities(db, action.subject_id); await revokeAction({ action, identities, settings, discordClient, twitchClient, youtubeClient }); req.session.flash = { type: "success", message: "Action revoked." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/notes", (req, res) => { if (!req.session.user) { return res.redirect("/"); } const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); if (!isMod) { return deny(res); } const target = resolveTarget(db, req.body); const note = (req.body.note || "").trim(); if (!target || !note) { req.session.flash = { type: "error", message: "Target and note are required." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } addNote(db, target.subjectId, note, req.session.user.id, req.session.user.username || "Moderator"); req.session.flash = { type: "success", message: "Note added." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); web.mount(`/plugins/${PLUGIN_ID}`, router, { label: "Moderation", role: "mod", section: "moderation" }); web.addNavItem({ label: "TOs & Bans", path: `/plugins/${PLUGIN_ID}/tos-bans`, role: "mod", section: "moderation" }); if (discordClient) { startDiscordAuditPolling(db, settings, discordClient); } if (twitchClient) { attachTwitchModerationEvents(db, twitchClient); } installFreezeHook(db); } }; function deny(res) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } function ensureEvidenceDir() { fs.mkdirSync(EVIDENCE_DIR, { recursive: true }); } function ensureTables(db) { db.exec(` CREATE TABLE IF NOT EXISTS moderation_subjects ( id TEXT PRIMARY KEY, internal_user_id TEXT, display_name TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS moderation_identities ( id TEXT PRIMARY KEY, subject_id TEXT NOT NULL, platform TEXT NOT NULL, platform_user_id TEXT, platform_username TEXT, created_at INTEGER NOT NULL, UNIQUE(platform, platform_user_id) ); CREATE TABLE IF NOT EXISTS moderation_actions ( id TEXT PRIMARY KEY, subject_id TEXT NOT NULL, action_type TEXT NOT NULL, scope TEXT NOT NULL, platform TEXT, source TEXT, status TEXT NOT NULL, duration_seconds INTEGER, reason_short TEXT NOT NULL, reason_detail TEXT NOT NULL, created_by_user_id TEXT, created_by_name TEXT, created_at INTEGER NOT NULL, expires_at INTEGER, external_ref TEXT ); CREATE TABLE IF NOT EXISTS moderation_evidence ( id TEXT PRIMARY KEY, action_id TEXT NOT NULL, file_path TEXT NOT NULL, file_name TEXT, uploaded_by TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS moderation_notes ( id TEXT PRIMARY KEY, subject_id TEXT NOT NULL, note TEXT NOT NULL, created_by_user_id TEXT, created_by_name TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS moderation_ban_pot ( id INTEGER PRIMARY KEY CHECK (id = 1), balance INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ); `); } function ensureBanPot(db) { const existing = db.prepare("SELECT id FROM moderation_ban_pot WHERE id = 1").get(); if (!existing) { db.prepare("INSERT INTO moderation_ban_pot (id, balance, updated_at) VALUES (1, 0, ?)") .run(Date.now()); } } function listUserDirectory(db) { const users = db.prepare("SELECT id, internal_username FROM user_profiles ORDER BY internal_username").all(); const identities = db .prepare("SELECT user_id, provider, provider_user_id, display_name FROM user_identities") .all(); const map = new Map(); users.forEach((user) => { map.set(user.id, { id: user.id, internal: user.internal_username, identities: [] }); }); identities.forEach((row) => { if (!map.has(row.user_id)) { map.set(row.user_id, { id: row.user_id, internal: row.user_id, identities: [] }); } map.get(row.user_id).identities.push({ label: row.provider, id: row.provider_user_id, display: row.display_name || row.provider_user_id }); }); return Array.from(map.values()); } function resolveTarget(db, body) { const internal = (body.target_username || "").trim(); if (internal) { const user = db .prepare("SELECT id, internal_username FROM user_profiles WHERE internal_username = ?") .get(internal); if (user) { const subjectId = getOrCreateSubjectByUser(db, user.id, user.internal_username); syncSubjectIdentities(db, subjectId, user.id); return { subjectId, internalUserId: user.id }; } } const platform = (body.target_platform || "").trim().toLowerCase(); const platformId = (body.target_platform_id || "").trim(); const platformUsername = (body.target_platform_username || "").trim(); if (!platform) { return null; } const key = platformId || platformUsername; if (!key) { return null; } const subjectId = getOrCreateSubjectByIdentity( db, platform, key, platformUsername || platformId ); return { subjectId, internalUserId: null }; } function getOrCreateSubjectByUser(db, userId, displayName) { const existing = db .prepare("SELECT id FROM moderation_subjects WHERE internal_user_id = ?") .get(userId); if (existing) { return existing.id; } const id = crypto.randomUUID(); db.prepare( "INSERT INTO moderation_subjects (id, internal_user_id, display_name, created_at) VALUES (?, ?, ?, ?)" ).run(id, userId, displayName || null, Date.now()); return id; } function getOrCreateSubjectByIdentity(db, platform, platformUserId, platformUsername) { const existing = db .prepare("SELECT subject_id FROM moderation_identities WHERE platform = ? AND platform_user_id = ?") .get(platform, platformUserId); if (existing) { return existing.subject_id; } const subjectId = crypto.randomUUID(); db.prepare( "INSERT INTO moderation_subjects (id, internal_user_id, display_name, created_at) VALUES (?, NULL, ?, ?)" ).run(subjectId, platformUsername || platformUserId, Date.now()); db.prepare( "INSERT INTO moderation_identities (id, subject_id, platform, platform_user_id, platform_username, created_at) VALUES (?, ?, ?, ?, ?, ?)" ).run(crypto.randomUUID(), subjectId, platform, platformUserId, platformUsername || null, Date.now()); return subjectId; } function syncSubjectIdentities(db, subjectId, userId) { const identities = db .prepare("SELECT provider, provider_user_id, display_name FROM user_identities WHERE user_id = ?") .all(userId); identities.forEach((identity) => { const existing = db .prepare( "SELECT id FROM moderation_identities WHERE platform = ? AND platform_user_id = ?" ) .get(identity.provider, identity.provider_user_id); if (existing) { return; } db.prepare( "INSERT INTO moderation_identities (id, subject_id, platform, platform_user_id, platform_username, created_at) VALUES (?, ?, ?, ?, ?, ?)" ).run( crypto.randomUUID(), subjectId, identity.provider, identity.provider_user_id, identity.display_name || identity.provider_user_id, Date.now() ); }); } function linkSubjectToUser(db, userId) { const subject = db .prepare("SELECT id FROM moderation_subjects WHERE internal_user_id = ?") .get(userId); if (subject) { return subject.id; } const identities = db .prepare("SELECT provider, provider_user_id, display_name FROM user_identities WHERE user_id = ?") .all(userId); for (const identity of identities) { const existing = db .prepare( "SELECT subject_id FROM moderation_identities WHERE platform = ? AND platform_user_id = ?" ) .get(identity.provider, identity.provider_user_id); if (existing) { db.prepare( "UPDATE moderation_subjects SET internal_user_id = ?, display_name = COALESCE(display_name, ?) WHERE id = ?" ).run(userId, identity.display_name || identity.provider_user_id, existing.subject_id); return existing.subject_id; } } return null; } function listSubjectIdentities(db, subjectId) { return db .prepare( "SELECT platform, platform_user_id, platform_username FROM moderation_identities WHERE subject_id = ?" ) .all(subjectId); } function buildDurationSeconds(body, isAdmin, isMod) { if (body.permanent === "on") { return null; } if (isAdmin && body.duration_value) { const value = Number(body.duration_value); const unit = (body.duration_unit || "hours").toLowerCase(); if (Number.isFinite(value) && value > 0) { const multipliers = { hour: 3600, hours: 3600, day: 86400, days: 86400, week: 604800, weeks: 604800, month: 2592000, months: 2592000, year: 31536000, years: 31536000 }; const multiplier = multipliers[unit] || 3600; return Math.floor(value * multiplier); } } if (isMod && body.duration_preset) { const preset = PRESET_DURATIONS.find( (entry) => entry.seconds.toString() === body.duration_preset.toString() ); return preset ? preset.seconds : null; } return null; } function createAction(db, payload) { if (payload.externalRef) { const existing = db .prepare("SELECT id FROM moderation_actions WHERE external_ref = ?") .get(payload.externalRef); if (existing) { return getAction(db, existing.id); } } const now = Date.now(); const expiresAt = payload.durationSeconds ? now + payload.durationSeconds * 1000 : null; const action = { id: crypto.randomUUID(), subject_id: payload.subjectId, action_type: payload.actionType, scope: payload.scope || "global", platform: payload.platform || "global", source: payload.source || "manual", status: "active", duration_seconds: payload.durationSeconds || null, reason_short: payload.reasonShort, reason_detail: payload.reasonDetail, created_by_user_id: payload.createdById || null, created_by_name: payload.createdByName || null, created_at: now, expires_at: expiresAt, external_ref: payload.externalRef || null }; db.prepare( "INSERT INTO moderation_actions (id, subject_id, action_type, scope, platform, source, status, duration_seconds, reason_short, reason_detail, created_by_user_id, created_by_name, created_at, expires_at, external_ref) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ).run( action.id, action.subject_id, action.action_type, action.scope, action.platform, action.source, action.status, action.duration_seconds, action.reason_short, action.reason_detail, action.created_by_user_id, action.created_by_name, action.created_at, action.expires_at, action.external_ref ); return action; } function addEvidence(db, actionId, filePath, fileName, uploadedBy) { db.prepare( "INSERT INTO moderation_evidence (id, action_id, file_path, file_name, uploaded_by, created_at) VALUES (?, ?, ?, ?, ?, ?)" ).run(crypto.randomUUID(), actionId, filePath, fileName, uploadedBy, Date.now()); } function addNote(db, subjectId, note, createdById, createdByName) { db.prepare( "INSERT INTO moderation_notes (id, subject_id, note, created_by_user_id, created_by_name, created_at) VALUES (?, ?, ?, ?, ?, ?)" ).run(crypto.randomUUID(), subjectId, note, createdById, createdByName, Date.now()); } function listActions(db, { limit = 200 } = {}) { return db .prepare( "SELECT a.*, s.display_name FROM moderation_actions a " + "LEFT JOIN moderation_subjects s ON s.id = a.subject_id " + "ORDER BY a.created_at DESC LIMIT ?" ) .all(limit); } function listEvidenceForActions(db, ids) { if (!ids || !ids.length) { return {}; } const rows = db .prepare( `SELECT id, action_id, file_name, file_path FROM moderation_evidence WHERE action_id IN (${ids .map(() => "?") .join(",")})` ) .all(...ids); return rows.reduce((acc, row) => { if (!acc[row.action_id]) { acc[row.action_id] = []; } acc[row.action_id].push({ id: row.id, name: row.file_name || path.basename(row.file_path) }); return acc; }, {}); } function listNotes(db, { limit = 200 } = {}) { return db .prepare( "SELECT n.*, s.display_name, s.internal_user_id FROM moderation_notes n " + "LEFT JOIN moderation_subjects s ON s.id = n.subject_id " + "ORDER BY n.created_at DESC LIMIT ?" ) .all(limit); } function listActiveSanctions(db) { const now = Date.now(); return db .prepare( "SELECT a.*, s.display_name FROM moderation_actions a " + "LEFT JOIN moderation_subjects s ON s.id = a.subject_id " + "WHERE a.status = 'active' AND a.action_type IN ('ban', 'timeout') " + "AND (a.expires_at IS NULL OR a.expires_at > ?) " + "ORDER BY a.created_at DESC" ) .all(now); } function getAction(db, id) { return db .prepare("SELECT * FROM moderation_actions WHERE id = ?") .get(id); } function updateActionDuration(db, id, durationSeconds, expiresAt) { db.prepare( "UPDATE moderation_actions SET duration_seconds = ?, expires_at = ? WHERE id = ?" ).run(durationSeconds, expiresAt, id); } function setActionStatus(db, id, status) { db.prepare("UPDATE moderation_actions SET status = ? WHERE id = ?").run(status, id); } function getActiveSanctionForUser(db, userId) { const subject = db .prepare("SELECT id FROM moderation_subjects WHERE internal_user_id = ?") .get(userId); if (!subject) { return null; } const now = Date.now(); const action = db .prepare( "SELECT a.*, s.display_name FROM moderation_actions a " + "LEFT JOIN moderation_subjects s ON s.id = a.subject_id " + "WHERE a.subject_id = ? AND a.status = 'active' AND a.action_type IN ('ban', 'timeout') " + "AND (a.expires_at IS NULL OR a.expires_at > ?) " + "ORDER BY a.created_at DESC LIMIT 1" ) .get(subject.id, now); return action || null; } function getBanPot(db) { const row = db.prepare("SELECT balance FROM moderation_ban_pot WHERE id = 1").get(); return row ? row.balance : 0; } function addBanPot(db, amount) { const current = getBanPot(db); db.prepare("UPDATE moderation_ban_pot SET balance = ?, updated_at = ? WHERE id = 1").run( current + amount, Date.now() ); } async function enforceAction({ action, identities, settings, discordClient, twitchClient, youtubeClient, reasonShort, reasonDetail }) { const summary = reasonShort || "Moderation action"; const detail = reasonDetail || ""; const duration = action.duration_seconds || null; for (const identity of identities) { if (identity.platform === "discord") { await enforceDiscord( discordClient, settings, identity.platform_user_id, action.action_type, summary, detail, duration ); } if (identity.platform === "twitch") { await enforceTwitch( twitchClient, identity.platform_username || identity.platform_user_id, action.action_type, summary, duration ); } if (identity.platform === "youtube") { // Placeholder for YouTube enforcement // Future: apply chat bans/timeouts with YouTube API continue; } } } async function revokeAction({ action, identities, settings, discordClient, twitchClient }) { for (const identity of identities) { if (identity.platform === "discord") { if (action.action_type === "ban") { await revokeDiscordBan(discordClient, settings, identity.platform_user_id); } if (action.action_type === "timeout") { await revokeDiscordTimeout(discordClient, settings, identity.platform_user_id); } } if (identity.platform === "twitch") { if (action.action_type === "ban") { await revokeTwitchBan(twitchClient, identity.platform_username || identity.platform_user_id); } if (action.action_type === "timeout") { await revokeTwitchTimeout(twitchClient, identity.platform_username || identity.platform_user_id); } } } } async function enforceDiscord(client, settings, userId, actionType, reasonShort, reasonDetail, durationSeconds) { if (!client || !userId) { return; } const guildId = settings?.getSetting?.("discord_guild_id", null); if (!guildId) { return; } const guild = client.guilds?.cache?.get(guildId) || null; if (!guild) { return; } const reason = `${reasonShort}${reasonDetail ? ` | ${reasonDetail}` : ""}`.slice(0, 480); if (actionType === "ban") { await guild.members.ban(userId, { reason }).catch(() => null); await notifyDiscordMember(guild, userId, reasonShort, reasonDetail, "ban"); return; } if (actionType === "timeout") { const member = await guild.members.fetch(userId).catch(() => null); if (!member) { return; } const durationMs = durationSeconds ? durationSeconds * 1000 : null; if (durationMs) { await member.timeout(durationMs, reason).catch(() => null); await notifyDiscordMember(guild, userId, reasonShort, reasonDetail, "timeout"); } } } async function revokeDiscordBan(client, settings, userId) { if (!client || !userId) { return; } const guildId = settings?.getSetting?.("discord_guild_id", null); if (!guildId) { return; } const guild = client.guilds?.cache?.get(guildId) || null; if (!guild) { return; } await guild.members.unban(userId).catch(() => null); } async function revokeDiscordTimeout(client, settings, userId) { if (!client || !userId) { return; } const guildId = settings?.getSetting?.("discord_guild_id", null); const guild = client.guilds?.cache?.get(guildId) || null; if (!guild) { return; } const member = await guild.members.fetch(userId).catch(() => null); if (!member) { return; } await member.timeout(null).catch(() => null); } async function notifyDiscordMember(guild, userId, reasonShort, reasonDetail, type) { const member = await guild.members.fetch(userId).catch(() => null); if (!member) { return; } const title = type === "ban" ? "You have been banned" : "You have been timed out"; const message = `${title} from ${guild.name}.\nSummary: ${reasonShort}\nDetails: ${reasonDetail}`.slice(0, 1900); await member.send(message).catch(() => null); } async function enforceTwitch(client, username, actionType, reasonShort, durationSeconds) { if (!client || !username) { return; } const reason = reasonShort || "Moderation action"; const channels = typeof client.getChannels === "function" ? client.getChannels() : []; const channel = channels[0] || null; if (!channel) { return; } if (actionType === "ban") { await client.ban(channel, username, reason).catch(() => null); await tryWhisper(client, username, `You have been banned. Reason: ${reason}`); return; } if (actionType === "timeout") { const duration = durationSeconds || 3600; await client.timeout(channel, username, duration, reason).catch(() => null); await tryWhisper(client, username, `You have been timed out. Reason: ${reason}`); } } async function revokeTwitchBan(client, username) { if (!client || !username) { return; } const channels = typeof client.getChannels === "function" ? client.getChannels() : []; const channel = channels[0] || null; if (!channel) { return; } await client.unban(channel, username).catch(() => null); } async function revokeTwitchTimeout(client, username) { if (!client || !username) { return; } const channels = typeof client.getChannels === "function" ? client.getChannels() : []; const channel = channels[0] || null; if (!channel) { return; } await client.unban(channel, username).catch(() => null); } async function tryWhisper(client, username, message) { if (!client || typeof client.whisper !== "function") { return; } await client.whisper(username, message).catch(() => null); } function distributeBanAssets(db, subjectId, { reason }) { const framework = global.lumiFrameworks?.echonomy; if (!framework) { return; } const subject = db .prepare("SELECT internal_user_id FROM moderation_subjects WHERE id = ?") .get(subjectId); if (!subject?.internal_user_id) { return; } const balance = framework.getBalance(subject.internal_user_id); if (!balance || balance <= 0) { return; } const result = framework.removeBalance({ userId: subject.internal_user_id, amount: balance, note: `Ban distribution${reason ? `: ${reason}` : ""}`, meta: { source: "moderation", type: "ban" }, allowFrozen: true }); if (result?.ok === false) { return; } addBanPot(db, balance); } function installFreezeHook(db) { global.lumiModeration = { isFrozen: (userId) => isUserFrozen(db, userId), getBanPot: () => getBanPot(db) }; } function isUserFrozen(db, userId) { const sanction = getActiveSanctionForUser(db, userId); if (!sanction) { return false; } return true; } function startDiscordAuditPolling(db, settings, client) { const poll = async () => { if (!client?.guilds?.cache) { return; } for (const guild of client.guilds.cache.values()) { await harvestDiscordAuditLogs(db, guild, settings); } }; poll(); setInterval(poll, 60000); } async function harvestDiscordAuditLogs(db, guild, settings) { const types = [ "MEMBER_BAN_ADD", "MEMBER_BAN_REMOVE", "MEMBER_KICK", "MEMBER_UPDATE" ]; for (const type of types) { const entries = await guild.fetchAuditLogs({ type, limit: 10 }).catch(() => null); if (!entries) { continue; } const lastKey = `discord_audit_${guild.id}_${type}`; const lastIdRow = db.prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?") .get(PLUGIN_ID, lastKey); const lastId = lastIdRow?.value || null; const items = Array.from(entries.entries.values()); for (const entry of items) { if (lastId && entry.id === lastId) { break; } const target = entry.target; if (!target?.id) { continue; } const subjectId = getOrCreateSubjectByIdentity(db, "discord", target.id, target.tag || target.username); const actionType = mapDiscordAuditType(entry, type); if (!actionType) { continue; } const duration = actionType === "timeout" ? computeTimeoutDuration(entry) : null; createAction(db, { subjectId, actionType, scope: "global", platform: "discord", reasonShort: entry.reason || "Discord moderation action", reasonDetail: entry.reason || "", durationSeconds: duration, createdById: entry.executor?.id || null, createdByName: entry.executor?.tag || entry.executor?.username || null, source: "external", externalRef: entry.id }); } if (items[0]) { setPluginSetting(db, lastKey, items[0].id); } } } function mapDiscordAuditType(entry, type) { if (type === "MEMBER_BAN_ADD") { return "ban"; } if (type === "MEMBER_BAN_REMOVE") { return "unban"; } if (type === "MEMBER_KICK") { return "kick"; } if (type === "MEMBER_UPDATE") { const change = entry.changes?.find((item) => item.key === "communication_disabled_until"); if (!change) { return null; } if (change.new) { return "timeout"; } return "untimeout"; } return null; } function computeTimeoutDuration(entry) { const change = entry.changes?.find((item) => item.key === "communication_disabled_until"); if (!change || !change.new) { return null; } const until = new Date(change.new).getTime(); const now = entry.createdTimestamp || Date.now(); const diff = Math.max(0, Math.floor((until - now) / 1000)); return diff || null; } function setPluginSetting(db, key, value) { 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, key, value, Date.now()); } function attachTwitchModerationEvents(db, client) { client.on("ban", (channel, username, reason, userstate) => { const targetId = userstate?.["target-user-id"] || username; const subjectId = getOrCreateSubjectByIdentity(db, "twitch", targetId, username); createAction(db, { subjectId, actionType: "ban", scope: "global", platform: "twitch", reasonShort: reason || "Twitch ban", reasonDetail: reason || "", durationSeconds: null, createdById: userstate?.["room-id"] || null, createdByName: userstate?.["display-name"] || null, source: "external", externalRef: `${channel}:${username}:${Date.now()}` }); }); client.on("timeout", (channel, username, reason, duration, userstate) => { const targetId = userstate?.["target-user-id"] || username; const subjectId = getOrCreateSubjectByIdentity(db, "twitch", targetId, username); createAction(db, { subjectId, actionType: "timeout", scope: "global", platform: "twitch", reasonShort: reason || "Twitch timeout", reasonDetail: reason || "", durationSeconds: duration || null, createdById: userstate?.["room-id"] || null, createdByName: userstate?.["display-name"] || null, source: "external", externalRef: `${channel}:${username}:${Date.now()}` }); }); } function installGlobalGate(app, middleware) { app.use(middleware); const stack = app._router?.stack; if (Array.isArray(stack) && stack.length) { const layer = stack.pop(); stack.unshift(layer); } }