1140 lines
35 KiB
JavaScript
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);
|
|
}
|
|
}
|