VoidBot/scripts/send-discord-message.mjs
2026-05-05 02:17:55 +01:00

315 lines
7.8 KiB
JavaScript

import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
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();
if (!botToken) {
throw new Error("DISCORD_BOT_TOKEN is required.");
}
const content = await readContent(options);
if (content.trim().length === 0) {
throw new Error("Message content is empty.");
}
if (options.ownerDm) {
const ownerId = env.DISCORD_OWNER_ID?.trim();
if (!ownerId) {
throw new Error("DISCORD_OWNER_ID is required for --owner-dm.");
}
const channelId = await openOwnerDmChannel(botToken, ownerId);
await postChunkedDiscordMessage(botToken, channelId, content);
writeLastSpeechStatus({
sentAt: new Date().toISOString(),
mode: "owner_dm",
channelId,
contentLength: content.length,
chunkCount: splitDiscordContent(content).length,
preview: content.slice(0, 280),
});
process.stdout.write(`${JSON.stringify({ ok: true, mode: "owner_dm", channelId })}\n`);
return;
}
if (!options.channelId) {
throw new Error("Provide --owner-dm or --channel-id.");
}
await postChunkedDiscordMessage(
botToken,
options.channelId,
content,
options.replyToMessageId,
);
writeLastSpeechStatus({
sentAt: new Date().toISOString(),
mode: "channel",
channelId: options.channelId,
replyToMessageId: options.replyToMessageId ?? null,
contentLength: content.length,
chunkCount: splitDiscordContent(content).length,
preview: content.slice(0, 280),
});
process.stdout.write(
`${JSON.stringify({
ok: true,
mode: "channel",
channelId: options.channelId,
replyToMessageId: options.replyToMessageId ?? null,
})}\n`,
);
}
function parseArgs(args) {
const options = {
ownerDm: false,
channelId: undefined,
replyToMessageId: undefined,
content: undefined,
contentFile: undefined,
};
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
switch (argument) {
case "--owner-dm":
options.ownerDm = true;
break;
case "--channel-id":
options.channelId = args[index + 1];
index += 1;
break;
case "--reply-to":
options.replyToMessageId = args[index + 1];
index += 1;
break;
case "--content":
options.content = args[index + 1];
index += 1;
break;
case "--content-file":
options.contentFile = args[index + 1];
index += 1;
break;
default:
throw new Error(`Unknown argument: ${argument}`);
}
}
if (options.ownerDm && options.channelId) {
throw new Error("Use either --owner-dm or --channel-id, not both.");
}
return options;
}
function readLocalEnv() {
const envPath = resolve(repoRoot, ".env");
const parsed = { ...process.env };
try {
const raw = stripLeadingBom(readFileSync(envPath, "utf8"));
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;
}
async function readContent(options) {
if (typeof options.content === "string") {
return options.content;
}
if (typeof options.contentFile === "string") {
return stripLeadingBom(readFileSync(resolve(repoRoot, options.contentFile), "utf8"));
}
return new Promise((resolveContent, reject) => {
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
input += chunk;
});
process.stdin.on("end", () => resolveContent(input));
process.stdin.on("error", reject);
});
}
async function openOwnerDmChannel(botToken, recipientId) {
const response = await fetch("https://discord.com/api/v10/users/@me/channels", {
method: "POST",
headers: {
Authorization: `Bot ${botToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
recipient_id: recipientId,
}),
});
if (!response.ok) {
throw new Error(`Failed to open Discord DM channel: ${response.status} ${await response.text()}`);
}
const payload = await response.json();
if (!payload?.id) {
throw new Error("Discord DM channel creation returned no channel id.");
}
return payload.id;
}
async function postChunkedDiscordMessage(botToken, channelId, content, replyToMessageId) {
const chunks = splitDiscordContent(content);
for (let index = 0; index < chunks.length; index += 1) {
await postDiscordMessage(
botToken,
channelId,
chunks[index],
index === 0 ? replyToMessageId : undefined,
);
}
}
async function postDiscordMessage(botToken, channelId, content, replyToMessageId) {
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
method: "POST",
headers: {
Authorization: `Bot ${botToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content,
message_reference: replyToMessageId
? {
message_id: replyToMessageId,
fail_if_not_exists: false,
}
: undefined,
allowed_mentions: {
parse: [],
},
}),
});
if (!response.ok) {
throw new Error(`Failed to post Discord message: ${response.status} ${await response.text()}`);
}
}
function splitDiscordContent(input, limit = 1900) {
const normalized = input.replace(/\r\n/g, "\n").trim();
if (normalized.length === 0) {
return ["(empty response)"];
}
if (normalized.length <= limit) {
return [normalized];
}
const chunks = [];
let remaining = normalized;
while (remaining.length > limit) {
const splitIndex = findSplitIndex(remaining, limit);
const nextChunk = remaining.slice(0, splitIndex).trim();
chunks.push(nextChunk.length > 0 ? nextChunk : remaining.slice(0, limit));
remaining = remaining.slice(splitIndex).trimStart();
}
if (remaining.length > 0) {
chunks.push(remaining);
}
return chunks;
}
function findSplitIndex(input, limit) {
const doubleNewline = input.lastIndexOf("\n\n", limit);
if (doubleNewline >= Math.floor(limit * 0.6)) {
return doubleNewline + 2;
}
const newline = input.lastIndexOf("\n", limit);
if (newline >= Math.floor(limit * 0.6)) {
return newline + 1;
}
const space = input.lastIndexOf(" ", limit);
if (space >= Math.floor(limit * 0.6)) {
return space + 1;
}
return limit;
}
function writeLastSpeechStatus(payload) {
const statusPath = resolve(repoRoot, ".voidbot/status/void-last-speech.json");
mkdirSync(dirname(statusPath), { recursive: true });
writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function stripLeadingBom(input) {
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
}
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}