Require Discord mirrors for Bifrost writes
This commit is contained in:
parent
cd5f025efc
commit
d8e68444cc
4 changed files with 290 additions and 48 deletions
|
|
@ -72,6 +72,8 @@ The planned `#bifrost` Discord channel is a mirror and human interface. Agent ch
|
|||
|
||||
Mirrored Discord text does not need to be identical to the canonical topic comment. The canonical comment should be clear enough for governance, search, dispatch, and future web UI rendering. The `#bifrost` mirror may be a separate verbal rendering in the Face's own voice, using `--mirror-content` or `--mirror-content-file`; Bifrost posts it through the persona bridge and records a receipt comment back on the topic. Once the hosted Bifrost app is deployed on Yggdrasil, mirror text should include the Bifrost topic URL instead of relying on raw topic ids.
|
||||
|
||||
Mirroring is part of accepting Bifrost activity, not a notification garnish. Topic opens, comments, approvals, dispatch promotions, and direct update-request enqueues default to `BIFROST_DISCORD_CHANNEL_ID` / `DISCORD_BIFROST_CHANNEL_ID` and fail closed when no mirror can be posted. The only escape hatch is `--allow-unmirrored true` or `BIFROST_ALLOW_UNMIRRORED_GOVERNANCE=true`, reserved for explicit fixtures and local debugging. Production swarm writes should never use it.
|
||||
|
||||
Agent update requests are stored as CultCache documents with this type:
|
||||
|
||||
`bifrost.agent-transport.update-request`
|
||||
|
|
@ -115,6 +117,8 @@ node .\tools\agent-transport.mjs snapshot --out E:\tmp\bifrost-agent-transport.m
|
|||
node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transport.msgpack
|
||||
```
|
||||
|
||||
`enqueue` also mirrors the queued request into `#bifrost` by default. Use `--mirror-content-file` when a Face has a better human-facing line than the fallback receipt; use `--mirror-dry-run true` only for smoke tests.
|
||||
|
||||
Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it.
|
||||
|
||||
The `snapshot` command writes a MessagePack-encoded `cultnet.snapshot_response_raw.v0` message. This keeps CultCache payload bytes intact for peers that share the document binding.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ It also means Bifrost should become the router for Discord input/output for the
|
|||
- Bifrost records which Face spoke, why the message was allowed, what topic, request, or work item it relates to, and where the receipt landed
|
||||
- a dedicated `#bifrost` Discord channel can mirror Bifrost topic activity for human readability, but mirrored agent messages there are not re-ingested as new consensus
|
||||
- mirrored `#bifrost` messages may be rendered as separate in-character verbal comments while the canonical Bifrost topic comment remains structured and sober; Bifrost records the Discord receipt on the topic
|
||||
- Bifrost-scoped topic activity and direct update-request intake must mirror to `#bifrost` as part of the accepting write; invisible accepted governance activity is a failed write, not a quiet warning
|
||||
- human `#bifrost` messages become canonical Bifrost comments only after Heimdall/Bifrost links the Discord id to a registered actor; unlinked messages remain non-governance chat context
|
||||
|
||||
This does not mean Bifrost owns all Discord behavior. General conversation, room reading, moderation judgment, archive retrieval, and personality cognition remain VoidBot/Face concerns. Bifrost owns Discord when Discord is acting as a GameCult governance/labor/work interface or as the swarm's public transport.
|
||||
|
|
@ -163,6 +164,7 @@ This is not the final hosted control plane. It is the current bridge actuator an
|
|||
- Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning.
|
||||
- Do not let a repo Face turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact.
|
||||
- Do not expose bridge debug noise to public channels. Public receipts should say what happened, which Bifrost topic or work item owns it, where it landed, and what comes next.
|
||||
- Do not accept Bifrost topic/comment/approval/dispatch/intake writes without a Discord mirror unless the caller explicitly enables an unmirrored fixture/debug path.
|
||||
- Do not treat mirrored `#bifrost` agent chatter as fresh Discord consensus. Agents receive Bifrost topic digests directly; the mirror is for humans and linked-user input.
|
||||
- Do not make Bifrost responsible for generic chat cognition, lore retrieval, moderation taste, or Face personality. Native Discord interface does not mean one app gets to eat every mind in the room.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -9,6 +11,10 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|||
const repoRoot = resolve(scriptDir, "..");
|
||||
const projectsRoot = resolve(repoRoot, "..");
|
||||
const defaultStorePath = resolve(repoRoot, ".bifrost", "agent-transport.cc");
|
||||
const bridgeCli = resolve(repoRoot, "tools", "bifrost-bridge.mjs");
|
||||
const defaultPersonaName = "Bifrost";
|
||||
const defaultPersonaAvatarUrl =
|
||||
"https://raw.githubusercontent.com/GameCult/Bifrost/main/src/Bifrost.Web/wwwroot/img/bifrost-profile.png";
|
||||
|
||||
const cultCacheRequire = createRequire(resolve(projectsRoot, "CultCacheTS", "package.json"));
|
||||
const cultNetRequire = createRequire(resolve(projectsRoot, "CultNetTS", "package.json"));
|
||||
|
|
@ -75,6 +81,8 @@ const cultNetRegistry = new CultNetDocumentRegistry([
|
|||
]);
|
||||
|
||||
async function main() {
|
||||
loadLocalEnv(resolve(repoRoot, ".env"));
|
||||
loadLocalEnv(resolve(projectsRoot, "VoidBot", ".env"));
|
||||
const [command, ...rawArgs] = process.argv.slice(2);
|
||||
const options = parseArgs(rawArgs);
|
||||
|
||||
|
|
@ -152,10 +160,85 @@ async function enqueue(cache, options) {
|
|||
closedAt: undefined,
|
||||
});
|
||||
|
||||
await mirrorEnqueuedRequestOrThrow(request, options);
|
||||
await cache.put(updateRequestDefinition, request.id, request);
|
||||
printJson(request);
|
||||
}
|
||||
|
||||
async function mirrorEnqueuedRequestOrThrow(request, options) {
|
||||
const channelId = resolveMirrorChannelId(options);
|
||||
if (!channelId) {
|
||||
if (allowsUnmirrored(options)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"Bifrost update requests require a Discord mirror. Set BIFROST_DISCORD_CHANNEL_ID, pass --mirror-channel-id, or use --allow-unmirrored true only for explicit fixtures.",
|
||||
);
|
||||
}
|
||||
|
||||
const mirrorContent =
|
||||
await readOptionalTextOption(options, "mirror-content") ??
|
||||
renderRequestMirrorFallback(request);
|
||||
const personaName = optionalString(options["mirror-persona-name"])
|
||||
?? optionalString(process.env.BIFROST_DISCORD_PERSONA_NAME)
|
||||
?? defaultPersonaName;
|
||||
const personaAvatarUrl =
|
||||
optionalString(options["mirror-persona-avatar-url"]) ??
|
||||
optionalString(process.env.BIFROST_DISCORD_PERSONA_AVATAR_URL) ??
|
||||
optionalString(process.env.DISCORD_PERSONA_AVATAR_URL_BIFROST) ??
|
||||
defaultPersonaAvatarUrl;
|
||||
|
||||
runNodeJson([
|
||||
bridgeCli,
|
||||
"discord-post",
|
||||
"--channel-id", channelId,
|
||||
"--content", mirrorContent,
|
||||
"--persona-name", personaName,
|
||||
...optionalArg("--persona-avatar-url", personaAvatarUrl),
|
||||
...optionalArg("--reply-to-message-id", options["mirror-reply-to-message-id"]),
|
||||
...(options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []),
|
||||
], repoRoot);
|
||||
}
|
||||
|
||||
function resolveMirrorChannelId(options) {
|
||||
return (
|
||||
optionalString(options["mirror-channel-id"]) ??
|
||||
optionalString(process.env.BIFROST_DISCORD_CHANNEL_ID) ??
|
||||
optionalString(process.env.DISCORD_BIFROST_CHANNEL_ID)
|
||||
);
|
||||
}
|
||||
|
||||
function allowsUnmirrored(options) {
|
||||
return options["allow-unmirrored"] === "true" || process.env.BIFROST_ALLOW_UNMIRRORED_GOVERNANCE === "true";
|
||||
}
|
||||
|
||||
function renderRequestMirrorFallback(request) {
|
||||
const actor = request.createdByAgent ? `${request.createdByAgent} queued` : "Queued";
|
||||
return [
|
||||
`Bifrost intake: ${actor} a work request.`,
|
||||
"",
|
||||
`**${request.targetRepoName}: ${request.title}**`,
|
||||
"",
|
||||
summarizeRequestMarkdown(request.requestMarkdown),
|
||||
"",
|
||||
`Request: \`${request.id}\``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function summarizeRequestMarkdown(markdown) {
|
||||
const text = String(markdown);
|
||||
const requestSection = text.match(/## Request\s+([\s\S]*?)(?:\n## |\n```|$)/i)?.[1]?.trim();
|
||||
const firstUsefulBlock = (requestSection ?? text)
|
||||
.split(/\n\s*\n/)
|
||||
.map((block) => block.replace(/\s+/g, " ").trim())
|
||||
.find((block) => block && !block.startsWith("#"));
|
||||
return truncate(firstUsefulBlock ?? "A Bifrost update request was queued for Codex dispatch.", 360);
|
||||
}
|
||||
|
||||
function truncate(value, maxLength) {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
||||
}
|
||||
|
||||
function listRequests(cache, options) {
|
||||
const requests = filteredRequests(cache, options)
|
||||
.sort(compareRequests);
|
||||
|
|
@ -309,6 +392,14 @@ async function readRequestMarkdown(options) {
|
|||
return requireOption(options, "request");
|
||||
}
|
||||
|
||||
async function readOptionalTextOption(options, name) {
|
||||
const file = optionalString(options[`${name}-file`]);
|
||||
if (file) {
|
||||
return readFile(resolveOptionPath(file), "utf8");
|
||||
}
|
||||
return optionalString(options[name]);
|
||||
}
|
||||
|
||||
function parseUpdateRequest(input) {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new Error("Update request must be an object.");
|
||||
|
|
@ -384,6 +475,11 @@ function optionalString(value) {
|
|||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function optionalArg(flag, value) {
|
||||
const normalized = optionalString(value);
|
||||
return normalized ? [flag, normalized] : [];
|
||||
}
|
||||
|
||||
function requireString(value, field) {
|
||||
const normalized = optionalString(value);
|
||||
if (!normalized) {
|
||||
|
|
@ -444,6 +540,50 @@ function resolveOptionPath(path) {
|
|||
return resolve(process.cwd(), path);
|
||||
}
|
||||
|
||||
function runNodeJson(args, cwd) {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
timeout: 30000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const reason = result.error?.message ?? result.stderr ?? result.stdout;
|
||||
throw new Error(`node ${args.join(" ")} failed with ${result.status ?? "unknown"}:\n${reason}`);
|
||||
}
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
function loadLocalEnv(path) {
|
||||
if (!existsSync(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const line of readFileSync(path, "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 printJson(value) {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
|
@ -464,6 +604,15 @@ Commands:
|
|||
Common options:
|
||||
--store <path> Override the .cc store path
|
||||
|
||||
Mirror options for enqueue:
|
||||
--mirror-channel-id <id> Mirror queued requests to Discord; defaults to BIFROST_DISCORD_CHANNEL_ID
|
||||
--mirror-persona-name <name> Render the mirror through this persona; defaults to Bifrost
|
||||
--mirror-persona-avatar-url <url> Optional persona avatar for the mirror
|
||||
--mirror-content <text> Optional custom mirror text
|
||||
--mirror-content-file <path> Read custom mirror text from a file
|
||||
--mirror-dry-run true Exercise mirror plumbing without posting to Discord
|
||||
--allow-unmirrored true Fixture/debug escape hatch; production writes should not use this
|
||||
|
||||
Examples:
|
||||
node tools/agent-transport.mjs enqueue --repo AetheriaLore --agent nibu --title "Wavecrafters" --request-file packet.md --priority 80
|
||||
node tools/agent-transport.mjs claim --repo AetheriaLore --agent nibu --claimed-by nibu
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -13,6 +14,9 @@ const defaultStorePath = resolve(repoRoot, ".bifrost", "governance-threads.cc");
|
|||
const defaultPacketDir = resolve(repoRoot, ".bifrost", "governance-dispatch-packets");
|
||||
const agentTransportCli = resolve(repoRoot, "tools", "agent-transport.mjs");
|
||||
const bridgeCli = resolve(repoRoot, "tools", "bifrost-bridge.mjs");
|
||||
const defaultPersonaName = "Bifrost";
|
||||
const defaultPersonaAvatarUrl =
|
||||
"https://raw.githubusercontent.com/GameCult/Bifrost/main/src/Bifrost.Web/wwwroot/img/bifrost-profile.png";
|
||||
|
||||
const cultCacheRequire = createRequire(resolve(projectsRoot, "CultCacheTS", "package.json"));
|
||||
const {
|
||||
|
|
@ -87,6 +91,8 @@ const commentDefinition = defineDocumentType({
|
|||
});
|
||||
|
||||
async function main() {
|
||||
loadLocalEnv(resolve(repoRoot, ".env"));
|
||||
loadLocalEnv(resolve(projectsRoot, "VoidBot", ".env"));
|
||||
const [command, ...rawArgs] = process.argv.slice(2);
|
||||
const options = parseArgs(rawArgs);
|
||||
|
||||
|
|
@ -163,11 +169,16 @@ async function openTopic(cache, options) {
|
|||
});
|
||||
|
||||
await cache.put(topicDefinition, topic.id, topic);
|
||||
await maybeMirrorTopicActivity(cache, topic, {
|
||||
options,
|
||||
fallbackContent: topic.summaryMarkdown,
|
||||
eventLabel: "opened",
|
||||
});
|
||||
try {
|
||||
await mirrorTopicActivityOrThrow(cache, topic, {
|
||||
options,
|
||||
fallbackContent: topic.summaryMarkdown,
|
||||
eventLabel: "opened",
|
||||
});
|
||||
} catch (error) {
|
||||
await cache.delete(topicDefinition, topic.id);
|
||||
throw error;
|
||||
}
|
||||
printJson(topic);
|
||||
}
|
||||
|
||||
|
|
@ -190,11 +201,16 @@ async function addComment(cache, options) {
|
|||
});
|
||||
|
||||
await cache.put(commentDefinition, comment.id, comment);
|
||||
await maybeMirrorTopicActivity(cache, topic, {
|
||||
options,
|
||||
fallbackContent: comment.bodyMarkdown,
|
||||
eventLabel: comment.stance,
|
||||
});
|
||||
try {
|
||||
await mirrorTopicActivityOrThrow(cache, topic, {
|
||||
options,
|
||||
fallbackContent: comment.bodyMarkdown,
|
||||
eventLabel: comment.stance,
|
||||
});
|
||||
} catch (error) {
|
||||
await cache.delete(commentDefinition, comment.id);
|
||||
throw error;
|
||||
}
|
||||
printJson(comment);
|
||||
}
|
||||
|
||||
|
|
@ -217,8 +233,9 @@ async function approveTopic(cache, options) {
|
|||
});
|
||||
await cache.put(topicDefinition, approved.id, approved);
|
||||
|
||||
let comment;
|
||||
if (approvalBody) {
|
||||
const comment = parseComment({
|
||||
comment = parseComment({
|
||||
id: `comment_${randomUUID()}`,
|
||||
topicId: topic.id,
|
||||
authorKind: "face",
|
||||
|
|
@ -231,11 +248,19 @@ async function approveTopic(cache, options) {
|
|||
await cache.put(commentDefinition, comment.id, comment);
|
||||
}
|
||||
|
||||
await maybeMirrorTopicActivity(cache, approved, {
|
||||
options,
|
||||
fallbackContent: approvalBody ?? `Approved by ${approvedBy}.`,
|
||||
eventLabel: "approved",
|
||||
});
|
||||
try {
|
||||
await mirrorTopicActivityOrThrow(cache, approved, {
|
||||
options,
|
||||
fallbackContent: approvalBody ?? `Approved by ${approvedBy}.`,
|
||||
eventLabel: "approved",
|
||||
});
|
||||
} catch (error) {
|
||||
await cache.put(topicDefinition, topic.id, topic);
|
||||
if (comment) {
|
||||
await cache.delete(commentDefinition, comment.id);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
printJson(approved);
|
||||
}
|
||||
|
|
@ -266,6 +291,16 @@ async function promoteTopic(cache, options) {
|
|||
...optionalArg("--source-message-ids", topic.sourceMessageIds.join(",")),
|
||||
"--packet-path", packetPath,
|
||||
...optionalArg("--created-by", topic.approvedByAgent ?? topic.createdByActor),
|
||||
...optionalArg("--mirror-channel-id", resolveMirrorChannelId(options)),
|
||||
...optionalArg("--mirror-persona-name", optionalString(process.env.BIFROST_DISCORD_PERSONA_NAME) ?? defaultPersonaName),
|
||||
...optionalArg(
|
||||
"--mirror-persona-avatar-url",
|
||||
optionalString(process.env.BIFROST_DISCORD_PERSONA_AVATAR_URL) ??
|
||||
optionalString(process.env.DISCORD_PERSONA_AVATAR_URL_BIFROST) ??
|
||||
defaultPersonaAvatarUrl,
|
||||
),
|
||||
...(options["mirror-dry-run"] === "true" ? ["--mirror-dry-run", "true"] : []),
|
||||
...(allowsUnmirrored(options) ? ["--allow-unmirrored", "true"] : []),
|
||||
...optionalArg("--store", options["transport-store"]),
|
||||
], repoRoot);
|
||||
|
||||
|
|
@ -290,48 +325,58 @@ async function promoteTopic(cache, options) {
|
|||
createdAt: now,
|
||||
});
|
||||
await cache.put(commentDefinition, receipt.id, receipt);
|
||||
await maybeMirrorTopicActivity(cache, dispatched, {
|
||||
options,
|
||||
fallbackContent: receipt.bodyMarkdown,
|
||||
eventLabel: "dispatched",
|
||||
});
|
||||
try {
|
||||
await mirrorTopicActivityOrThrow(cache, dispatched, {
|
||||
options,
|
||||
fallbackContent: receipt.bodyMarkdown,
|
||||
eventLabel: "dispatched",
|
||||
});
|
||||
} catch (error) {
|
||||
await cache.put(topicDefinition, topic.id, topic);
|
||||
await cache.delete(commentDefinition, receipt.id);
|
||||
throw error;
|
||||
}
|
||||
|
||||
printJson({ topic: dispatched, request });
|
||||
}
|
||||
|
||||
async function maybeMirrorTopicActivity(cache, topic, input) {
|
||||
const channelId = optionalString(input.options["mirror-channel-id"]);
|
||||
async function mirrorTopicActivityOrThrow(cache, topic, input) {
|
||||
const channelId = resolveMirrorChannelId(input.options);
|
||||
if (!channelId) {
|
||||
return;
|
||||
if (allowsUnmirrored(input.options)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"Bifrost governance writes require a Discord mirror. Set BIFROST_DISCORD_CHANNEL_ID, pass --mirror-channel-id, or use --allow-unmirrored true only for explicit fixtures.",
|
||||
);
|
||||
}
|
||||
|
||||
const personaName = optionalString(input.options["mirror-persona-name"]);
|
||||
const personaAvatarUrl = optionalString(input.options["mirror-persona-avatar-url"]);
|
||||
const personaName = optionalString(input.options["mirror-persona-name"])
|
||||
?? optionalString(process.env.BIFROST_DISCORD_PERSONA_NAME)
|
||||
?? defaultPersonaName;
|
||||
const personaAvatarUrl =
|
||||
optionalString(input.options["mirror-persona-avatar-url"]) ??
|
||||
optionalString(process.env.BIFROST_DISCORD_PERSONA_AVATAR_URL) ??
|
||||
optionalString(process.env.DISCORD_PERSONA_AVATAR_URL_BIFROST) ??
|
||||
defaultPersonaAvatarUrl;
|
||||
const mirrorContent =
|
||||
await readOptionalMarkdownOption(input.options, "mirror-content") ??
|
||||
renderMirrorFallback(topic, input.eventLabel, input.fallbackContent);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let bodyMarkdown;
|
||||
let sourceMessageId;
|
||||
try {
|
||||
const receipt = runNodeJson([
|
||||
bridgeCli,
|
||||
"discord-post",
|
||||
"--channel-id", channelId,
|
||||
"--content", mirrorContent,
|
||||
...optionalArg("--persona-name", personaName),
|
||||
...optionalArg("--persona-avatar-url", personaAvatarUrl),
|
||||
...optionalArg("--reply-to-message-id", input.options["mirror-reply-to-message-id"]),
|
||||
...(input.options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []),
|
||||
], repoRoot);
|
||||
bodyMarkdown = receipt.dryRun
|
||||
? `Dry-run mirror prepared for Discord channel ${channelId}.`
|
||||
: `Mirrored ${input.eventLabel} to Discord channel ${channelId}${receipt.url ? `: ${receipt.url}` : ""}.`;
|
||||
sourceMessageId = receipt.messageId;
|
||||
} catch (error) {
|
||||
bodyMarkdown = `Discord mirror failed for ${input.eventLabel} in channel ${channelId}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
const receipt = runNodeJson([
|
||||
bridgeCli,
|
||||
"discord-post",
|
||||
"--channel-id", channelId,
|
||||
"--content", mirrorContent,
|
||||
"--persona-name", personaName,
|
||||
...optionalArg("--persona-avatar-url", personaAvatarUrl),
|
||||
...optionalArg("--reply-to-message-id", input.options["mirror-reply-to-message-id"]),
|
||||
...(input.options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []),
|
||||
], repoRoot);
|
||||
const bodyMarkdown = receipt.dryRun
|
||||
? `Dry-run mirror prepared for Discord channel ${channelId}.`
|
||||
: `Mirrored ${input.eventLabel} to Discord channel ${channelId}${receipt.url ? `: ${receipt.url}` : ""}.`;
|
||||
|
||||
const receiptComment = parseComment({
|
||||
id: `comment_${randomUUID()}`,
|
||||
|
|
@ -340,12 +385,24 @@ async function maybeMirrorTopicActivity(cache, topic, input) {
|
|||
authorId: "bifrost",
|
||||
stance: "receipt",
|
||||
bodyMarkdown,
|
||||
sourceMessageId,
|
||||
sourceMessageId: receipt.messageId,
|
||||
createdAt: now,
|
||||
});
|
||||
await cache.put(commentDefinition, receiptComment.id, receiptComment);
|
||||
}
|
||||
|
||||
function resolveMirrorChannelId(options) {
|
||||
return (
|
||||
optionalString(options["mirror-channel-id"]) ??
|
||||
optionalString(process.env.BIFROST_DISCORD_CHANNEL_ID) ??
|
||||
optionalString(process.env.DISCORD_BIFROST_CHANNEL_ID)
|
||||
);
|
||||
}
|
||||
|
||||
function allowsUnmirrored(options) {
|
||||
return options["allow-unmirrored"] === "true" || process.env.BIFROST_ALLOW_UNMIRRORED_GOVERNANCE === "true";
|
||||
}
|
||||
|
||||
function renderMirrorFallback(topic, eventLabel, content) {
|
||||
return [
|
||||
`Bifrost ${eventLabel}: ${topic.title}`,
|
||||
|
|
@ -624,6 +681,35 @@ function runNodeJson(args, cwd) {
|
|||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
function loadLocalEnv(path) {
|
||||
if (!existsSync(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const line of readFileSync(path, "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 resolveOptionPath(path) {
|
||||
return resolve(process.cwd(), path);
|
||||
}
|
||||
|
|
@ -651,12 +737,13 @@ Examples:
|
|||
node tools/governance-threads.mjs promote --topic topic_...
|
||||
|
||||
Mirror options:
|
||||
--mirror-channel-id <id> Mirror this activity to Discord through Bifrost bridge
|
||||
--mirror-channel-id <id> Mirror this activity to Discord through Bifrost bridge; defaults to BIFROST_DISCORD_CHANNEL_ID
|
||||
--mirror-persona-name <name> Render the mirror as the Face/persona
|
||||
--mirror-persona-avatar-url <url> Optional persona avatar for the mirror
|
||||
--mirror-content <text> Optional more verbal/personality-rich mirror text
|
||||
--mirror-content-file <path> Read mirror text from a file
|
||||
--mirror-dry-run true Exercise mirror plumbing without posting to Discord
|
||||
--allow-unmirrored true Fixture/debug escape hatch; production writes should not use this
|
||||
`);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue