fix: publish Buzz bot profile names

This commit is contained in:
Shakker
2026-07-26 02:09:18 +01:00
committed by Shakker
parent 0e0698b6bc
commit 08eac200b3
8 changed files with 201 additions and 6 deletions

View File

@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
- **Gateway exec deny fallback:** fail closed immediately when shell-expanded arguments prevent an allowlisted command from producing an enforceable execution plan and effective policy is `ask=off` with `askFallback=deny`, instead of registering an approval that can only time out. Fixes #113191. Thanks @shakkernerd.
- **Cron local-provider preflight:** report the guarded-fetch deadline as a bounded preflight timeout, preserve concrete nested non-timeout errors, and carry the failure reason into fallback warnings. Thanks @shakkernerd.
- **Buzz standalone sends:** let `openclaw message send` and other non-Gateway processes open a bounded authenticated relay connection, publish the message, and close cleanly while running Gateways continue to reuse their active connection. Thanks @shakkernerd.
- **Buzz bot profiles:** persist optional Buzz account names, publish them as bot display names without delaying Gateway startup, preserve existing profile metadata, and include configured owner attestations so Buzz can show verified provenance. Thanks @shakkernerd.
- **Buzz resumable setup:** persist paused bot identities, resume disabled setup in place, retry authenticated room discovery without rotating keys, require verified **Bot**-role room membership instead of accepting unverified room UUIDs, and give accurate CLI authorization guidance for generated identities that Buzz desktop cannot discover. Thanks @shakkernerd.
- **Buzz inbound authorization:** apply shared room sender and command authorization before agent dispatch, allow authorized control commands to bypass mention gating, and preserve Buzz thread/reply identifiers through delivery. Thanks @shakkernerd.
- **ClickClack split-origin setup codes:** consume versioned exact claim endpoints without appending a second claim path, validate the returned canonical API base, preserve private API transport overrides, and keep legacy setup URLs working. Fixes #111919. Thanks @shakkernerd.

View File

@@ -89,9 +89,8 @@ disabled; the next setup run offers to reuse that identity.
Every target room must contain the bot identity with the **Bot** role. An
existing human member or ordinary room member role is not sufficient.
Buzz desktop's room member picker searches published profiles and cannot add a
newly generated bare OpenClaw identity by public key. Use the Buzz CLI as the
existing human room owner or admin:
Buzz desktop cannot reliably assign the Bot role to an externally managed
OpenClaw identity. Use the Buzz CLI as the existing human room owner or admin:
```bash
buzz channels add-member \
@@ -103,6 +102,16 @@ buzz channels add-member \
Run that command as the existing human owner or admin. Never give OpenClaw that
human private key.
After the Gateway connects, OpenClaw publishes the configured Buzz channel
account name as the bot's Buzz display name. If you skip optional account
naming, the display name defaults to `OpenClaw`. This replaces the shortened
public key in Buzz after its profile cache refreshes.
Buzz displays `owner unavailable` when the bot profile has no valid NIP-OA
owner attestation. This does not mean room access failed. When
`channels.buzz.authTag` is configured, OpenClaw includes that attestation in the
published profile so Buzz can show the verified human owner.
The local Buzz `just dev` relay does not require separate relay membership by
default. A hosted or closed relay may require the bot public key to be added to
the workspace community first. Adding community membership grants relay access;
@@ -196,6 +205,7 @@ Guided setup is recommended. The equivalent configuration looks like:
{
channels: {
buzz: {
name: "OpenClaw",
relayUrl: "wss://buzz.example.com",
privateKey: "nsec1...",
groupPolicy: "allowlist",

View File

@@ -80,14 +80,18 @@ The guided flow asks for your Buzz relay URL and creates a dedicated bot
identity by default. Give the displayed **public key only** to a Buzz admin, who
must add the identity to each room with the **Bot** role.
After the Gateway connects, OpenClaw publishes the Buzz channel account name as
the bot's Buzz display name. The default is `OpenClaw`. A configured NIP-OA
`authTag` is preserved in that profile so Buzz can display its verified owner.
OpenClaw immediately attempts authenticated room discovery. If room access is
not ready, add the bot in Buzz and retry without leaving setup. You can also
finish later: OpenClaw saves the identity with Buzz disabled, and the next setup
run offers to reuse it instead of generating another key.
For local Buzz development, `just dev` does not require separate relay
membership by default. Buzz desktop's room member picker cannot add a newly
generated bare identity by public key, so add the bot directly with the CLI:
membership by default. Buzz desktop cannot reliably assign the Bot role to an
externally managed identity, so add the bot directly with the CLI:
```bash
buzz channels add-member \

View File

@@ -5,6 +5,7 @@ import {
parseBuzzMessageEvent,
type BuzzInboundMessage,
} from "./message-event.js";
import { syncBuzzProfile } from "./profile.js";
import { authenticateBuzzRelay, createBuzzAuthSigner, parseBuzzAuthTag } from "./relay-auth.js";
import { decodeBuzzPrivateKey, resolveBuzzPublicKey } from "./types.js";
@@ -100,6 +101,9 @@ export async function startBuzzBus(options: {
onMessageError?: (error: Error) => void;
onFatalError?: (error: Error) => void;
onDedupeError?: (error: Error) => void;
profileName?: string;
onProfilePublished?: (eventId: string) => void;
onProfileError?: (error: Error) => void;
signal?: AbortSignal;
}): Promise<BuzzBus> {
const secretKey = decodeBuzzPrivateKey(options.privateKey);
@@ -180,6 +184,33 @@ export async function startBuzzBus(options: {
},
),
);
// Profile metadata is presentation-only. Synchronize it after message
// subscriptions are live so a slow profile query cannot delay Gateway readiness.
if (options.profileName?.trim()) {
void syncBuzzProfile({
relay,
secretKey,
publicKey,
displayName: options.profileName,
authTag,
signal: options.signal,
})
.then((result) => {
if (result.status === "published") {
options.onProfilePublished?.(result.eventId);
}
})
.catch((error: unknown) => {
if (options.signal?.aborted) {
return;
}
options.onProfileError?.(
error instanceof Error
? error
: new Error("Buzz profile sync failed", { cause: error }),
);
});
}
return bus;
} catch (error) {

View File

@@ -38,6 +38,7 @@ export async function startBuzzGatewayAccount(ctx: ChannelGatewayContext<Resolve
throw new Error("Buzz requires at least one channels.buzz.groups entry");
}
const configuredChannelIds = new Set(channelIds);
const profileName = account.name?.trim() || "OpenClaw";
let hasAttemptedSession = false;
let reconnectAttempt = 0;
@@ -58,6 +59,7 @@ export async function startBuzzGatewayAccount(ctx: ChannelGatewayContext<Resolve
relayUrl: account.relayUrl,
privateKey: account.privateKey,
authTag: account.authTag,
profileName,
channelIds,
since: sessionSince,
signal: ctx.abortSignal,
@@ -78,6 +80,12 @@ export async function startBuzzGatewayAccount(ctx: ChannelGatewayContext<Resolve
onDedupeError: (error) => {
ctx.log?.error?.(`[${account.accountId}] Buzz replay state failed: ${error.message}`);
},
onProfilePublished: () => {
ctx.log?.info?.(`[${account.accountId}] Buzz bot profile published as "${profileName}"`);
},
onProfileError: (error) => {
ctx.log?.warn?.(`[${account.accountId}] Buzz bot profile sync failed: ${error.message}`);
},
});
connectedAt = Date.now();
activeBuses.set(account.accountId, bus);

View File

@@ -0,0 +1,132 @@
import { finalizeEvent, type Event, type Relay } from "nostr-tools";
const PROFILE_KIND = 0;
const PROFILE_QUERY_TIMEOUT_MS = 5_000;
export type BuzzProfileSyncResult =
| { status: "unchanged" }
| { status: "published"; eventId: string };
function parseProfileContent(event: Event | undefined): Record<string, unknown> {
if (!event) {
return {};
}
try {
const parsed: unknown = JSON.parse(event.content);
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? { ...parsed }
: {};
} catch {
return {};
}
}
function resolveProfileTags(event: Event | undefined, authTag: string[] | undefined): string[][] {
const existingTags = event?.tags ?? [];
if (!authTag) {
return existingTags.map((tag) => [...tag]);
}
return [...existingTags.filter((tag) => tag[0] !== "auth").map((tag) => [...tag]), [...authTag]];
}
function hasConfiguredAuthTag(event: Event | undefined, authTag: string[] | undefined): boolean {
if (!authTag) {
return true;
}
const authTags = event?.tags.filter((tag) => tag[0] === "auth") ?? [];
return authTags.length === 1 && JSON.stringify(authTags[0]) === JSON.stringify(authTag);
}
async function queryCurrentProfile(params: {
relay: Relay;
publicKey: string;
signal?: AbortSignal;
}): Promise<Event | undefined> {
params.signal?.throwIfAborted();
return await new Promise<Event | undefined>((resolve, reject) => {
const events: Event[] = [];
const state: {
settled: boolean;
timeout?: ReturnType<typeof setTimeout>;
subscription?: ReturnType<Relay["subscribe"]>;
} = { settled: false };
const finish = (error?: unknown) => {
if (state.settled) {
return;
}
state.settled = true;
if (state.timeout) {
clearTimeout(state.timeout);
}
params.signal?.removeEventListener("abort", onAbort);
state.subscription?.close("profile query complete");
if (error !== undefined) {
reject(
error instanceof Error ? error : new Error("Buzz profile query failed", { cause: error }),
);
return;
}
resolve(
events.reduce<Event | undefined>(
(latest, event) => (!latest || event.created_at > latest.created_at ? event : latest),
undefined,
),
);
};
const onAbort = () => finish(params.signal?.reason ?? new Error("Buzz profile query aborted"));
params.signal?.addEventListener("abort", onAbort, { once: true });
state.timeout = setTimeout(
() => finish(new Error("Timed out querying the Buzz bot profile")),
PROFILE_QUERY_TIMEOUT_MS,
);
state.subscription = params.relay.subscribe(
[{ kinds: [PROFILE_KIND], authors: [params.publicKey], limit: 1 }],
{
onevent: (event) => events.push(event),
oneose: () => finish(),
onclose: (reason) => {
if (reason !== "profile query complete") {
finish(new Error(`Buzz profile query closed: ${reason}`));
}
},
},
);
if (state.settled) {
state.subscription.close("profile query complete");
}
});
}
export async function syncBuzzProfile(params: {
relay: Relay;
secretKey: Uint8Array;
publicKey: string;
displayName: string;
authTag?: string[];
signal?: AbortSignal;
}): Promise<BuzzProfileSyncResult> {
const displayName = params.displayName.trim();
if (!displayName) {
return { status: "unchanged" };
}
const current = await queryCurrentProfile(params);
const content = parseProfileContent(current);
if (content.display_name === displayName && hasConfiguredAuthTag(current, params.authTag)) {
return { status: "unchanged" };
}
content.display_name = displayName;
const now = Math.floor(Date.now() / 1000);
const event = finalizeEvent(
{
kind: PROFILE_KIND,
content: JSON.stringify(content),
created_at: current ? Math.max(now, current.created_at + 1) : now,
tags: resolveProfileTags(current, params.authTag),
},
params.secretKey,
);
await params.relay.publish(event);
return { status: "published", eventId: event.id };
}

View File

@@ -4,6 +4,7 @@ import {
type ChannelSetupInput,
} from "openclaw/plugin-sdk/channel-setup";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/setup";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup-runtime";
import { decodeBuzzPrivateKey, resolveBuzzPublicKey } from "./types.js";
@@ -42,6 +43,13 @@ export function isSameBuzzIdentity(currentKey?: string, nextKey?: string): boole
export const buzzSetupAdapter: ChannelSetupAdapter<BuzzSetupInput> = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "buzz",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (accountId !== DEFAULT_ACCOUNT_ID) {
return "Buzz currently supports only the default account.";

View File

@@ -270,7 +270,7 @@ async function noteBuzzAccessInstructions(params: {
` Bot npub: ${npub}`,
` Bot hex public key: ${hex}`,
"",
"Buzz desktop's room member picker searches published profiles and cannot add this newly generated bare identity.",
"Buzz desktop cannot reliably assign the Bot role to this externally managed identity.",
"Use Buzz CLI as the existing human room owner/admin:",
`buzz channels add-member --channel <ROOM_UUID> --pubkey ${hex} --role bot`,
"",
@@ -315,6 +315,7 @@ export function createBuzzSetupWizard(
[
"You need a Buzz relay URL, a Buzz admin, and at least one target room.",
"OpenClaw creates a dedicated bot identity and shares only its public key for approval.",
"After setup, the Buzz channel account name is published as the bot's Buzz display name.",
`Docs: ${formatDocsLink("/channels/buzz", "channels/buzz")}`,
].join("\n"),
"Before you set up Buzz",