fix: register Buzz agent identities

This commit is contained in:
Shakker
2026-07-26 06:31:25 +01:00
committed by Shakker
parent 480a384ad2
commit ffb00ca624
5 changed files with 119 additions and 34 deletions
+1
View File
@@ -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.
+7
View File
@@ -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
+5
View File
@@ -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.
@@ -241,7 +241,8 @@ describe("Buzz bus lifecycle", () => {
await vi.waitFor(() => expect(onMessageError).toHaveBeenCalledWith(expect.any(Error)));
await new Promise<void>((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();
});
+104 -33
View File
@@ -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<string, unknown>, 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<Event | undefined> {
}): Promise<Map<number, Event>> {
params.signal?.throwIfAborted();
return await new Promise<Event | undefined>((resolve, reject) => {
const events: Event[] = [];
return await new Promise<Map<number, Event>>((resolve, reject) => {
const latestByKind = new Map<number, Event>();
const state: {
settled: boolean;
timeout?: ReturnType<typeof setTimeout>;
@@ -66,12 +74,7 @@ async function queryCurrentProfile(params: {
);
return;
}
resolve(
events.reduce<Event | undefined>(
(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<string, unknown>;
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" };
}