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

1140 lines
35 KiB
JavaScript

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);
}
}