diff --git a/tests/cultmesh-bridge-commands.test.mjs b/tests/cultmesh-bridge-commands.test.mjs new file mode 100644 index 0000000..2c0d56f --- /dev/null +++ b/tests/cultmesh-bridge-commands.test.mjs @@ -0,0 +1,89 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { + discordPostCommandDefinition, + discordPostCommandDocumentType, + discordPostCommandSchemaId, +} from "../tools/bifrost-discord-command-documents.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const cultLib = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "CultLib")); +const tool = resolve(root, "tools", "cultmesh-bridge-commands.mjs"); +const canary = "CANARY-9f3a1c77"; + +function loadRuntime() { + const mesh = resolve(cultLib, "packages", "cultmesh-ts", "dist", "index.js"); + const cache = resolve(cultLib, "packages", "cultcache-ts", "dist", "index.js"); + const requireCult = createRequire(mesh); + return { CultMesh: requireCult(mesh).CultMesh, defineDocumentType: requireCult(cache).defineDocumentType }; +} + +async function putCommand(store, commandId, command) { + const { CultMesh, defineDocumentType } = loadRuntime(); + const definition = discordPostCommandDefinition(defineDocumentType); + const node = await CultMesh.createNode(store, { documents: [definition] }); + await node.put(definition, commandId, { + schemaName: discordPostCommandDocumentType, + schemaVersion: discordPostCommandSchemaId, + commandId, + command, + status: "pending", + requestedBy: "test", + requestedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + source: { kind: "test", id: commandId }, + payload: { channelId: "123", content: canary }, + }); + await node.flush?.(); +} + +function run(...args) { + const env = { ...process.env, BIFROST_SKIP_LOCAL_ENV: "true", VOIDBOT_CULTLIB_ROOT: cultLib }; + delete env.BIFROST_DISCORD_BOT_TOKEN; + delete env.DISCORD_BOT_TOKEN; + const result = spawnSync(process.execPath, [tool, ...args], { cwd: root, encoding: "utf8", env }); + return { ...result, json: JSON.parse(result.stdout) }; +} + +test("a non-discord-post command is refused with a failed receipt, never left pending", async () => { + const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc"); + await putCommand(store, "dm1", "discord-dm"); + + const processed = run("process", "--store", store); + assert.equal(processed.json.commandCount, 1); + assert.equal(processed.json.ok, false); + assert.equal(processed.json.processed[0].status, "failed"); + + const receipt = run("receipt", "--store", store, "--command-id", "dm1").json; + assert.equal(receipt.ok, false); + assert.equal(receipt.receipt.status, "failed"); + assert.equal(receipt.receipt.ok, false); + assert.match(receipt.receipt.error, /only discord-post is supported/); + + const again = run("process", "--store", store); + assert.equal(again.json.commandCount, 0, "a refused command is not pending or running afterwards"); +}); + +test("a refusal echoes no input value", async () => { + const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc"); + await putCommand(store, "dm2", `${canary}-verb`); + const processed = run("process", "--store", store, "--command-id", "dm2"); + assert.equal(processed.json.processed[0].status, "failed"); + assert.equal(processed.json.processed[0].error.includes(canary), false); +}); + +test("a discord-post command still reaches the actuator", async () => { + const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc"); + await putCommand(store, "post1", "discord-post"); + const processed = run("process", "--store", store); + assert.equal(processed.json.commandCount, 1); + const receipt = processed.json.processed[0]; + assert.equal(receipt.action, "discord-post"); + assert.match(receipt.error, /Bifrost Discord actuator failed/); + assert.doesNotMatch(receipt.error, /only discord-post is supported/); +}); diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 20b98a5..8d4885b 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -131,7 +131,6 @@ async function deleteCommand(options) { } async function processOneCommand(node, command, options) { - const verb = resolveBridgeVerb(command); const running = { ...command, status: "running", @@ -142,16 +141,18 @@ async function processOneCommand(node, command, options) { 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"); - await mkdir(tempDir, { recursive: true }); - await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8"); 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", - verb, - ...(verb === "discord-dm" - ? ["--recipient-id", requireString(payload.recipientId, "payload.recipientId")] - : ["--channel-id", requireString(payload.channelId, "payload.channelId")]), + "discord-post", + "--channel-id", + requireString(payload.channelId, "payload.channelId"), "--content-file", contentPath, "--cultmesh-command-id", @@ -163,11 +164,9 @@ async function processOneCommand(node, command, options) { "--identity", optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost", ]; - if (verb === "discord-post") { - pushOption(args, "--persona-name", payload.personaName); - pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); - pushOption(args, "--reply-to-message-id", payload.replyToMessageId); - } + 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, { @@ -182,11 +181,11 @@ async function processOneCommand(node, command, options) { if (result.status !== 0 || result.error) { throw new Error(renderSpawnFailure(result)); } - const posted = parseJson(result.stdout, `bifrost bridge ${verb} receipt`); + const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt"); const receipt = buildReceipt(command, { status: "completed", ok: true, - action: verb, + action: "discord-post", channelId: posted.channelId, messageId: posted.messageId, transport: posted.transport, @@ -206,7 +205,7 @@ async function processOneCommand(node, command, options) { const receipt = buildReceipt(command, { status: "failed", ok: false, - action: verb, + action: "discord-post", canonicalReceiptId: `crossing_${command.commandId}`, error: error instanceof Error ? error.message : String(error), }); @@ -224,18 +223,6 @@ async function processOneCommand(node, command, options) { } } -const supportedBridgeVerbs = new Set(["discord-post", "discord-dm"]); - -function resolveBridgeVerb(command) { - const verb = optionalString(command.command) ?? "discord-post"; - if (!supportedBridgeVerbs.has(verb)) { - throw new Error( - `Unsupported Bifrost bridge command "${verb}". Supported: ${[...supportedBridgeVerbs].join(", ")}.`, - ); - } - return verb; -} - function buildReceipt(command, result) { const completedAt = new Date().toISOString(); return { @@ -267,7 +254,7 @@ function listCommandRecords(node) { } return node.cache.getAll(commandDefinition()) .map(unwrapRecord) - .filter((entry) => entry && entry.commandId && supportedBridgeVerbs.has(entry.command ?? "discord-post")); + .filter((entry) => entry && entry.commandId); } async function openCommandNode(storePath) { diff --git a/tools/operator-notification.mjs b/tools/operator-notification.mjs deleted file mode 100644 index 7b98030..0000000 --- a/tools/operator-notification.mjs +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { createHash } from "node:crypto"; -import { - discordPostCommandDefinition, - discordPostCommandDocumentType as commandDocumentType, - discordPostCommandSchemaId as commandSchemaId, - discordPostReceiptDefinition, - discordPostReceiptDocumentType as receiptDocumentType, -} from "./bifrost-discord-command-documents.mjs"; - -const repoRoot = resolve(import.meta.dirname, ".."); -const projectsRoot = resolve(repoRoot, ".."); - -// The gate's own provider store. Bifrost owns the Discord crossing, so an alarm -// is a command addressed to the gate, not a document dropped into somebody -// else's state. cultmesh-bridge-commands.mjs reads this same path by default. -const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc"); - -async function main() { - const [verb, ...rawArgs] = process.argv.slice(2); - const options = parseArgs(rawArgs); - - if (!verb || verb === "help" || verb === "--help" || verb === "-h") { - printHelp(); - return; - } - - switch (verb) { - case "publish-idunn-alarm": - await publishIdunnAlarm(options); - return; - default: - throw new Error(`Unknown command "${verb}". Run "node tools/operator-notification.mjs help".`); - } -} - -async function publishIdunnAlarm(options) { - const storePath = resolve(options.store ?? defaultStorePath); - const alarm = { - alarmId: readOptionOrEnv(options, "alarm-id", "IDUNN_ALARM_ID", "unknown-alarm"), - daemonId: readOptionOrEnv(options, "daemon-id", "IDUNN_ALARM_DAEMON_ID", "unknown-daemon"), - severity: readOptionOrEnv(options, "severity", "IDUNN_ALARM_SEVERITY", "operator-action-required"), - reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."), - raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()), - }; - // DISCORD_OWNER_ID is the established GameCult name for this, used by VoidBot's - // config schema. Read it first rather than minting another spelling. - const recipientId = - optionalString(options["recipient-id"]) ?? - optionalString(process.env.DISCORD_OWNER_ID) ?? - optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID); - if (!recipientId) { - throw new Error( - "Set --recipient-id, DISCORD_OWNER_ID, or IDUNN_OPERATOR_DISCORD_ID so Bifrost knows which operator to reach.", - ); - } - - const command = buildDiscordDmCommand(alarm, recipientId); - const waitSeconds = - Number.parseInt(readOptionOrEnv(options, "wait-seconds", "IDUNN_ALARM_WAIT_SECONDS", "0"), 10) || 0; - - // Build the runtime and definitions before honouring --dry-run. A dry run that - // skips the runtime cannot notice a broken document definition, which is how - // this publisher shipped unable to publish anything at all. - const { CultMesh, defineDocumentType } = loadCultMeshRuntime(); - const commandDefinition = discordPostCommandDefinition(defineDocumentType); - const receiptDefinition = discordPostReceiptDefinition(defineDocumentType); - - if (options["dry-run"] === "true") { - printJson({ - dryRun: true, - action: "publish-idunn-alarm", - storePath, - documentType: commandDocumentType, - schemaId: commandSchemaId, - command, - }); - return; - } - - const node = await CultMesh.createNode(storePath, { - documents: [commandDefinition, receiptDefinition], - }); - await node.put(commandDefinition, command.commandId, command); - await node.flush?.(); - - const receipt = - waitSeconds > 0 - ? await waitForReceipt(node, receiptDefinition, command.commandId, waitSeconds * 1000) - : undefined; - - printJson({ - ok: receipt ? receipt.ok === true : true, - action: "publish-idunn-alarm", - storePath, - documentType: commandDocumentType, - schemaId: commandSchemaId, - commandId: command.commandId, - delivery: receipt - ? { - status: receipt.status, - ok: receipt.ok, - messageId: receipt.messageId, - transport: receipt.transport, - error: receipt.error, - } - : { status: "pending", note: "Not awaited. Pass --wait-seconds to confirm delivery." }, - }); - - // A receipt that says the crossing failed must fail the caller. Idunn reads - // the exit code; reporting ok:false on stdout and exiting 0 would hand it a - // success it can act on. - if (receipt && receipt.ok !== true) { - process.exitCode = 1; - } -} - -function buildDiscordDmCommand(alarm, recipientId) { - const commandId = `idunn-alarm-${createHash("sha1").update(JSON.stringify(alarm)).digest("hex").slice(0, 16)}`; - const content = [ - "Idunn needs operator intervention.", - "", - `Service: ${alarm.daemonId}`, - `Severity: ${alarm.severity}`, - `Reason: ${alarm.reason}`, - `Alarm: ${alarm.alarmId}`, - `Raised: ${alarm.raisedAt}`, - "", - "Automatic recovery either was not authorized or failed.", - ].join("\n"); - const requestedAt = new Date().toISOString(); - - const command = { - schemaName: commandDocumentType, - schemaVersion: commandSchemaId, - commandId, - command: "discord-dm", - status: "pending", - requestedBy: "idunn", - requestedAt, - updatedAt: requestedAt, - source: { kind: "idunn-operator-alarm", id: alarm.alarmId }, - actor: { id: "idunn", displayName: "Idunn" }, - payload: { recipientId, content }, - }; - - const commandUri = optionalString(process.env.BIFROST_CULTMESH_COMMAND_URI); - if (commandUri) { - command.commandUri = commandUri; - } - return command; -} - -async function waitForReceipt(node, definition, commandId, timeoutMs) { - const startedAt = Date.now(); - while (Date.now() - startedAt <= timeoutMs) { - await node.cache?.pullAllBackingStores?.(); - const receipt = await node.get(definition, commandId); - if (receipt) { - return receipt; - } - await new Promise((done) => setTimeout(done, 250)); - } - throw new Error( - `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for a ${receiptDocumentType} for ${commandId}. ` + - "Is the Bifrost bridge command pump running?", - ); -} - -function loadCultMeshRuntime() { - const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"), - resolve(projectsRoot, "CultMeshTS", "package.json"), - ]; - - for (const packageJson of candidates) { - if (!existsSync(packageJson)) { - continue; - } - try { - const requireCult = createRequire(packageJson); - const { CultMesh } = requireCult("cultmesh-ts"); - const { defineDocumentType } = requireCult("cultcache-ts"); - if (CultMesh && defineDocumentType) { - return { CultMesh, defineDocumentType }; - } - } catch { - } - } - - throw new Error("CultMesh/CultCache TypeScript runtime is unavailable."); -} - -function readOptionOrEnv(options, optionName, envName, fallback) { - return optionalString(options[optionName]) ?? optionalString(process.env[envName]) ?? fallback; -} - -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 optionalString(value) { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function printJson(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function printHelp() { - process.stdout.write(`Bifrost operator notification CultMesh publisher - -Publishes an Idunn operator alarm as a ${commandSchemaId} document addressed to -the Bifrost Discord gate. Bifrost owns the crossing; this tool only asks. The -bridge command pump turns the command into a DM and writes back a -${receiptDocumentType}.v1 receipt. - -Commands: - publish-idunn-alarm Publish an Idunn alarm as a discord-dm gate command - -Options: - --recipient-id Operator Discord user id (or DISCORD_OWNER_ID / - IDUNN_OPERATOR_DISCORD_ID) - --store Gate provider store (default: .bifrost/provider-store.cc) - --wait-seconds Wait for the delivery receipt instead of returning at publish - --dry-run Build the command and definitions without writing - -Examples: - node tools/operator-notification.mjs publish-idunn-alarm --dry-run - node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed" --wait-seconds 30 -`); -} - -main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -});