With discord-dm gone the verb set, listCommandRecords filter, resolveBridgeVerb throw and the verb variable decided nothing, and the filter hid non-discord-post commands so they stayed pending with no receipt. Delete all of it: a command whose verb is not discord-post now reaches processOneCommand and is refused with a failed receipt (error names no input value). Payload staging moves inside the try so a refusal cannot escape as a thrown error. Adds tests/cultmesh-bridge-commands.test.mjs: discord-dm refused with failed receipt and non-ok result, canary-free error, discord-post still reaches the actuator. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
460 lines
15 KiB
JavaScript
460 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createRequire } from "node:module";
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import { crossingReceiptDefinition } from "./bifrost-crossing-documents.mjs";
|
|
import {
|
|
discordPostCommandDefinition,
|
|
discordPostCommandDocumentType as commandDocumentType,
|
|
discordPostCommandSchemaId as commandSchemaId,
|
|
discordPostReceiptDefinition,
|
|
discordPostReceiptDocumentType as receiptDocumentType,
|
|
discordPostReceiptSchemaId as receiptSchemaId,
|
|
} from "./bifrost-discord-command-documents.mjs";
|
|
|
|
const repoRoot = resolve(import.meta.dirname, "..");
|
|
const projectsRoot = resolve(repoRoot, "..");
|
|
const cultLibRoot = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projectsRoot, "CultLib"));
|
|
const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc");
|
|
|
|
async function main() {
|
|
loadBifrostLocalEnv();
|
|
const [command, ...rawArgs] = process.argv.slice(2);
|
|
const options = parseArgs(rawArgs);
|
|
|
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
switch (command) {
|
|
case "process":
|
|
await processCommands(options);
|
|
return;
|
|
case "write-smoke":
|
|
await writeSmokeCommand(options);
|
|
return;
|
|
case "receipt":
|
|
await printReceipt(options);
|
|
return;
|
|
case "delete":
|
|
await deleteCommand(options);
|
|
return;
|
|
default:
|
|
throw new Error(`Unknown command "${command}". Run "node tools/cultmesh-bridge-commands.mjs help".`);
|
|
}
|
|
}
|
|
|
|
async function processCommands(options) {
|
|
const storePath = resolve(options.store ?? defaultStorePath);
|
|
const commandIdFilter = optionalString(options["command-id"]);
|
|
const node = await openCommandNode(storePath);
|
|
await node.cache?.pullAllBackingStores?.();
|
|
const commands = listCommandRecords(node)
|
|
.filter((command) => command.status === "pending" || command.status === "running")
|
|
.filter((command) => !commandIdFilter || command.commandId === commandIdFilter)
|
|
.sort((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)));
|
|
|
|
const processed = [];
|
|
for (const command of commands) {
|
|
processed.push(await processOneCommand(node, command, options));
|
|
}
|
|
await node.flush?.();
|
|
|
|
printJson({
|
|
ok: processed.every((receipt) => receipt.status === "completed"),
|
|
action: "process",
|
|
storePath,
|
|
commandCount: commands.length,
|
|
processed,
|
|
});
|
|
}
|
|
|
|
async function writeSmokeCommand(options) {
|
|
const storePath = resolve(options.store ?? defaultStorePath);
|
|
const commandId = optionalString(options["command-id"]) ?? `bifrost-smoke-${Date.now()}`;
|
|
const command = {
|
|
schemaName: commandDocumentType,
|
|
schemaVersion: commandSchemaId,
|
|
commandId,
|
|
command: "discord-post",
|
|
status: "pending",
|
|
requestedBy: "bifrost-smoke",
|
|
requestedAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
source: {
|
|
kind: "smoke",
|
|
id: commandId,
|
|
},
|
|
payload: {
|
|
channelId: requireOption(options, "channel-id"),
|
|
content: optionalString(options.content) ?? "Bifrost CultMesh Discord command smoke.",
|
|
personaName: optionalString(options["persona-name"]),
|
|
personaAvatarUrl: optionalString(options["persona-avatar-url"]),
|
|
replyToMessageId: optionalString(options["reply-to-message-id"]),
|
|
},
|
|
};
|
|
const node = await openCommandNode(storePath);
|
|
await node.put(commandDefinition(), command.commandId, command);
|
|
await node.flush?.();
|
|
printJson({ ok: true, action: "write-smoke", storePath, commandId });
|
|
}
|
|
|
|
async function printReceipt(options) {
|
|
const commandId = requireOption(options, "command-id");
|
|
const storePath = resolve(options.store ?? defaultStorePath);
|
|
const node = await openCommandNode(storePath);
|
|
await node.cache?.pullAllBackingStores?.();
|
|
const receipt = unwrapRecord(await node.get(receiptDefinition(), commandId));
|
|
if (!receipt) {
|
|
printJson({ ok: false, commandId, receipt: null });
|
|
return;
|
|
}
|
|
printJson({ ok: receipt.status === "completed", commandId, receipt });
|
|
}
|
|
|
|
async function deleteCommand(options) {
|
|
const commandId = requireOption(options, "command-id");
|
|
const storePath = resolve(options.store ?? defaultStorePath);
|
|
const node = await openCommandNode(storePath);
|
|
await node.cache?.pullAllBackingStores?.();
|
|
const deletedCommand = typeof node.cache?.delete === "function"
|
|
? await node.cache.delete(commandDefinition(), commandId)
|
|
: false;
|
|
const deletedReceipt = typeof node.cache?.delete === "function"
|
|
? await node.cache.delete(receiptDefinition(), commandId)
|
|
: false;
|
|
await node.flush?.();
|
|
printJson({ ok: Boolean(deletedCommand || deletedReceipt), action: "delete", storePath, commandId, deletedCommand, deletedReceipt });
|
|
}
|
|
|
|
async function processOneCommand(node, command, options) {
|
|
const running = {
|
|
...command,
|
|
status: "running",
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
await node.put(commandDefinition(), command.commandId, running);
|
|
|
|
const payload = command.payload && typeof command.payload === "object" ? command.payload : {};
|
|
const tempDir = resolve(repoRoot, ".bifrost", "cultmesh-command-payloads", command.commandId);
|
|
const contentPath = resolve(tempDir, "content.md");
|
|
|
|
try {
|
|
if ((optionalString(command.command) ?? "discord-post") !== "discord-post") {
|
|
throw new Error("Unsupported Bifrost bridge command; only discord-post is supported.");
|
|
}
|
|
await mkdir(tempDir, { recursive: true });
|
|
await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8");
|
|
const args = [
|
|
"tools/bifrost-bridge.mjs",
|
|
"discord-post",
|
|
"--channel-id",
|
|
requireString(payload.channelId, "payload.channelId"),
|
|
"--content-file",
|
|
contentPath,
|
|
"--cultmesh-command-id",
|
|
command.commandId,
|
|
"--source-kind",
|
|
optionalString(command.source?.kind) ?? "cultmesh-command",
|
|
"--source-id",
|
|
optionalString(command.source?.id) ?? command.commandId,
|
|
"--identity",
|
|
optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost",
|
|
];
|
|
pushOption(args, "--persona-name", payload.personaName);
|
|
pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl);
|
|
pushOption(args, "--reply-to-message-id", payload.replyToMessageId);
|
|
pushOption(args, "--receipt-store", options["receipt-store"]);
|
|
|
|
const result = spawnSync(process.execPath, args, {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
env: {
|
|
...process.env,
|
|
BIFROST_CULTMESH_COMMAND_ID: command.commandId,
|
|
},
|
|
});
|
|
if (result.status !== 0 || result.error) {
|
|
throw new Error(renderSpawnFailure(result));
|
|
}
|
|
const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt");
|
|
const receipt = buildReceipt(command, {
|
|
status: "completed",
|
|
ok: true,
|
|
action: "discord-post",
|
|
channelId: posted.channelId,
|
|
messageId: posted.messageId,
|
|
transport: posted.transport,
|
|
url: posted.url,
|
|
canonicalReceiptId: posted.crossingReceiptId,
|
|
payload: posted,
|
|
});
|
|
await node.put(receiptDefinition(), command.commandId, receipt);
|
|
await node.put(commandDefinition(), command.commandId, {
|
|
...running,
|
|
status: "completed",
|
|
updatedAt: receipt.completedAt,
|
|
receiptId: receipt.receiptId,
|
|
});
|
|
return receipt;
|
|
} catch (error) {
|
|
const receipt = buildReceipt(command, {
|
|
status: "failed",
|
|
ok: false,
|
|
action: "discord-post",
|
|
canonicalReceiptId: `crossing_${command.commandId}`,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
await node.put(receiptDefinition(), command.commandId, receipt);
|
|
await node.put(commandDefinition(), command.commandId, {
|
|
...running,
|
|
status: "failed",
|
|
updatedAt: receipt.completedAt,
|
|
receiptId: receipt.receiptId,
|
|
error: receipt.error,
|
|
});
|
|
return receipt;
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function buildReceipt(command, result) {
|
|
const completedAt = new Date().toISOString();
|
|
return {
|
|
schemaName: receiptDocumentType,
|
|
schemaVersion: receiptSchemaId,
|
|
receiptId: command.commandId,
|
|
commandId: command.commandId,
|
|
command: command.command,
|
|
status: result.status,
|
|
ok: result.ok,
|
|
action: result.action,
|
|
channelId: result.channelId ?? command.payload?.channelId ?? "",
|
|
messageId: result.messageId ?? "",
|
|
transport: result.transport ?? "",
|
|
url: result.url ?? "",
|
|
canonicalReceiptId: result.canonicalReceiptId ?? "",
|
|
error: result.error ?? "",
|
|
requestedAt: command.requestedAt ?? command.createdAt ?? "",
|
|
completedAt,
|
|
source: command.source ?? {},
|
|
actor: command.actor ?? {},
|
|
payload: result.payload ?? {},
|
|
};
|
|
}
|
|
|
|
function listCommandRecords(node) {
|
|
if (typeof node.cache?.getAll !== "function") {
|
|
return [];
|
|
}
|
|
return node.cache.getAll(commandDefinition())
|
|
.map(unwrapRecord)
|
|
.filter((entry) => entry && entry.commandId);
|
|
}
|
|
|
|
async function openCommandNode(storePath) {
|
|
const { CultMesh, defineDocumentType } = loadCultMeshRuntime();
|
|
return CultMesh.createNode(storePath, {
|
|
documents: [
|
|
commandDefinition(defineDocumentType),
|
|
receiptDefinition(defineDocumentType),
|
|
crossingReceiptDefinition(defineDocumentType),
|
|
genericDocument(defineDocumentType, "gamecult.eve.provider_advertisement", "gamecult.eve.provider_advertisement.v1", "providerId"),
|
|
genericDocument(defineDocumentType, "gamecult.eve.surface_state", "gamecult.eve.surface_state.v1", "providerId"),
|
|
genericDocument(defineDocumentType, "gamecult.eve.interface_binding", "gamecult.eve.interface_binding.v1", "bindingId"),
|
|
],
|
|
});
|
|
}
|
|
|
|
function commandDefinition(defineDocumentTypeInput) {
|
|
const defineDocumentType = defineDocumentTypeInput ?? loadCultMeshRuntime().defineDocumentType;
|
|
return discordPostCommandDefinition(defineDocumentType);
|
|
}
|
|
|
|
function receiptDefinition(defineDocumentTypeInput) {
|
|
const defineDocumentType = defineDocumentTypeInput ?? loadCultMeshRuntime().defineDocumentType;
|
|
return discordPostReceiptDefinition(defineDocumentType);
|
|
}
|
|
|
|
function genericDocument(defineDocumentType, type, schemaId, name) {
|
|
return defineDocumentType({
|
|
type,
|
|
schemaName: type,
|
|
schemaId,
|
|
schemaVersion: schemaId,
|
|
contentHash: schemaId,
|
|
global: false,
|
|
name,
|
|
schema: parseObjectDocument(type),
|
|
});
|
|
}
|
|
|
|
function loadCultMeshRuntime() {
|
|
const candidates = [
|
|
{
|
|
mesh: resolve(cultLibRoot, "packages", "cultmesh-ts", "dist", "index.js"),
|
|
cache: resolve(cultLibRoot, "packages", "cultcache-ts", "dist", "index.js"),
|
|
},
|
|
{
|
|
mesh: resolve(projectsRoot, "CultMeshTS", "dist", "index.js"),
|
|
cache: resolve(projectsRoot, "CultCacheTS", "dist", "index.js"),
|
|
},
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (!existsSync(candidate.mesh) || !existsSync(candidate.cache)) {
|
|
continue;
|
|
}
|
|
try {
|
|
const requireCult = createRequire(candidate.mesh);
|
|
const { CultMesh } = requireCult(candidate.mesh);
|
|
const { defineDocumentType } = requireCult(candidate.cache);
|
|
if (CultMesh && defineDocumentType) {
|
|
return { CultMesh, defineDocumentType };
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
throw new Error("CultMesh/CultCache TypeScript runtime is unavailable.");
|
|
}
|
|
|
|
function parseObjectDocument(label) {
|
|
return {
|
|
parse(input) {
|
|
if (!input || typeof input !== "object") {
|
|
throw new Error(`${label} must be an object.`);
|
|
}
|
|
return input;
|
|
},
|
|
};
|
|
}
|
|
|
|
function loadBifrostLocalEnv() {
|
|
if (process.env.BIFROST_SKIP_LOCAL_ENV === "true") {
|
|
return;
|
|
}
|
|
loadLocalEnv(resolve(repoRoot, ".env"));
|
|
loadLocalEnv(resolve(projectsRoot, "VoidBot", ".env"));
|
|
}
|
|
|
|
function loadLocalEnv(path) {
|
|
if (!existsSync(path)) {
|
|
return;
|
|
}
|
|
const content = readFileSync(path, "utf8");
|
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith("#")) {
|
|
continue;
|
|
}
|
|
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
if (!match || process.env[match[1]] !== undefined) {
|
|
continue;
|
|
}
|
|
process.env[match[1]] = match[2].replace(/^"(.*)"$/, "$1");
|
|
}
|
|
}
|
|
|
|
function parseArgs(args) {
|
|
const options = {};
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const token = args[index];
|
|
if (!token.startsWith("--")) {
|
|
throw new Error(`Unexpected argument "${token}".`);
|
|
}
|
|
const key = token.slice(2);
|
|
const next = args[index + 1];
|
|
if (!next || next.startsWith("--")) {
|
|
options[key] = "true";
|
|
continue;
|
|
}
|
|
options[key] = next;
|
|
index += 1;
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function requireOption(options, name) {
|
|
const value = optionalString(options[name]);
|
|
if (!value) {
|
|
throw new Error(`Missing required option --${name}.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireString(value, name) {
|
|
const text = optionalString(value);
|
|
if (!text) {
|
|
throw new Error(`Missing required ${name}.`);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function optionalString(value) {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
function pushOption(args, name, value) {
|
|
const text = optionalString(value);
|
|
if (text) {
|
|
args.push(name, text);
|
|
}
|
|
}
|
|
|
|
function unwrapRecord(record) {
|
|
const candidate = Array.isArray(record) && record.length === 1 ? record[0] : record?.value ?? record;
|
|
return candidate && typeof candidate === "object" ? candidate : null;
|
|
}
|
|
|
|
function parseJson(value, label) {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
throw new Error(`${label} returned non-JSON output: ${value}`);
|
|
}
|
|
}
|
|
|
|
function renderSpawnFailure(result) {
|
|
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
const error = result.error instanceof Error ? result.error.message : "";
|
|
return [
|
|
`Bifrost Discord actuator failed with status ${result.status ?? "unknown"}.`,
|
|
error,
|
|
stderr,
|
|
stdout,
|
|
].filter(Boolean).join(" ");
|
|
}
|
|
|
|
function printJson(value) {
|
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function printHelp() {
|
|
process.stdout.write(`Bifrost CultMesh bridge commands
|
|
|
|
Usage:
|
|
node tools/cultmesh-bridge-commands.mjs process [--store <path>] [--command-id <id>]
|
|
node tools/cultmesh-bridge-commands.mjs receipt --command-id <id> [--store <path>]
|
|
node tools/cultmesh-bridge-commands.mjs delete --command-id <id> [--store <path>]
|
|
node tools/cultmesh-bridge-commands.mjs write-smoke --channel-id <id> [--store <path>]
|
|
|
|
The command request document is ${commandDocumentType} / ${commandSchemaId}.
|
|
The receipt document is ${receiptDocumentType} / ${receiptSchemaId}.
|
|
`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|