From ffb00ca624f99a701d0155fad017ff6bdb010852 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Jul 2026 06:31:25 +0100 Subject: [PATCH] fix: register Buzz agent identities --- CHANGELOG.md | 1 + docs/channels/buzz.md | 7 + extensions/buzz/README.md | 5 + .../buzz/src/buzz-bus.lifecycle.test.ts | 3 +- extensions/buzz/src/profile.ts | 137 +++++++++++++----- 5 files changed, 119 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45de9bee792b..1576bac0f5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - **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 presence:** publish nonblocking online presence when the Gateway connects, refresh it without overlapping heartbeat writes, and let Buzz's final-connection cleanup provide accurate offline state across reconnects and multiple Gateway instances. 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 agent identity:** register connected bots in Buzz's agent directory without overwriting existing profile policy, so later room invitations retain the Bot role instead of downgrading the identity to a normal member. Thanks @shakkernerd. - **Buzz guided setup:** reuse or generate the bot identity automatically, wait for Bot-role approval before falling back to identity-preserving Retry/Back controls, select single-room defaults, preserve advanced access settings, verify setup without posting test messages, finish targeted channel setup directly, derive new bot profiles from the routed agent identity, and authorize fresh setups from Buzz's live room roster without per-message relay queries. 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. diff --git a/docs/channels/buzz.md b/docs/channels/buzz.md index 2d2cf7d2ba4a..2638f44cdec2 100644 --- a/docs/channels/buzz.md +++ b/docs/channels/buzz.md @@ -114,6 +114,13 @@ account name, then the identity name of the single agent routed to the configured Buzz rooms, and finally `OpenClaw`. This replaces the shortened public key in Buzz after its profile cache refreshes. +OpenClaw also registers the same public identity in Buzz's agent directory. It +preserves an existing agent-directory profile and channel-add policy; for a new +profile it allows authorized Buzz users to add the identity. This lets Buzz +assign the **Bot** role when the identity is invited to additional rooms +instead of treating it as a normal member. OpenClaw still receives messages +only from rooms explicitly selected in `channels.buzz.groups`. + 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 diff --git a/extensions/buzz/README.md b/extensions/buzz/README.md index 4d5f54292e86..b925c12d2b1a 100644 --- a/extensions/buzz/README.md +++ b/extensions/buzz/README.md @@ -86,6 +86,11 @@ name. For a new profile it uses the explicit Buzz account name, then the identity name of the single agent routed to the configured rooms, and finally `OpenClaw`. A configured NIP-OA `authTag` is preserved in that profile so Buzz can display its verified owner. +OpenClaw also registers the public identity in Buzz's agent directory while +preserving any existing directory profile and channel-add policy. Buzz can then +recognize the identity as an agent and assign the **Bot** role when it is added +to more rooms. Those rooms still require explicit OpenClaw configuration before +the Gateway accepts messages from them. While the Gateway remains connected, OpenClaw also refreshes the bot's Buzz presence so room members see it as online. Buzz clears that presence when the last Gateway connection for the bot identity closes. diff --git a/extensions/buzz/src/buzz-bus.lifecycle.test.ts b/extensions/buzz/src/buzz-bus.lifecycle.test.ts index 040467aa2325..5bed6c74448a 100644 --- a/extensions/buzz/src/buzz-bus.lifecycle.test.ts +++ b/extensions/buzz/src/buzz-bus.lifecycle.test.ts @@ -241,7 +241,8 @@ describe("Buzz bus lifecycle", () => { await vi.waitFor(() => expect(onMessageError).toHaveBeenCalledWith(expect.any(Error))); await new Promise((resolve) => setTimeout(resolve, 0)); expect(relayMocks.publish.mock.calls.some(([event]) => event.kind === 0)).toBe(false); - expect(onProfilePublished).not.toHaveBeenCalled(); + expect(relayMocks.publish.mock.calls.some(([event]) => event.kind === 10_100)).toBe(true); + expect(onProfilePublished).toHaveBeenCalledOnce(); expect(onFatalError).not.toHaveBeenCalled(); await bus.close(); }); diff --git a/extensions/buzz/src/profile.ts b/extensions/buzz/src/profile.ts index 0b025b0de670..81b70975d2ba 100644 --- a/extensions/buzz/src/profile.ts +++ b/extensions/buzz/src/profile.ts @@ -1,7 +1,10 @@ import { finalizeEvent, type Event, type Relay } from "nostr-tools"; const PROFILE_KIND = 0; +const AGENT_PROFILE_KIND = 10_100; const PROFILE_QUERY_TIMEOUT_MS = 5_000; +const DEFAULT_CHANNEL_ADD_POLICY = "anyone"; +const CHANNEL_ADD_POLICIES = new Set(["anyone", "owner_only", "nobody"]); export type BuzzProfileSyncResult = | { status: "unchanged" } @@ -37,14 +40,19 @@ function hasConfiguredAuthTag(event: Event | undefined, authTag: string[] | unde return authTags.length === 1 && JSON.stringify(authTags[0]) === JSON.stringify(authTag); } -async function queryCurrentProfile(params: { +function readNonEmptyString(content: Record, key: string): string | undefined { + const value = content[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +async function queryCurrentProfiles(params: { relay: Relay; publicKey: string; signal?: AbortSignal; -}): Promise { +}): Promise> { params.signal?.throwIfAborted(); - return await new Promise((resolve, reject) => { - const events: Event[] = []; + return await new Promise>((resolve, reject) => { + const latestByKind = new Map(); const state: { settled: boolean; timeout?: ReturnType; @@ -66,12 +74,7 @@ async function queryCurrentProfile(params: { ); return; } - resolve( - events.reduce( - (latest, event) => (!latest || event.created_at > latest.created_at ? event : latest), - undefined, - ), - ); + resolve(latestByKind); }; const onAbort = () => finish(params.signal?.reason ?? new Error("Buzz profile query aborted")); params.signal?.addEventListener("abort", onAbort, { once: true }); @@ -80,9 +83,17 @@ async function queryCurrentProfile(params: { PROFILE_QUERY_TIMEOUT_MS, ); state.subscription = params.relay.subscribe( - [{ kinds: [PROFILE_KIND], authors: [params.publicKey], limit: 1 }], + [ + { kinds: [PROFILE_KIND], authors: [params.publicKey], limit: 1 }, + { kinds: [AGENT_PROFILE_KIND], authors: [params.publicKey], limit: 1 }, + ], { - onevent: (event) => events.push(event), + onevent: (event) => { + const current = latestByKind.get(event.kind); + if (!current || event.created_at > current.created_at) { + latestByKind.set(event.kind, event); + } + }, oneose: () => finish(), onclose: (reason) => { if (reason !== "profile query complete") { @@ -97,6 +108,25 @@ async function queryCurrentProfile(params: { }); } +function buildProfileEvent(params: { + kind: number; + content: Record; + current?: Event; + tags: string[][]; + secretKey: Uint8Array; +}): Event { + const now = Math.floor(Date.now() / 1000); + return finalizeEvent( + { + kind: params.kind, + content: JSON.stringify(params.content), + created_at: params.current ? Math.max(now, params.current.created_at + 1) : now, + tags: params.tags, + }, + params.secretKey, + ); +} + export async function syncBuzzProfile(params: { relay: Relay; secretKey: Uint8Array; @@ -110,29 +140,70 @@ export async function syncBuzzProfile(params: { return { status: "unchanged" }; } - const current = await queryCurrentProfile(params); - const content = parseProfileContent(current); - const currentDisplayName = - typeof content.display_name === "string" ? content.display_name.trim() : ""; - const resolvedDisplayName = currentDisplayName || displayName; + const currentProfiles = await queryCurrentProfiles(params); + const currentMetadata = currentProfiles.get(PROFILE_KIND); + const currentAgentProfile = currentProfiles.get(AGENT_PROFILE_KIND); + const metadataContent = parseProfileContent(currentMetadata); + const agentContent = parseProfileContent(currentAgentProfile); + const resolvedDisplayName = + readNonEmptyString(metadataContent, "display_name") ?? + readNonEmptyString(agentContent, "display_name") ?? + readNonEmptyString(agentContent, "name") ?? + displayName; + const events: Event[] = []; + if ( - content.display_name === resolvedDisplayName && - hasConfiguredAuthTag(current, params.authTag) + metadataContent.display_name !== resolvedDisplayName || + !hasConfiguredAuthTag(currentMetadata, params.authTag) ) { - return { status: "unchanged" }; + metadataContent.display_name = resolvedDisplayName; + events.push( + buildProfileEvent({ + kind: PROFILE_KIND, + content: metadataContent, + current: currentMetadata, + tags: resolveProfileTags(currentMetadata, params.authTag), + secretKey: params.secretKey, + }), + ); } - content.display_name = resolvedDisplayName; - 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 }; + let agentProfileChanged = false; + if (!readNonEmptyString(agentContent, "name")) { + agentContent.name = resolvedDisplayName; + agentProfileChanged = true; + } + if (!readNonEmptyString(agentContent, "display_name")) { + agentContent.display_name = resolvedDisplayName; + agentProfileChanged = true; + } + if ( + typeof agentContent.channel_add_policy !== "string" || + !CHANNEL_ADD_POLICIES.has(agentContent.channel_add_policy) + ) { + // OpenClaw accepts messages only from configured Bot-role rooms, so allowing + // room admins to add this public identity does not expand Gateway ingress. + agentContent.channel_add_policy = DEFAULT_CHANNEL_ADD_POLICY; + agentProfileChanged = true; + } + if (agentProfileChanged) { + events.push( + buildProfileEvent({ + kind: AGENT_PROFILE_KIND, + content: agentContent, + current: currentAgentProfile, + tags: currentAgentProfile?.tags.map((tag) => [...tag]) ?? [], + secretKey: params.secretKey, + }), + ); + } + + if (events.length === 0) { + return { status: "unchanged" }; + } + for (const event of events) { + await params.relay.publish(event); + } + const lastEvent = events.at(-1); + return lastEvent ? { status: "published", eventId: lastEvent.id } : { status: "unchanged" }; }