Tests only, plus wording. No change to what the reader or bridge does.
- A spawn timeout and a spawn error (ENOBUFS from a bridge that floods
stdout) record unknown, are never re-posted, and block the closure.
WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS overrides the 60 s timeout so the test
reaches that branch; the default is unchanged.
- A 500/502/503/429 answer to the message POST exits 75 from the bridge, and
through the reader is retried with the same nonce until it completes.
- allowed_mentions {parse: []} is asserted in the posted body; status exits
non-zero while an entry is unknown and zero when none is.
- The bridge comment and docs/watchdog-notice.md no longer call a non-2xx
answer "definite": it is retried with the same nonce, relying on
enforce_nonce inside Discord's window, and may double-send if Discord acted
on a 5xx outside it.
Builds. Two tests fail on Windows (SIGKILL status), as before; they pass on
Yggdrasil.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1476 lines
48 KiB
JavaScript
1476 lines
48 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
beginCrossingReceipt,
|
|
crossingProvenanceFromOptions,
|
|
} from "./bifrost-crossing-documents.mjs";
|
|
import {
|
|
buildAuthorizedRelease,
|
|
canonicalCommitSha,
|
|
canonicalRef,
|
|
canonicalRepository,
|
|
openRepositoryReleaseAuthorityStore,
|
|
releaseAuthorityId,
|
|
revokeAuthorizedRelease,
|
|
} from "./bifrost-repository-release-authority.mjs";
|
|
|
|
const PERSONA_WEBHOOK_NAME = "Bifrost Persona Pipe";
|
|
const PERSONA_WEBHOOK_CACHE_PATH = resolve(".bifrost/discord-webhook-cache.json");
|
|
const THREAD_CHANNEL_TYPES = new Set([10, 11, 12]);
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(scriptDir, "..");
|
|
const projectsRoot = resolve(repoRoot, "..");
|
|
|
|
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 "github-draft-pr":
|
|
await createGitHubDraftPr(options);
|
|
return;
|
|
case "github-pr-comment":
|
|
await commentGitHubPr(options);
|
|
return;
|
|
case "discord-post":
|
|
await postDiscordMessage(options);
|
|
return;
|
|
case "discord-dm":
|
|
await sendDiscordDm(options);
|
|
return;
|
|
case "reddit-post":
|
|
await postRedditThread(options);
|
|
return;
|
|
case "other-request":
|
|
await requestOtherBridgeAction(options);
|
|
return;
|
|
case "authorize-release":
|
|
await authorizeRepositoryRelease(options);
|
|
return;
|
|
case "revoke-release":
|
|
await revokeRepositoryRelease(options);
|
|
return;
|
|
default:
|
|
throw new Error(`Unknown command "${command}". Run "node tools/bifrost-bridge.mjs help".`);
|
|
}
|
|
}
|
|
|
|
async function authorizeRepositoryRelease(options) {
|
|
ensureGitHubBridgeGate(options);
|
|
const commandId = optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID);
|
|
if (!commandId) throw new Error("authorize-release requires BIFROST_CULTMESH_COMMAND_ID or --cultmesh-command-id.");
|
|
const repository = canonicalRepository(requireOption(options, "repository"));
|
|
const ref = canonicalRef(requireOption(options, "ref"));
|
|
const commitSha = canonicalCommitSha(requireOption(options, "commit-sha"));
|
|
const policyDecisionId = requireOption(options, "policy-decision-id");
|
|
const authorityReference = requireOption(options, "authority-ref");
|
|
const actorIdentity = requireOption(options, "identity");
|
|
const sourceKind = requireOption(options, "source-kind");
|
|
const sourceId = requireOption(options, "source-id");
|
|
const epiphanyRunId = requireOption(options, "epiphany-run-id");
|
|
const epiphanyLaneId = requireOption(options, "epiphany-lane-id");
|
|
const epiphanyAgentIdentity = requireOption(options, "epiphany-agent-identity");
|
|
const remoteUrl = options["remote-url"] ?? `https://github.com/${repository}.git`;
|
|
const gitCommand = optionalString(options["git-executable"]) ?? resolveGitCommand();
|
|
const gitPrefixArgs = optionalString(options["git-exec-args"])?.split("\u001f").filter(Boolean) ?? [];
|
|
const remote = run(gitCommand, [...gitPrefixArgs, "ls-remote", "--refs", remoteUrl, ref], repoRoot).stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
if (remote.length !== 1) throw new Error(`Remote ref ${repository}:${ref} did not resolve exactly once.`);
|
|
const [remoteSha, remoteRef] = remote[0].trim().split(/\s+/);
|
|
if (canonicalCommitSha(remoteSha) !== commitSha || remoteRef !== ref) {
|
|
throw new Error(`Remote ref ${repository}:${ref} does not exactly match requested commit ${commitSha}.`);
|
|
}
|
|
const storePath = resolve(options["release-authority-store"] ?? process.env.BIFROST_REPOSITORY_RELEASE_AUTHORITY_STORE ?? ".bifrost/repository-release-authority.cc");
|
|
const store = await openRepositoryReleaseAuthorityStore(storePath);
|
|
const authorityId = releaseAuthorityId(repository, ref, commitSha);
|
|
const existing = store.get(authorityId);
|
|
if (existing) {
|
|
if (existing.commandId === commandId && existing.status === "authorized") {
|
|
printJson({ ok: true, action: "authorize-release", authority: existing, idempotent: true });
|
|
return;
|
|
}
|
|
throw new Error(`Release authority ${authorityId} already exists; immutable authority records cannot be replaced.`);
|
|
}
|
|
const competing = store.getAll().find((authority) =>
|
|
authority.repositoryFullName === repository &&
|
|
authority.upstreamRef === ref &&
|
|
authority.status === "authorized"
|
|
);
|
|
if (competing) {
|
|
throw new Error(
|
|
`Release authority ${competing.authorityId} already authorizes ${repository}:${ref}; revoke it before authorizing another commit.`,
|
|
);
|
|
}
|
|
const externalReceiptUrl = `https://github.com/${repository}/commit/${commitSha}`;
|
|
const crossing = await beginCrossingReceipt({ ...options, "receipt-id": undefined }, {
|
|
commandId,
|
|
crossingKind: "github.repository_release_verification",
|
|
actor: { kind: "service", name: actorIdentity, bifrostIdentity: actorIdentity },
|
|
source: { kind: sourceKind, id: sourceId },
|
|
authority: { authorityRef: authorityReference, policyDecisionId },
|
|
epiphany: { runId: epiphanyRunId, laneId: epiphanyLaneId, agentIdentity: epiphanyAgentIdentity },
|
|
target: { surface: "GitHub", repositoryFullName: repository, upstreamRef: ref, commitSha },
|
|
});
|
|
await crossing.start();
|
|
await crossing.complete({ externalReceipt: { url: externalReceiptUrl, id: commitSha, repositoryFullName: repository, upstreamRef: ref, commitSha } });
|
|
const authority = await store.put(buildAuthorizedRelease({
|
|
repositoryFullName: repository, upstreamRef: ref, commitSha, commandId,
|
|
crossingReceiptId: crossing.receiptId, policyDecisionId, authorityReference,
|
|
actorIdentity, sourceKind, sourceId, epiphanyRunId, epiphanyLaneId,
|
|
epiphanyAgentIdentity, externalReceiptUrl, externalReceiptId: commitSha,
|
|
authorizedAt: new Date().toISOString(), expiresAt: optionalString(options["expires-at"]) ?? "",
|
|
}));
|
|
printJson({ ok: true, action: "authorize-release", authority });
|
|
}
|
|
|
|
async function revokeRepositoryRelease(options) {
|
|
ensureGitHubBridgeGate(options);
|
|
const commandId = optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID);
|
|
if (!commandId) throw new Error("revoke-release requires BIFROST_CULTMESH_COMMAND_ID or --cultmesh-command-id.");
|
|
const repository = canonicalRepository(requireOption(options, "repository"));
|
|
const ref = canonicalRef(requireOption(options, "ref"));
|
|
const commitSha = canonicalCommitSha(requireOption(options, "commit-sha"));
|
|
const reason = requireOption(options, "reason");
|
|
const storePath = resolve(options["release-authority-store"] ?? process.env.BIFROST_REPOSITORY_RELEASE_AUTHORITY_STORE ?? ".bifrost/repository-release-authority.cc");
|
|
const store = await openRepositoryReleaseAuthorityStore(storePath);
|
|
const authorityId = releaseAuthorityId(repository, ref, commitSha);
|
|
const current = store.get(authorityId);
|
|
if (!current) throw new Error(`Release authority ${authorityId} does not exist.`);
|
|
const authority = await store.put(revokeAuthorizedRelease(current, { commandId, reason, revokedAt: new Date().toISOString() }));
|
|
printJson({ ok: true, action: "revoke-release", authority });
|
|
}
|
|
|
|
function loadBifrostLocalEnv() {
|
|
if (process.env.BIFROST_SKIP_LOCAL_ENV === "true") {
|
|
return;
|
|
}
|
|
|
|
loadLocalEnv(resolve(repoRoot, ".env"));
|
|
loadLocalEnv(resolve(projectsRoot, "VoidBot", ".env"));
|
|
}
|
|
|
|
async function createGitHubDraftPr(options) {
|
|
ensureGitHubBridgeGate(options);
|
|
const repoRoot = resolve(requireOption(options, "repo-root"));
|
|
const identity = slugify(requireOption(options, "identity"));
|
|
const title = requireOption(options, "title");
|
|
const relativePath = normalizeRelativePath(requireOption(options, "path"));
|
|
const body = await readOptionText(options, "body", "body-file", "Draft PR opened by Bifrost.");
|
|
const content = await readOptionText(options, "content", "content-file");
|
|
const base = options.base ?? "main";
|
|
const branch = options.branch ?? `bifrost/${identity}/${slugify(title)}-${timestampSlug(new Date())}`;
|
|
const commitMessage = options["commit-message"] ?? title;
|
|
const allowDirty = options["allow-dirty"] === "true";
|
|
const dryRun = options["dry-run"] === "true";
|
|
const targetRepositoryFullName =
|
|
optionalString(options["target-repository-full-name"]) ??
|
|
detectGitHubRepositoryFullName(options, repoRoot);
|
|
|
|
const originalBranch = git(["branch", "--show-current"], repoRoot).stdout.trim();
|
|
const status = git(["status", "--short"], repoRoot).stdout.trim();
|
|
if (status.length > 0 && !allowDirty && !dryRun) {
|
|
throw new Error(`Target repo has uncommitted changes. Refusing bridge write without --allow-dirty.\n${status}`);
|
|
}
|
|
|
|
const targetPath = resolve(repoRoot, relativePath);
|
|
if (!targetPath.toLowerCase().startsWith(`${repoRoot.toLowerCase()}\\`) && targetPath.toLowerCase() !== repoRoot.toLowerCase()) {
|
|
throw new Error(`Target path escapes repo root: ${relativePath}`);
|
|
}
|
|
|
|
if (dryRun) {
|
|
printJson({
|
|
dryRun: true,
|
|
action: "github-draft-pr",
|
|
repoRoot,
|
|
identity,
|
|
title,
|
|
path: relativePath,
|
|
base,
|
|
branch,
|
|
originalBranch,
|
|
commitMessage,
|
|
dirty: status.length > 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bridgeAction = await beginBridgeAction(options, {
|
|
actorKind: "Agent",
|
|
actorName: identity,
|
|
targetSurface: "GitHub",
|
|
actionKind: "GitHubDraftPullRequest",
|
|
targetRepositoryFullName,
|
|
targetLocator: relativePath,
|
|
title,
|
|
summary: body,
|
|
});
|
|
|
|
let pushed = false;
|
|
let prUrl = "";
|
|
let commitSha = "";
|
|
let changedPaths = [];
|
|
try {
|
|
try {
|
|
await bridgeAction?.start();
|
|
git(["switch", "-c", branch], repoRoot);
|
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
await writeFile(targetPath, content, "utf8");
|
|
git(["add", "--", relativePath], repoRoot);
|
|
git(["commit", "-m", commitMessage], repoRoot);
|
|
commitSha = git(["rev-parse", "HEAD"], repoRoot).stdout.trim();
|
|
changedPaths = git(["diff-tree", "--no-commit-id", "--name-only", "-r", commitSha], repoRoot)
|
|
.stdout
|
|
.split(/\r?\n/)
|
|
.map((path) => path.trim())
|
|
.filter(Boolean);
|
|
git(["push", "-u", "origin", branch], repoRoot, {
|
|
env: {
|
|
BIFROST_GITHUB_PUSH_AUTHORIZED: "true",
|
|
},
|
|
});
|
|
pushed = true;
|
|
|
|
const pr = runGitHubCli(options, [
|
|
"pr",
|
|
"create",
|
|
"--draft",
|
|
"--title",
|
|
title,
|
|
"--body",
|
|
body,
|
|
"--base",
|
|
base,
|
|
"--head",
|
|
branch,
|
|
], repoRoot, {
|
|
env: {
|
|
BIFROST_GITHUB_MUTATION_AUTHORIZED: "true",
|
|
},
|
|
});
|
|
prUrl = pr.stdout.trim();
|
|
await bridgeAction?.complete({
|
|
receiptUrl: prUrl,
|
|
externalReceiptId: extractGitHubNumber(prUrl) ?? branch,
|
|
receiptPayload: JSON.stringify(buildReceiptPayload(options, {
|
|
branch,
|
|
base,
|
|
path: relativePath,
|
|
changedPaths,
|
|
commitSha,
|
|
commitMessage,
|
|
prUrl,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
await bridgeAction?.fail(error);
|
|
throw error;
|
|
}
|
|
} finally {
|
|
if (originalBranch) {
|
|
git(["switch", originalBranch], repoRoot, { allowFailure: true });
|
|
}
|
|
}
|
|
|
|
printJson({
|
|
action: "github-draft-pr",
|
|
ok: true,
|
|
repoRoot,
|
|
identity,
|
|
title,
|
|
path: relativePath,
|
|
base,
|
|
branch,
|
|
pushed,
|
|
commitSha,
|
|
changedPaths,
|
|
prUrl,
|
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
|
provenance: buildBridgeProvenance(options),
|
|
});
|
|
}
|
|
|
|
async function commentGitHubPr(options) {
|
|
ensureGitHubBridgeGate(options);
|
|
const repoRoot = resolve(requireOption(options, "repo-root"));
|
|
const identity = slugify(requireOption(options, "identity"));
|
|
const pr = requireOption(options, "pr");
|
|
const content = await readOptionText(options, "content", "content-file");
|
|
const dryRun = options["dry-run"] === "true";
|
|
const body = `${identity} says:\n\n${content.trim()}`;
|
|
const targetRepositoryFullName =
|
|
optionalString(options["target-repository-full-name"]) ??
|
|
detectGitHubRepositoryFullName(options, repoRoot);
|
|
|
|
if (dryRun) {
|
|
printJson({
|
|
dryRun: true,
|
|
action: "github-pr-comment",
|
|
repoRoot,
|
|
identity,
|
|
pr,
|
|
body,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bridgeAction = await beginBridgeAction(options, {
|
|
actorKind: "Agent",
|
|
actorName: identity,
|
|
targetSurface: "GitHub",
|
|
actionKind: "GitHubPullRequestComment",
|
|
targetRepositoryFullName,
|
|
targetLocator: `pull/${pr}`,
|
|
title: `PR comment on #${pr}`,
|
|
summary: content,
|
|
});
|
|
|
|
try {
|
|
await bridgeAction?.start();
|
|
const payloadPath = resolve(repoRoot, ".bifrost", `github-pr-comment-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
|
|
await mkdir(dirname(payloadPath), { recursive: true });
|
|
await writeFile(payloadPath, JSON.stringify({ body }), "utf8");
|
|
let comment;
|
|
try {
|
|
comment = parseRequiredJson(
|
|
runGitHubCli(
|
|
options,
|
|
[
|
|
"api",
|
|
`repos/${targetRepositoryFullName}/issues/${pr}/comments`,
|
|
"--method", "POST",
|
|
"--input", payloadPath,
|
|
],
|
|
repoRoot,
|
|
{
|
|
env: {
|
|
BIFROST_GITHUB_MUTATION_AUTHORIZED: "true",
|
|
},
|
|
},
|
|
).stdout,
|
|
"GitHub PR comment response",
|
|
);
|
|
} finally {
|
|
await unlink(payloadPath).catch(() => {});
|
|
}
|
|
const receiptUrl = optionalString(comment.html_url);
|
|
const externalReceiptId = comment.id === undefined || comment.id === null ? "" : String(comment.id);
|
|
if (!receiptUrl || !externalReceiptId) {
|
|
throw new Error("GitHub PR comment response returned no concrete receipt URL or comment id.");
|
|
}
|
|
await bridgeAction?.complete({
|
|
receiptUrl,
|
|
externalReceiptId,
|
|
receiptPayload: JSON.stringify(buildReceiptPayload(options, {
|
|
pullRequestNumber: Number(pr),
|
|
repositoryFullName: targetRepositoryFullName,
|
|
issueCommentId: comment.id,
|
|
issueCommentNodeId: optionalString(comment.node_id) ?? "",
|
|
issueCommentUrl: receiptUrl,
|
|
author: identity,
|
|
body,
|
|
})),
|
|
});
|
|
printJson({
|
|
action: "github-pr-comment",
|
|
ok: true,
|
|
repoRoot,
|
|
identity,
|
|
pr,
|
|
receiptUrl,
|
|
externalReceiptId,
|
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
|
provenance: buildBridgeProvenance(options),
|
|
});
|
|
} catch (error) {
|
|
await bridgeAction?.fail(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function postDiscordMessage(options) {
|
|
ensureBridgeReceiptGate(options);
|
|
const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN;
|
|
const channelId = requireOption(options, "channel-id");
|
|
const content = await readOptionText(options, "content", "content-file");
|
|
const replyToMessageId = optionalString(options["reply-to-message-id"]);
|
|
const personaName = optionalString(options["persona-name"]);
|
|
const personaAvatarUrl = optionalString(options["persona-avatar-url"]);
|
|
const dryRun = options["dry-run"] === "true";
|
|
|
|
if (dryRun) {
|
|
printJson({
|
|
dryRun: true,
|
|
action: "discord-post",
|
|
channelId,
|
|
content,
|
|
replyToMessageId,
|
|
personaName,
|
|
personaAvatarUrl,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bridgeAction = await beginBridgeAction(options, {
|
|
actorKind: personaName ? "Persona" : "Service",
|
|
actorName: personaName ?? "bifrost",
|
|
targetSurface: "Discord",
|
|
actionKind: "DiscordPost",
|
|
targetRepositoryFullName: options["target-repository-full-name"] ?? "",
|
|
targetLocator: `channel/${channelId}`,
|
|
title: personaName ? `Discord post from ${personaName}` : "Discord post",
|
|
summary: content,
|
|
});
|
|
|
|
let result;
|
|
try {
|
|
await bridgeAction?.start();
|
|
if (!token) {
|
|
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before posting to Discord.");
|
|
}
|
|
result = personaName
|
|
? await postDiscordPersonaMessage(token, channelId, content, {
|
|
personaName,
|
|
personaAvatarUrl,
|
|
replyToMessageId,
|
|
})
|
|
: await postDiscordBotMessage(token, channelId, content, replyToMessageId);
|
|
await bridgeAction?.complete({
|
|
receiptUrl: `https://discord.com/channels/${result.guildId ?? "@me"}/${channelId}/${result.id}`,
|
|
externalReceiptId: result.id,
|
|
receiptPayload: JSON.stringify(buildReceiptPayload(options, {
|
|
channelId,
|
|
messageId: result.id,
|
|
guildId: result.guildId ?? "",
|
|
transport: result.transport,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
await bridgeAction?.fail(error);
|
|
throw error;
|
|
}
|
|
|
|
printJson({
|
|
action: "discord-post",
|
|
ok: true,
|
|
channelId,
|
|
messageId: result.id,
|
|
transport: result.transport,
|
|
url: `https://discord.com/channels/${result.guildId ?? "@me"}/${channelId}/${result.id}`,
|
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
|
provenance: buildBridgeProvenance(options),
|
|
});
|
|
}
|
|
|
|
// discord-dm exits NOT_SENT_EXIT only when it knows no message was delivered:
|
|
// anything before the message POST, or a non-2xx answer to it. A 5xx or 429
|
|
// is not proof of non-delivery (Discord may have created the message before
|
|
// answering): the caller retries with the same nonce and relies on
|
|
// enforce_nonce inside Discord's window. A failure after the POST was sent
|
|
// (network error, receipt completion, output) keeps exit 1, so the caller
|
|
// treats the outcome as unknown.
|
|
const NOT_SENT_EXIT = 75;
|
|
class NotSentError extends Error {}
|
|
class DiscordRejection extends Error {}
|
|
|
|
async function sendDiscordDm(options) {
|
|
const state = { postStarted: false };
|
|
try {
|
|
await deliverDiscordDm(options, state);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw state.postStarted && !(error instanceof DiscordRejection) ? error : new NotSentError(message);
|
|
}
|
|
}
|
|
|
|
async function deliverDiscordDm(options, state) {
|
|
ensureBridgeReceiptGate(options);
|
|
const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN;
|
|
const recipientId = requireOption(options, "recipient-id");
|
|
const content = await readOptionText(options, "content", "content-file");
|
|
const nonce = optionalString(options.nonce);
|
|
if (options.nonce !== undefined && (!nonce || nonce.length > 25)) {
|
|
throw new Error("--nonce must be 1 to 25 characters.");
|
|
}
|
|
const dryRun = options["dry-run"] === "true";
|
|
|
|
if (dryRun) {
|
|
printJson({
|
|
dryRun: true,
|
|
action: "discord-dm",
|
|
recipientId,
|
|
content,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bridgeAction = await beginBridgeAction(options, {
|
|
actorKind: "Service",
|
|
actorName: "bifrost",
|
|
targetSurface: "Discord",
|
|
actionKind: "DiscordDirectMessage",
|
|
targetRepositoryFullName: options["target-repository-full-name"] ?? "",
|
|
targetLocator: `recipient/${recipientId}`,
|
|
title: "Discord direct message",
|
|
summary: content,
|
|
});
|
|
|
|
let channelId;
|
|
let result;
|
|
try {
|
|
await bridgeAction?.start();
|
|
if (!token) {
|
|
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM.");
|
|
}
|
|
channelId = await openDiscordDmChannel(token, recipientId);
|
|
state.postStarted = true;
|
|
result = await postDiscordBotMessage(token, channelId, content, undefined, nonce);
|
|
await bridgeAction?.complete({
|
|
receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`,
|
|
externalReceiptId: result.id,
|
|
receiptPayload: JSON.stringify(buildReceiptPayload(options, {
|
|
recipientId,
|
|
channelId,
|
|
messageId: result.id,
|
|
transport: result.transport,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
await bridgeAction?.fail(error);
|
|
throw error;
|
|
}
|
|
printJson({
|
|
action: "discord-dm",
|
|
ok: true,
|
|
recipientId,
|
|
channelId,
|
|
messageId: result.id,
|
|
transport: result.transport,
|
|
url: `https://discord.com/channels/@me/${channelId}/${result.id}`,
|
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
|
provenance: buildBridgeProvenance(options),
|
|
});
|
|
}
|
|
|
|
async function postRedditThread(options) {
|
|
ensureBridgeReceiptGate(options);
|
|
const subreddit = normalizeSubreddit(options.subreddit ?? process.env.BIFROST_REDDIT_SUBREDDIT ?? "GameCultOrg");
|
|
const title = requireOption(options, "title");
|
|
const content = await readOptionText(options, "content", "content-file");
|
|
const personaName = optionalString(options["persona-name"]);
|
|
const personaFlairId = optionalString(options["persona-flair-id"]);
|
|
const personaFlairText = optionalString(options["persona-flair-text"]) ?? personaName;
|
|
const dryRun = options["dry-run"] === "true";
|
|
|
|
if (dryRun) {
|
|
printJson({
|
|
dryRun: true,
|
|
action: "reddit-post",
|
|
subreddit,
|
|
title,
|
|
content,
|
|
personaName,
|
|
personaFlairId,
|
|
personaFlairText,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bridgeAction = await beginBridgeAction(options, {
|
|
actorKind: personaName ? "Persona" : "Service",
|
|
actorName: personaName ?? "bifrost",
|
|
targetSurface: "Reddit",
|
|
actionKind: "RedditPost",
|
|
targetRepositoryFullName: options["target-repository-full-name"] ?? "",
|
|
targetLocator: `r/${subreddit}`,
|
|
title,
|
|
summary: content,
|
|
});
|
|
|
|
let result;
|
|
try {
|
|
await bridgeAction?.start();
|
|
const accessToken = await getRedditAccessToken();
|
|
result = await submitRedditSelfPost(accessToken, {
|
|
subreddit,
|
|
title,
|
|
content,
|
|
flairId: personaFlairId,
|
|
flairText: personaFlairText,
|
|
});
|
|
await bridgeAction?.complete({
|
|
receiptUrl: result.url,
|
|
externalReceiptId: result.thingId,
|
|
receiptPayload: JSON.stringify(buildReceiptPayload(options, {
|
|
subreddit,
|
|
thingId: result.thingId,
|
|
url: result.url,
|
|
personaName,
|
|
personaFlairId,
|
|
personaFlairText,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
await bridgeAction?.fail(error);
|
|
throw error;
|
|
}
|
|
|
|
printJson({
|
|
action: "reddit-post",
|
|
ok: true,
|
|
subreddit,
|
|
title,
|
|
personaName,
|
|
personaFlairId,
|
|
personaFlairText,
|
|
thingId: result.thingId,
|
|
url: result.url,
|
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
|
provenance: buildBridgeProvenance(options),
|
|
});
|
|
}
|
|
|
|
async function requestOtherBridgeAction(options) {
|
|
ensureBridgeReceiptGate(options);
|
|
const identity = slugify(requireOption(options, "identity"));
|
|
const targetLocator = requireOption(options, "target-locator");
|
|
const surfaceName = optionalString(options["surface-name"]) ?? "future-surface";
|
|
const content = await readOptionText(options, "content", "content-file", "");
|
|
const title = optionalString(options.title) ?? `Outside-world bridge request for ${surfaceName}`;
|
|
const dryRun = options["dry-run"] === "true";
|
|
|
|
if (dryRun) {
|
|
printJson({
|
|
dryRun: true,
|
|
action: "other-request",
|
|
identity,
|
|
surfaceName,
|
|
targetLocator,
|
|
title,
|
|
content,
|
|
provenance: buildBridgeProvenance(options),
|
|
});
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
"other-request was a receipt-only HTTP bridge hatch and has been removed. " +
|
|
"Define a typed Bifrost CultMesh command/receipt document for this surface before executing it.",
|
|
);
|
|
}
|
|
|
|
async function getRedditAccessToken() {
|
|
const clientId = optionalString(process.env.BIFROST_REDDIT_CLIENT_ID ?? process.env.REDDIT_CLIENT_ID);
|
|
const clientSecret = process.env.BIFROST_REDDIT_CLIENT_SECRET ?? process.env.REDDIT_CLIENT_SECRET ?? "";
|
|
const refreshToken = optionalString(process.env.BIFROST_REDDIT_REFRESH_TOKEN ?? process.env.REDDIT_REFRESH_TOKEN);
|
|
const userAgent = getRedditUserAgent();
|
|
|
|
if (!clientId || !refreshToken) {
|
|
throw new Error(
|
|
"Set BIFROST_REDDIT_CLIENT_ID and BIFROST_REDDIT_REFRESH_TOKEN before posting to Reddit. " +
|
|
"Set BIFROST_REDDIT_CLIENT_SECRET when the Reddit app uses one.",
|
|
);
|
|
}
|
|
|
|
const response = await fetch("https://www.reddit.com/api/v1/access_token", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": userAgent,
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`Reddit token exchange failed with ${response.status}: ${text}`);
|
|
}
|
|
|
|
const payload = JSON.parse(text);
|
|
if (!payload.access_token) {
|
|
throw new Error("Reddit token exchange returned no access token.");
|
|
}
|
|
return payload.access_token;
|
|
}
|
|
|
|
async function submitRedditSelfPost(accessToken, input) {
|
|
const body = new URLSearchParams({
|
|
api_type: "json",
|
|
kind: "self",
|
|
sr: input.subreddit,
|
|
title: input.title,
|
|
text: input.content,
|
|
resubmit: "true",
|
|
send_replies: "true",
|
|
});
|
|
|
|
if (input.flairId) {
|
|
body.set("flair_id", input.flairId);
|
|
}
|
|
if (input.flairText) {
|
|
body.set("flair_text", input.flairText.slice(0, 64));
|
|
}
|
|
|
|
const response = await fetch("https://oauth.reddit.com/api/submit", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": getRedditUserAgent(),
|
|
},
|
|
body,
|
|
});
|
|
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`Reddit post failed with ${response.status}: ${text}`);
|
|
}
|
|
|
|
const payload = JSON.parse(text);
|
|
const errors = payload?.json?.errors ?? [];
|
|
if (errors.length > 0) {
|
|
throw new Error(`Reddit post failed: ${JSON.stringify(errors)}`);
|
|
}
|
|
|
|
const data = payload?.json?.data ?? {};
|
|
const thingId = data.name ?? data.id ?? "";
|
|
const url = data.url ?? (data.id ? `https://www.reddit.com/r/${input.subreddit}/comments/${data.id}` : "");
|
|
if (!thingId && !url) {
|
|
throw new Error(`Reddit post returned no receipt: ${text}`);
|
|
}
|
|
|
|
return {
|
|
thingId,
|
|
url,
|
|
};
|
|
}
|
|
|
|
async function openDiscordDmChannel(token, recipientId) {
|
|
const response = await fetch("https://discord.com/api/v10/users/@me/channels", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bot ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
recipient_id: recipientId,
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`Discord DM channel creation failed with ${response.status}: ${text}`);
|
|
}
|
|
|
|
const channel = JSON.parse(text);
|
|
if (!channel.id) {
|
|
throw new Error("Discord DM channel creation returned no channel id.");
|
|
}
|
|
return channel.id;
|
|
}
|
|
|
|
async function postDiscordBotMessage(token, channelId, content, replyToMessageId, nonce) {
|
|
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bot ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
content,
|
|
message_reference: replyToMessageId
|
|
? {
|
|
message_id: replyToMessageId,
|
|
fail_if_not_exists: false,
|
|
}
|
|
: undefined,
|
|
allowed_mentions: {
|
|
parse: [],
|
|
},
|
|
...(nonce ? { nonce, enforce_nonce: true } : {}),
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new DiscordRejection(`Discord post failed with ${response.status}: ${text}`);
|
|
}
|
|
|
|
const message = JSON.parse(text);
|
|
return {
|
|
id: message.id,
|
|
guildId: message.guild_id,
|
|
transport: "bot",
|
|
};
|
|
}
|
|
|
|
async function postDiscordPersonaMessage(token, channelId, content, options) {
|
|
const target = await resolveWebhookTarget(token, channelId);
|
|
let webhook = await getConfiguredPersonaWebhook(target.webhookChannelId);
|
|
if (!webhook) {
|
|
webhook = getCachedPersonaWebhook(target.webhookChannelId);
|
|
}
|
|
if (!webhook) {
|
|
webhook = await createPersonaWebhook(token, target.webhookChannelId);
|
|
writeCachedPersonaWebhook(target.webhookChannelId, webhook);
|
|
}
|
|
|
|
try {
|
|
return await executePersonaWebhook(webhook, {
|
|
threadId: target.threadId,
|
|
content,
|
|
replyToMessageId: options.replyToMessageId,
|
|
username: options.personaName.slice(0, 80),
|
|
avatarUrl: options.personaAvatarUrl,
|
|
});
|
|
} catch (error) {
|
|
if (!isStaleWebhookError(error) || webhook.configured) {
|
|
throw error;
|
|
}
|
|
|
|
clearCachedPersonaWebhook(target.webhookChannelId);
|
|
const refreshed = await createPersonaWebhook(token, target.webhookChannelId);
|
|
writeCachedPersonaWebhook(target.webhookChannelId, refreshed);
|
|
return executePersonaWebhook(refreshed, {
|
|
threadId: target.threadId,
|
|
content,
|
|
replyToMessageId: options.replyToMessageId,
|
|
username: options.personaName.slice(0, 80),
|
|
avatarUrl: options.personaAvatarUrl,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function resolveWebhookTarget(token, channelId) {
|
|
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bot ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`Discord channel lookup failed with ${response.status}: ${text}`);
|
|
}
|
|
|
|
const channel = JSON.parse(text);
|
|
if (THREAD_CHANNEL_TYPES.has(channel.type)) {
|
|
if (!channel.parent_id) {
|
|
throw new Error(`Discord thread ${channelId} has no parent channel for webhook routing.`);
|
|
}
|
|
|
|
return {
|
|
webhookChannelId: channel.parent_id,
|
|
threadId: channel.id,
|
|
};
|
|
}
|
|
|
|
return {
|
|
webhookChannelId: channel.id,
|
|
threadId: undefined,
|
|
};
|
|
}
|
|
|
|
async function createPersonaWebhook(token, channelId) {
|
|
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/webhooks`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bot ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
name: PERSONA_WEBHOOK_NAME,
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Discord webhook creation failed with ${response.status}: ${text}. ` +
|
|
`Grant Manage Webhooks or set BIFROST_DISCORD_PERSONA_WEBHOOK_URL_${channelId}.`,
|
|
);
|
|
}
|
|
|
|
const payload = JSON.parse(text);
|
|
if (!payload.id || !payload.token) {
|
|
throw new Error(`Discord webhook creation for channel ${channelId} returned no executable token.`);
|
|
}
|
|
|
|
return {
|
|
id: payload.id,
|
|
token: payload.token,
|
|
channelId,
|
|
name: PERSONA_WEBHOOK_NAME,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
async function getConfiguredPersonaWebhook(channelId) {
|
|
const rawUrl =
|
|
optionalString(process.env[`BIFROST_DISCORD_PERSONA_WEBHOOK_URL_${channelId}`]) ??
|
|
optionalString(process.env.DISCORD_PERSONA_WEBHOOK_URL);
|
|
|
|
if (!rawUrl) {
|
|
return undefined;
|
|
}
|
|
|
|
const webhook = parseDiscordWebhookUrl(rawUrl, channelId);
|
|
const response = await fetch(`https://discord.com/api/v10/webhooks/${webhook.id}/${webhook.token}`);
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`Configured Discord webhook lookup failed with ${response.status}: ${text}`);
|
|
}
|
|
|
|
const payload = JSON.parse(text);
|
|
if (payload.channel_id !== channelId) {
|
|
throw new Error(`Configured Discord webhook targets channel ${payload.channel_id}, not ${channelId}.`);
|
|
}
|
|
|
|
return webhook;
|
|
}
|
|
|
|
function parseDiscordWebhookUrl(rawUrl, expectedChannelId) {
|
|
const url = new URL(rawUrl);
|
|
const match =
|
|
url.pathname.match(/\/api(?:\/v\d+)?\/webhooks\/([^/]+)\/([^/?#]+)/) ??
|
|
url.pathname.match(/\/webhooks\/([^/]+)\/([^/?#]+)/);
|
|
|
|
if (!match) {
|
|
throw new Error(
|
|
`Configured Discord webhook for channel ${expectedChannelId} must look like https://discord.com/api/webhooks/<id>/<token>.`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
id: match[1],
|
|
token: match[2],
|
|
channelId: expectedChannelId,
|
|
name: "configured",
|
|
createdAt: "configured",
|
|
configured: true,
|
|
};
|
|
}
|
|
|
|
async function executePersonaWebhook(webhook, input) {
|
|
const url = new URL(`https://discord.com/api/v10/webhooks/${webhook.id}/${webhook.token}`);
|
|
url.searchParams.set("wait", "true");
|
|
if (input.threadId) {
|
|
url.searchParams.set("thread_id", input.threadId);
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
content: input.content,
|
|
username: input.username,
|
|
avatar_url: input.avatarUrl,
|
|
message_reference: input.replyToMessageId
|
|
? {
|
|
message_id: input.replyToMessageId,
|
|
fail_if_not_exists: false,
|
|
}
|
|
: undefined,
|
|
allowed_mentions: {
|
|
parse: [],
|
|
},
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`Discord webhook execution failed with ${response.status}: ${text}`);
|
|
}
|
|
|
|
const payload = JSON.parse(text);
|
|
if (!payload.id) {
|
|
throw new Error("Discord webhook execution returned no message id.");
|
|
}
|
|
|
|
return {
|
|
id: payload.id,
|
|
guildId: payload.guild_id,
|
|
transport: "webhook",
|
|
};
|
|
}
|
|
|
|
function getCachedPersonaWebhook(channelId) {
|
|
return readPersonaWebhookCache()[channelId];
|
|
}
|
|
|
|
function writeCachedPersonaWebhook(channelId, webhook) {
|
|
const cache = readPersonaWebhookCache();
|
|
cache[channelId] = webhook;
|
|
writePersonaWebhookCache(cache);
|
|
}
|
|
|
|
function clearCachedPersonaWebhook(channelId) {
|
|
const cache = readPersonaWebhookCache();
|
|
delete cache[channelId];
|
|
writePersonaWebhookCache(cache);
|
|
}
|
|
|
|
function readPersonaWebhookCache() {
|
|
if (!existsSync(PERSONA_WEBHOOK_CACHE_PATH)) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(readFileSync(PERSONA_WEBHOOK_CACHE_PATH, "utf8"));
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writePersonaWebhookCache(cache) {
|
|
mkdirSync(dirname(PERSONA_WEBHOOK_CACHE_PATH), { recursive: true });
|
|
writeFileSync(PERSONA_WEBHOOK_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function isStaleWebhookError(error) {
|
|
return error instanceof Error && (
|
|
error.message.includes("Discord webhook execution failed with 401") ||
|
|
error.message.includes("Discord webhook execution failed with 404")
|
|
);
|
|
}
|
|
|
|
async function readOptionText(options, inlineName, fileName, fallback) {
|
|
const inline = optionalString(options[inlineName]);
|
|
if (inline) {
|
|
return inline;
|
|
}
|
|
|
|
const file = optionalString(options[fileName]);
|
|
if (file) {
|
|
return readFile(resolve(file), "utf8");
|
|
}
|
|
|
|
if (fallback !== undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
throw new Error(`Missing required option --${inlineName} or --${fileName}.`);
|
|
}
|
|
|
|
function git(args, cwd, options = {}) {
|
|
return run(resolveGitCommand(), args, cwd, options);
|
|
}
|
|
|
|
function runGitHubCli(options, args, cwd, runOptions = {}) {
|
|
const command = resolveGitHubCommand(options);
|
|
return run(command.exe, [...command.args, ...args], cwd, runOptions);
|
|
}
|
|
|
|
function run(command, args, cwd, options = {}) {
|
|
const env = {
|
|
...process.env,
|
|
...(options.env ?? {}),
|
|
};
|
|
const result = isWindowsCommandScript(command)
|
|
? spawnSync("cmd.exe", ["/d", "/s", "/c", command, ...args], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env,
|
|
windowsHide: true,
|
|
})
|
|
: spawnSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env,
|
|
windowsHide: true,
|
|
});
|
|
|
|
if (result.status !== 0 && !options.allowFailure) {
|
|
throw new Error(`${command} ${args.join(" ")} failed with ${result.status ?? "unknown"}:\n${result.stderr || result.stdout}`);
|
|
}
|
|
|
|
return {
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
status: result.status,
|
|
};
|
|
}
|
|
|
|
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 optionalString(value) {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
function buildReceiptPayload(options, payload) {
|
|
return {
|
|
...payload,
|
|
provenance: buildBridgeProvenance(options),
|
|
};
|
|
}
|
|
|
|
function buildBridgeProvenance(options) {
|
|
return {
|
|
bifrostIdentity: optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "",
|
|
sourceKind: optionalString(options["source-kind"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_KIND) ?? "",
|
|
sourceId: optionalString(options["source-id"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_ID) ?? "",
|
|
authorityReference: optionalString(options["authority-ref"]) ?? optionalString(process.env.BIFROST_BRIDGE_AUTHORITY_REF) ?? "",
|
|
epiphanyRunId: optionalString(options["epiphany-run-id"]) ?? optionalString(process.env.EPIPHANY_RUN_ID) ?? "",
|
|
epiphanyLaneId: optionalString(options["epiphany-lane-id"]) ?? optionalString(process.env.EPIPHANY_LANE_ID) ?? "",
|
|
epiphanyAgentIdentity: optionalString(options["epiphany-agent-identity"]) ?? optionalString(process.env.EPIPHANY_AGENT_IDENTITY) ?? "",
|
|
heimdallCapabilityRef:
|
|
optionalString(options["heimdall-capability-ref"]) ??
|
|
optionalString(process.env.HEIMDALL_CAPABILITY_REF) ??
|
|
optionalString(process.env.BIFROST_HEIMDALL_CAPABILITY_REF) ??
|
|
"",
|
|
};
|
|
}
|
|
|
|
function resolveGitCommand() {
|
|
return optionalString(process.env.BIFROST_GIT_EXECUTABLE) ?? "git";
|
|
}
|
|
|
|
async function beginBridgeAction(options, action) {
|
|
rejectRemovedHttpBridgeConfig(options);
|
|
const provenance = crossingProvenanceFromOptions(options);
|
|
const actorName = action.actorName ?? provenance.actor.name ?? "bifrost";
|
|
const targetSurface = normalizeSurface(action.targetSurface);
|
|
const crossingKind = crossingKindForAction(action.actionKind);
|
|
const lifecycle = await beginCrossingReceipt(options, {
|
|
commandId: provenance.commandId,
|
|
crossingKind,
|
|
actor: {
|
|
kind: action.actorKind ?? provenance.actor.kind ?? "Service",
|
|
name: actorName,
|
|
bifrostIdentity: provenance.actor.bifrostIdentity || actorName,
|
|
},
|
|
source: provenance.source,
|
|
authority: provenance.authority,
|
|
epiphany: provenance.epiphany,
|
|
target: {
|
|
surface: targetSurface,
|
|
repositoryFullName: action.targetRepositoryFullName ?? "",
|
|
locator: action.targetLocator ?? "",
|
|
title: action.title ?? "",
|
|
summary: action.summary ?? "",
|
|
},
|
|
});
|
|
|
|
return {
|
|
receiptId: lifecycle.receiptId,
|
|
commandId: lifecycle.commandId,
|
|
async start() {
|
|
return lifecycle.start();
|
|
},
|
|
async complete(result) {
|
|
return lifecycle.complete({
|
|
externalReceipt: {
|
|
url: result?.receiptUrl ?? "",
|
|
id: result?.externalReceiptId ?? "",
|
|
transport: targetSurface.toLowerCase(),
|
|
payload: parseOptionalJson(result?.receiptPayload),
|
|
},
|
|
});
|
|
},
|
|
async fail(error) {
|
|
return lifecycle.fail(error);
|
|
},
|
|
};
|
|
}
|
|
|
|
function crossingKindForAction(actionKind) {
|
|
switch (actionKind) {
|
|
case "GitHubDraftPullRequest":
|
|
return "github.draft_pr";
|
|
case "GitHubPullRequestComment":
|
|
return "github.pr_comment";
|
|
case "DiscordPost":
|
|
return "discord.post";
|
|
case "DiscordDirectMessage":
|
|
return "discord.dm";
|
|
case "RedditPost":
|
|
return "reddit.post";
|
|
default:
|
|
return "future_surface.request";
|
|
}
|
|
}
|
|
|
|
function normalizeSurface(value) {
|
|
return optionalString(value) ?? "future-surface";
|
|
}
|
|
|
|
function parseOptionalJson(value) {
|
|
const text = optionalString(value);
|
|
if (!text) {
|
|
return {};
|
|
}
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return { raw: text };
|
|
}
|
|
}
|
|
|
|
function parseRequiredJson(text, label) {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`${label} was not valid JSON: ${detail}`);
|
|
}
|
|
}
|
|
|
|
function ensureGitHubBridgeGate(options) {
|
|
rejectRemovedHttpBridgeConfig(options);
|
|
const cultMeshCommandId =
|
|
optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID);
|
|
const allowUngated =
|
|
options["allow-ungated-github"] === "true" ||
|
|
process.env.BIFROST_ALLOW_UNGATED_GITHUB === "true";
|
|
|
|
if (allowUngated) {
|
|
throw new Error(
|
|
"Bifrost GitHub mutations cannot use --allow-ungated-github or BIFROST_ALLOW_UNGATED_GITHUB. " +
|
|
"Use a typed Bifrost CultMesh command request/receipt route instead of mutating off-book.",
|
|
);
|
|
}
|
|
|
|
if (cultMeshCommandId) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
"GitHub bridge actions require Bifrost authorization and receipt logging. " +
|
|
"Provide BIFROST_CULTMESH_COMMAND_ID from a typed CultMesh command request; HTTP bridge hatches have been removed.",
|
|
);
|
|
}
|
|
|
|
function ensureBridgeReceiptGate(options) {
|
|
rejectRemovedHttpBridgeConfig(options);
|
|
const cultMeshCommandId =
|
|
optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID);
|
|
const allowUnreceipted =
|
|
options["allow-unreceipted-activity"] === "true" ||
|
|
process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
|
|
|
|
if (allowUnreceipted) {
|
|
throw new Error(
|
|
"Bifrost bridge activity cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
|
"Use a typed Bifrost CultMesh command request/receipt route instead of mutating off-book.",
|
|
);
|
|
}
|
|
|
|
if (cultMeshCommandId) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
"Bridge activity requires a Bifrost CultMesh command id so Bifrost can receipt the external crossing. " +
|
|
"Use a typed CultMesh command request/receipt route; HTTP and unreceipted bridge hatches have been removed.",
|
|
);
|
|
}
|
|
|
|
function rejectRemovedHttpBridgeConfig(options) {
|
|
if (
|
|
optionalString(options["bifrost-base-url"]) ||
|
|
optionalString(options["bifrost-token"]) ||
|
|
optionalString(process.env.BIFROST_BRIDGE_BASE_URL) ||
|
|
optionalString(process.env.BIFROST_BRIDGE_TOKEN)
|
|
) {
|
|
throw new Error(
|
|
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN and --bifrost-base-url / --bifrost-token were removed. " +
|
|
"Use typed Bifrost CultMesh command request/receipt documents discovered through Odin.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function detectGitHubRepositoryFullName(options, repoRoot) {
|
|
const result = runGitHubCli(
|
|
options,
|
|
["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
|
|
repoRoot,
|
|
{ allowFailure: true },
|
|
);
|
|
return optionalString(result.stdout) ?? "";
|
|
}
|
|
|
|
function resolveGitHubExecutable(options) {
|
|
return resolveGitHubCommand(options).exe;
|
|
}
|
|
|
|
function resolveGitHubCommand(options) {
|
|
return {
|
|
exe: optionalString(options["gh-executable"]) ?? optionalString(process.env.BIFROST_GH_EXECUTABLE) ?? "gh",
|
|
args: splitCommandArgs(options["gh-exec-args"] ?? process.env.BIFROST_GH_EXEC_ARGS ?? ""),
|
|
};
|
|
}
|
|
|
|
function isWindowsCommandScript(command) {
|
|
return /\.cmd$/i.test(command) || /\.bat$/i.test(command);
|
|
}
|
|
|
|
function splitCommandArgs(value) {
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function extractGitHubNumber(value) {
|
|
const match = optionalString(value)?.match(/\/(\d+)(?:[#?].*)?$/);
|
|
return match ? match[1] : undefined;
|
|
}
|
|
|
|
function normalizeSubreddit(value) {
|
|
const subreddit = optionalString(value)?.replace(/^\/?r\//i, "");
|
|
if (!subreddit || !/^[A-Za-z0-9_]{3,21}$/.test(subreddit)) {
|
|
throw new Error(`Invalid subreddit "${value}". Use a subreddit name such as GameCultOrg.`);
|
|
}
|
|
return subreddit;
|
|
}
|
|
|
|
function getRedditUserAgent() {
|
|
return optionalString(process.env.BIFROST_REDDIT_USER_AGENT ?? process.env.REDDIT_USER_AGENT) ??
|
|
"GameCult Bifrost bridge/0.1 by GameCultOrg";
|
|
}
|
|
|
|
function normalizeRelativePath(value) {
|
|
const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
if (normalized.includes("../") || normalized === "..") {
|
|
throw new Error(`Relative path may not escape the repo: ${value}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function slugify(value) {
|
|
return String(value)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 80) || "bridge";
|
|
}
|
|
|
|
function timestampSlug(date) {
|
|
return date.toISOString().replace(/[:.]/g, "-");
|
|
}
|
|
|
|
function printJson(value) {
|
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function loadLocalEnv(path) {
|
|
const normalizedPath = path.startsWith("/") && /^[A-Za-z]:/.test(path.slice(1))
|
|
? path.slice(1)
|
|
: path;
|
|
if (!existsSync(normalizedPath)) {
|
|
return;
|
|
}
|
|
|
|
for (const line of readFileSync(normalizedPath, "utf8").split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) {
|
|
continue;
|
|
}
|
|
const separator = trimmed.indexOf("=");
|
|
if (separator === -1) {
|
|
continue;
|
|
}
|
|
const key = trimmed.slice(0, separator).trim();
|
|
if (process.env[key]) {
|
|
continue;
|
|
}
|
|
let value = trimmed.slice(separator + 1).trim();
|
|
if (
|
|
value.length >= 2 &&
|
|
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
function printHelp() {
|
|
process.stdout.write(`Bifrost bridge
|
|
|
|
Commands:
|
|
github-draft-pr Write one file in a target repo and open a draft PR through gh
|
|
github-pr-comment Comment on a pull request through gh
|
|
discord-post Post a message to Discord through the bot token or persona webhook pipe
|
|
discord-dm Send a Discord DM through Bifrost's bridge-owned bot token
|
|
reddit-post Create a self-post in r/GameCultOrg through the Bifrost Reddit app
|
|
other-request Record a Bifrost-gated request for a future outside-world surface
|
|
authorize-release Authorize one exact remote GitHub ref and commit for downstream deployment
|
|
revoke-release Revoke the current authorization for one GitHub repository ref
|
|
|
|
Examples:
|
|
node tools/bifrost-bridge.mjs github-draft-pr --repo-root E:/Projects/AetheriaLore --identity nibu --title "Nibu: Glitchcraft" --path Aetheria/Articles/Nibu/glitchcraft.md --content-file article.md
|
|
node tools/bifrost-bridge.mjs github-pr-comment --repo-root E:/Projects/AetheriaLore --identity nibu --pr 12 --content "This needs a sharper leash."
|
|
node tools/bifrost-bridge.mjs discord-post --channel-id 1501196543150264332 --persona-name Nibu --content "Draft PR opened: https://github.com/..."
|
|
node tools/bifrost-bridge.mjs discord-dm --recipient-id 123456789 --content "Moderation status update..."
|
|
node tools/bifrost-bridge.mjs reddit-post --title "Nibu: Reset-loop continuity" --persona-name Nibu --content-file thread.md
|
|
node tools/bifrost-bridge.mjs other-request --identity epiphany.Persona --surface-name bluesky --target-locator at://did:example/app.bsky.feed.post/123 --heimdall-capability-ref heimdall:bluesky:capability:epiphany-persona --content "Persona asks to speak."
|
|
|
|
GitHub note:
|
|
GitHub actions require typed Bifrost CultMesh command provenance such as BIFROST_CULTMESH_COMMAND_ID so Bifrost can gate and receipt the crossing.
|
|
|
|
Provenance note:
|
|
--identity names the Bifrost identity acting through the bridge.
|
|
--heimdall-capability-ref records the Heimdall-backed account/capability reference used for the outside-world crossing; do not pass bearer tokens here.
|
|
`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = error instanceof NotSentError ? NOT_SENT_EXIT : 1;
|
|
});
|