diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 9e5b372..20b98a5 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -131,6 +131,7 @@ async function deleteCommand(options) { } async function processOneCommand(node, command, options) { + const verb = resolveBridgeVerb(command); const running = { ...command, status: "running", @@ -147,9 +148,10 @@ async function processOneCommand(node, command, options) { try { const args = [ "tools/bifrost-bridge.mjs", - "discord-post", - "--channel-id", - requireString(payload.channelId, "payload.channelId"), + verb, + ...(verb === "discord-dm" + ? ["--recipient-id", requireString(payload.recipientId, "payload.recipientId")] + : ["--channel-id", requireString(payload.channelId, "payload.channelId")]), "--content-file", contentPath, "--cultmesh-command-id", @@ -161,9 +163,11 @@ async function processOneCommand(node, command, options) { "--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); + 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, "--receipt-store", options["receipt-store"]); const result = spawnSync(process.execPath, args, { @@ -178,11 +182,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 discord-post receipt"); + const posted = parseJson(result.stdout, `bifrost bridge ${verb} receipt`); const receipt = buildReceipt(command, { status: "completed", ok: true, - action: "discord-post", + action: verb, channelId: posted.channelId, messageId: posted.messageId, transport: posted.transport, @@ -202,7 +206,7 @@ async function processOneCommand(node, command, options) { const receipt = buildReceipt(command, { status: "failed", ok: false, - action: "discord-post", + action: verb, canonicalReceiptId: `crossing_${command.commandId}`, error: error instanceof Error ? error.message : String(error), }); @@ -220,6 +224,18 @@ 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 { @@ -251,7 +267,7 @@ function listCommandRecords(node) { } return node.cache.getAll(commandDefinition()) .map(unwrapRecord) - .filter((entry) => entry && entry.command === "discord-post" && entry.commandId); + .filter((entry) => entry && entry.commandId && supportedBridgeVerbs.has(entry.command ?? "discord-post")); } async function openCommandNode(storePath) { diff --git a/tools/operator-notification.mjs b/tools/operator-notification.mjs index ac4cc10..685f41b 100644 --- a/tools/operator-notification.mjs +++ b/tools/operator-notification.mjs @@ -3,28 +3,37 @@ 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, ".."); -const defaultStorePath = resolve(projectsRoot, "VoidBot", ".voidbot", "status", "cultmesh", "voidbot-swarm-state.cc"); -const requestDocumentType = "gamecult.operator_dm_request"; -const requestSchemaId = "gamecult.operator_dm_request.v1"; + +// 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 [command, ...rawArgs] = process.argv.slice(2); + const [verb, ...rawArgs] = process.argv.slice(2); const options = parseArgs(rawArgs); - if (!command || command === "help" || command === "--help" || command === "-h") { + if (!verb || verb === "help" || verb === "--help" || verb === "-h") { printHelp(); return; } - switch (command) { + switch (verb) { case "publish-idunn-alarm": await publishIdunnAlarm(options); return; default: - throw new Error(`Unknown command "${command}". Run "node tools/operator-notification.mjs help".`); + throw new Error(`Unknown command "${verb}". Run "node tools/operator-notification.mjs help".`); } } @@ -37,94 +46,81 @@ async function publishIdunnAlarm(options) { reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."), raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()), }; - const request = buildOperatorDmRequest(alarm); + const recipientId = + optionalString(options["recipient-id"]) ?? + optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID) ?? + optionalString(process.env.BIFROST_OPERATOR_DISCORD_ID) ?? + optionalString(process.env.OWNER_DISCORD_ID); + if (!recipientId) { + throw new Error( + "Set --recipient-id, IDUNN_OPERATOR_DISCORD_ID, BIFROST_OPERATOR_DISCORD_ID, or OWNER_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: requestDocumentType, - schemaId: requestSchemaId, - request, + documentType: commandDocumentType, + schemaId: commandSchemaId, + command, }); return; } - const { CultMesh, defineDocumentType } = loadCultMeshRuntime(); - const requestDefinition = defineDocumentType({ - type: requestDocumentType, - schemaName: requestDocumentType, - schemaId: requestSchemaId, - schemaVersion: requestSchemaId, - contentHash: requestSchemaId, - global: false, - name: "requestId", - schema: parseObjectDocument("Operator DM request"), - }); const node = await CultMesh.createNode(storePath, { - documents: [ - requestDefinition, - defineDocumentType({ - type: "voidbot.swarm_state_snapshot", - schemaName: "voidbot.swarm_state_snapshot", - schemaId: "voidbot.swarm_state_snapshot.v1", - schemaVersion: "voidbot.swarm_state_snapshot.v1", - contentHash: "voidbot.swarm_state_snapshot.v1", - global: false, - schema: parseObjectDocument("VoidBot swarm snapshot"), - }), - defineDocumentType({ - type: "gamecult.eve.provider_advertisement", - schemaName: "gamecult.eve.provider_advertisement", - schemaId: "gamecult.eve.provider_advertisement.v1", - schemaVersion: "gamecult.eve.provider_advertisement.v1", - contentHash: "gamecult.eve.provider_advertisement.v1", - global: false, - name: "providerId", - schema: parseObjectDocument("Eve provider advertisement"), - }), - defineDocumentType({ - type: "gamecult.eve.surface_state", - schemaName: "gamecult.eve.surface_state", - schemaId: "gamecult.eve.surface_state.v1", - schemaVersion: "gamecult.eve.surface_state.v1", - contentHash: "gamecult.eve.surface_state.v1", - global: false, - name: "providerId", - schema: parseObjectDocument("Eve surface state"), - }), - defineDocumentType({ - type: "gamecult.eve.interface_binding", - schemaName: "gamecult.eve.interface_binding", - schemaId: "gamecult.eve.interface_binding.v1", - schemaVersion: "gamecult.eve.interface_binding.v1", - contentHash: "gamecult.eve.interface_binding.v1", - global: false, - name: "bindingId", - schema: parseObjectDocument("Eve interface binding"), - }), - ], + documents: [commandDefinition, receiptDefinition], }); - await node.put(requestDefinition, request.requestId, request); + 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: true, + ok: receipt ? receipt.ok === true : true, action: "publish-idunn-alarm", storePath, - documentType: requestDocumentType, - schemaId: requestSchemaId, - requestId: request.requestId, + 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 buildOperatorDmRequest(alarm) { - const requestId = `idunn-alarm-${createHash("sha1") - .update(JSON.stringify(alarm)) - .digest("hex") - .slice(0, 16)}`; - const message = [ +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}`, @@ -135,23 +131,43 @@ function buildOperatorDmRequest(alarm) { "", "Automatic recovery either was not authorized or failed.", ].join("\n"); + const requestedAt = new Date().toISOString(); - return { - requestId, - command: "owner.dm.send", + const command = { + schemaName: commandDocumentType, + schemaVersion: commandSchemaId, + commandId, + command: "discord-dm", status: "pending", - service: alarm.daemonId, - sourceId: alarm.alarmId, - severity: alarm.severity, - reason: alarm.reason, - message, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), requestedBy: "idunn", - transportOwner: "bifrost", - schemaName: requestDocumentType, - schemaVersion: requestSchemaId, + 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() { @@ -178,17 +194,6 @@ function loadCultMeshRuntime() { 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 readOptionOrEnv(options, optionName, envName, fallback) { return optionalString(options[optionName]) ?? optionalString(process.env[envName]) ?? fallback; } @@ -227,12 +232,24 @@ function printJson(value) { 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 gamecult.operator_dm_request.v1 + publish-idunn-alarm Publish an Idunn alarm as a discord-dm gate command + +Options: + --recipient-id Operator Discord user id (or IDUNN_OPERATOR_DISCORD_ID / + BIFROST_OPERATOR_DISCORD_ID / OWNER_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" + node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed" --wait-seconds 30 `); }