mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix: register Buzz agent identities
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user