From 08eac200b36bcc9ce5d567cd8ffb8fcd71ff92cf Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Jul 2026 02:09:18 +0100 Subject: [PATCH] fix: publish Buzz bot profile names --- CHANGELOG.md | 1 + docs/channels/buzz.md | 16 +++- extensions/buzz/README.md | 8 +- extensions/buzz/src/buzz-bus.ts | 31 +++++++ extensions/buzz/src/gateway.ts | 8 ++ extensions/buzz/src/profile.ts | 132 +++++++++++++++++++++++++++ extensions/buzz/src/setup-core.ts | 8 ++ extensions/buzz/src/setup-surface.ts | 3 +- 8 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 extensions/buzz/src/profile.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ca4b96269e..e1786bf2c7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/buzz.md b/docs/channels/buzz.md index 05548d453488..df7dae459aef 100644 --- a/docs/channels/buzz.md +++ b/docs/channels/buzz.md @@ -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", diff --git a/extensions/buzz/README.md b/extensions/buzz/README.md index fb95414e7597..82d1cf5fa7c2 100644 --- a/extensions/buzz/README.md +++ b/extensions/buzz/README.md @@ -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 \ diff --git a/extensions/buzz/src/buzz-bus.ts b/extensions/buzz/src/buzz-bus.ts index ea79e32e3ff3..4e86ca5bae9e 100644 --- a/extensions/buzz/src/buzz-bus.ts +++ b/extensions/buzz/src/buzz-bus.ts @@ -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 { 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) { diff --git a/extensions/buzz/src/gateway.ts b/extensions/buzz/src/gateway.ts index 3033a836bc05..129641dc498e 100644 --- a/extensions/buzz/src/gateway.ts +++ b/extensions/buzz/src/gateway.ts @@ -38,6 +38,7 @@ export async function startBuzzGatewayAccount(ctx: ChannelGatewayContext { 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); diff --git a/extensions/buzz/src/profile.ts b/extensions/buzz/src/profile.ts new file mode 100644 index 000000000000..6201bf243630 --- /dev/null +++ b/extensions/buzz/src/profile.ts @@ -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 { + 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 { + params.signal?.throwIfAborted(); + return await new Promise((resolve, reject) => { + const events: Event[] = []; + const state: { + settled: boolean; + timeout?: ReturnType; + subscription?: ReturnType; + } = { 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( + (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 { + 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 }; +} diff --git a/extensions/buzz/src/setup-core.ts b/extensions/buzz/src/setup-core.ts index 091ad85b3ad6..365d2cc28fda 100644 --- a/extensions/buzz/src/setup-core.ts +++ b/extensions/buzz/src/setup-core.ts @@ -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 = { 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."; diff --git a/extensions/buzz/src/setup-surface.ts b/extensions/buzz/src/setup-surface.ts index f8eb42aa7f14..4601fb9383dd 100644 --- a/extensions/buzz/src/setup-surface.ts +++ b/extensions/buzz/src/setup-surface.ts @@ -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 --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",