VoidBot/scripts/moderate-discord-user.mjs

266 lines
6.8 KiB
JavaScript

import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { TextDecoder } from "node:util";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
async function main() {
const options = parseArgs(process.argv.slice(2));
const env = readLocalEnv();
const botToken = env.DISCORD_BOT_TOKEN?.trim();
const guildId = options.guildId ?? env.DISCORD_GUILD_ID?.trim();
if (!guildId) {
throw new Error("Provide --guild-id or configure DISCORD_GUILD_ID.");
}
if (!options.userId) {
throw new Error("Provide --user-id.");
}
const reason = options.reason ?? "VoidBot moderation action.";
if (options.dryRun) {
process.stdout.write(`${JSON.stringify({
ok: true,
dryRun: true,
action: options.action,
guildId,
userId: options.userId,
durationMinutes: options.durationMinutes,
reason,
})}\n`);
return;
}
if (!botToken) {
throw new Error("DISCORD_BOT_TOKEN is required.");
}
const result = await executeModerationAction({
botToken,
guildId,
userId: options.userId,
action: options.action,
durationMinutes: options.durationMinutes,
reason,
});
process.stdout.write(`${JSON.stringify(result)}\n`);
}
function parseArgs(args) {
const options = {
action: undefined,
guildId: undefined,
userId: undefined,
durationMinutes: 60,
reason: undefined,
dryRun: false,
};
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
switch (argument) {
case "--action":
options.action = args[index + 1];
index += 1;
break;
case "--guild-id":
options.guildId = args[index + 1];
index += 1;
break;
case "--user-id":
options.userId = args[index + 1];
index += 1;
break;
case "--duration-minutes":
options.durationMinutes = Number(args[index + 1]);
index += 1;
break;
case "--reason":
options.reason = args[index + 1];
index += 1;
break;
case "--dry-run":
options.dryRun = true;
break;
default:
throw new Error(`Unknown argument: ${argument}`);
}
}
if (!["timeout", "ban", "kick", "remove-timeout"].includes(options.action)) {
throw new Error("Provide --action timeout, ban, kick, or remove-timeout.");
}
if (!Number.isFinite(options.durationMinutes) || options.durationMinutes < 1) {
throw new Error("--duration-minutes must be a positive number.");
}
return options;
}
async function executeModerationAction(input) {
switch (input.action) {
case "timeout":
return timeoutMember(input);
case "remove-timeout":
return removeTimeout(input);
case "ban":
return banMember(input);
case "kick":
return kickMember(input);
default:
throw new Error(`Unsupported moderation action: ${input.action}`);
}
}
async function timeoutMember(input) {
const until = new Date(Date.now() + input.durationMinutes * 60_000).toISOString();
const response = await discordFetch(input.botToken, `/guilds/${input.guildId}/members/${input.userId}`, {
method: "PATCH",
reason: input.reason,
body: {
communication_disabled_until: until,
},
});
return {
ok: true,
action: "timeout",
guildId: input.guildId,
userId: input.userId,
durationMinutes: input.durationMinutes,
communicationDisabledUntil: until,
status: response.status,
};
}
async function removeTimeout(input) {
const response = await discordFetch(input.botToken, `/guilds/${input.guildId}/members/${input.userId}`, {
method: "PATCH",
reason: input.reason,
body: {
communication_disabled_until: null,
},
});
return {
ok: true,
action: "remove-timeout",
guildId: input.guildId,
userId: input.userId,
status: response.status,
};
}
async function banMember(input) {
const response = await discordFetch(input.botToken, `/guilds/${input.guildId}/bans/${input.userId}`, {
method: "PUT",
reason: input.reason,
body: {
delete_message_seconds: 0,
},
});
return {
ok: true,
action: "ban",
guildId: input.guildId,
userId: input.userId,
status: response.status,
};
}
async function kickMember(input) {
const response = await discordFetch(input.botToken, `/guilds/${input.guildId}/members/${input.userId}`, {
method: "DELETE",
reason: input.reason,
});
return {
ok: true,
action: "kick",
guildId: input.guildId,
userId: input.userId,
status: response.status,
};
}
async function discordFetch(botToken, path, options) {
const headers = {
Authorization: `Bot ${botToken}`,
"X-Audit-Log-Reason": encodeURIComponent(options.reason ?? "VoidBot moderation action."),
};
const request = {
method: options.method,
headers,
};
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
request.body = JSON.stringify(options.body);
}
const response = await fetch(`https://discord.com/api/v10${path}`, request);
if (!response.ok) {
const text = await response.text();
throw new Error(`Discord moderation action failed: ${response.status} ${text}`);
}
return response;
}
function readLocalEnv() {
const envPath = resolve(repoRoot, ".env");
const parsed = { ...process.env };
try {
const raw = stripLeadingBom(readTextFileFlexible(envPath));
Object.assign(parsed, parseDotEnv(raw));
} catch (error) {
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) {
throw error;
}
}
return parsed;
}
function parseDotEnv(raw) {
const result = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) {
continue;
}
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex === -1) {
continue;
}
const key = trimmed.slice(0, separatorIndex).trim();
let value = trimmed.slice(separatorIndex + 1).trim();
if (
value.length >= 2 &&
((value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
result[key] = value;
}
return result;
}
function readTextFileFlexible(path) {
const buffer = readFileSync(path);
if (buffer.length >= 2) {
if (buffer[0] === 0xff && buffer[1] === 0xfe) {
return new TextDecoder("utf-16le").decode(buffer);
}
if (buffer[0] === 0xfe && buffer[1] === 0xff) {
return new TextDecoder("utf-16be").decode(buffer);
}
}
return new TextDecoder("utf-8").decode(buffer);
}
function stripLeadingBom(value) {
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
}
await main();