feat: add Buzz directory state model

This commit is contained in:
Shakker
2026-07-31 07:34:46 +01:00
parent 146bb513e8
commit ba2510e8fa
2 changed files with 553 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
import type { Event } from "nostr-tools";
import { describe, expect, it } from "vitest";
import {
BuzzDirectoryState,
BUZZ_PROFILE_KIND,
BUZZ_ROOM_METADATA_KIND,
parseBuzzDirectoryProfileEvent,
} from "./directory-state.js";
import type { BuzzRoomMembership } from "./room-membership.js";
const BOT_PUBLIC_KEY = "a".repeat(64);
const ALICE_PUBLIC_KEY = "b".repeat(64);
const BOB_PUBLIC_KEY = "c".repeat(64);
const ROOM_ID = "7c4a6d2a-2ed9-4b4e-a5e2-4d705ee9b34c";
function event(params: Partial<Event> & Pick<Event, "kind" | "pubkey">): Event {
return {
id: params.id ?? "f".repeat(64),
kind: params.kind,
pubkey: params.pubkey,
created_at: params.created_at ?? 1_700_000_000,
content: params.content ?? "",
sig: params.sig ?? "e".repeat(128),
tags: params.tags ?? [],
};
}
function membership(members: Array<[string, string?]>): BuzzRoomMembership {
return {
roomId: ROOM_ID,
createdAt: 1_700_000_000,
eventId: "1".repeat(64),
publisherPublicKey: "d".repeat(64),
members: new Set(members.map(([publicKey]) => publicKey)),
roles: new Map(
members
.filter((entry): entry is [string, string] => Boolean(entry[1]))
.map(([publicKey, role]) => [publicKey, role]),
),
};
}
describe("Buzz directory state", () => {
it("parses Buzz profile precedence without trusting malformed content", () => {
expect(
parseBuzzDirectoryProfileEvent(
event({
kind: BUZZ_PROFILE_KIND,
pubkey: ALICE_PUBLIC_KEY,
content: JSON.stringify({
display_name: "Alice",
name: "ignored",
picture: "https://example.com/alice.png",
image: "https://example.com/ignored.png",
nip05: "alice@example.com",
}),
}),
),
).toMatchObject({
publicKey: ALICE_PUBLIC_KEY,
displayName: "Alice",
handle: "alice@example.com",
avatarUrl: "https://example.com/alice.png",
});
expect(
parseBuzzDirectoryProfileEvent(
event({
kind: BUZZ_PROFILE_KIND,
pubkey: ALICE_PUBLIC_KEY,
content: JSON.stringify({ name: "Fallback", image: "https://example.com/fallback.png" }),
}),
),
).toMatchObject({
displayName: "Fallback",
avatarUrl: "https://example.com/fallback.png",
});
expect(
parseBuzzDirectoryProfileEvent(
event({
kind: BUZZ_PROFILE_KIND,
pubkey: ALICE_PUBLIC_KEY,
content: JSON.stringify({ display_name: "", name: "not-used" }),
}),
)?.displayName,
).toBeUndefined();
expect(
parseBuzzDirectoryProfileEvent(
event({ kind: BUZZ_PROFILE_KIND, pubkey: ALICE_PUBLIC_KEY, content: "{" }),
),
).toBeUndefined();
});
it("keeps stable public-key ids while applying current profiles and room metadata", () => {
const state = new BuzzDirectoryState({
publicKey: BOT_PUBLIC_KEY,
fallbackProfileName: "OpenClaw",
channelIds: [ROOM_ID],
});
state.replaceMemberships(
new Map([
[
ROOM_ID,
membership([
[BOT_PUBLIC_KEY, "bot"],
[ALICE_PUBLIC_KEY, "member"],
[BOB_PUBLIC_KEY, "member"],
]),
],
]),
);
state.applyProfileEvent(
event({
kind: BUZZ_PROFILE_KIND,
pubkey: ALICE_PUBLIC_KEY,
content: JSON.stringify({
display_name: "Alice",
picture: "https://example.com/alice.png",
}),
}),
);
state.applyRoomEvent(
event({
kind: BUZZ_ROOM_METADATA_KIND,
pubkey: "d".repeat(64),
tags: [
["d", ROOM_ID],
["name", "Engineering"],
],
}),
);
expect(state.resolveSenderName(ALICE_PUBLIC_KEY)).toBe("Alice");
expect(state.resolveSenderName(BOB_PUBLIC_KEY)).toBe("cccccccc...cccccc");
expect(state.listPeers({ query: "ali" })).toEqual([
expect.objectContaining({
kind: "user",
id: ALICE_PUBLIC_KEY,
name: "Alice",
avatarUrl: "https://example.com/alice.png",
}),
]);
expect(state.listGroups({})).toEqual([
expect.objectContaining({
kind: "group",
id: `buzz:${ROOM_ID}`,
name: "Engineering",
}),
]);
expect(state.listGroupMembers({ groupId: `buzz:${ROOM_ID}`, limit: 2 })).toHaveLength(2);
});
it("uses deterministic latest-event ordering and bounded profile selection", () => {
const state = new BuzzDirectoryState({
publicKey: BOT_PUBLIC_KEY,
fallbackProfileName: "OpenClaw",
channelIds: [ROOM_ID],
profileLimit: 2,
});
state.replaceMemberships(
new Map([
[
ROOM_ID,
membership([
[BOT_PUBLIC_KEY, "bot"],
[BOB_PUBLIC_KEY, "member"],
[ALICE_PUBLIC_KEY, "member"],
]),
],
]),
);
expect(state.profilePublicKeys()).toEqual([BOT_PUBLIC_KEY, ALICE_PUBLIC_KEY]);
expect(
state.applyProfileEvent(
event({
id: "2".repeat(64),
kind: BUZZ_PROFILE_KIND,
pubkey: ALICE_PUBLIC_KEY,
content: JSON.stringify({ display_name: "First" }),
}),
),
).toBe(true);
expect(
state.applyProfileEvent(
event({
id: "3".repeat(64),
kind: BUZZ_PROFILE_KIND,
pubkey: ALICE_PUBLIC_KEY,
content: JSON.stringify({ display_name: "Older tie" }),
}),
),
).toBe(false);
expect(
state.applyProfileEvent(
event({
id: "1".repeat(64),
kind: BUZZ_PROFILE_KIND,
pubkey: ALICE_PUBLIC_KEY,
content: JSON.stringify({ display_name: "Winning tie" }),
}),
),
).toBe(true);
expect(
state.applyProfileEvent(
event({
kind: BUZZ_PROFILE_KIND,
pubkey: BOB_PUBLIC_KEY,
content: JSON.stringify({ display_name: "Outside cap" }),
}),
),
).toBe(false);
expect(state.resolveSenderName(ALICE_PUBLIC_KEY)).toBe("Winning tie");
expect(state.resolveSenderName(BOB_PUBLIC_KEY)).toBe("cccccccc...cccccc");
});
});
+338
View File
@@ -0,0 +1,338 @@
import type { Event } from "nostr-tools";
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/directory-runtime";
import type { BuzzRoomMembership } from "./room-membership.js";
import { buildBuzzTarget, parseBuzzTarget } from "./target.js";
export const BUZZ_PROFILE_KIND = 0;
export const BUZZ_ROOM_METADATA_KIND = 39_000;
export const BUZZ_PROFILE_QUERY_CHUNK_SIZE = 200;
export const DEFAULT_BUZZ_DIRECTORY_PROFILE_LIMIT = 2_000;
const HEX_PUBLIC_KEY_PATTERN = /^[0-9a-f]{64}$/u;
const MAX_DIRECTORY_NAME_CHARS = 512;
const MAX_DIRECTORY_HANDLE_CHARS = 320;
const MAX_DIRECTORY_URL_CHARS = 4_096;
export type BuzzDirectoryProfile = {
publicKey: string;
displayName?: string;
handle?: string;
avatarUrl?: string;
createdAt: number;
eventId: string;
};
export type BuzzDirectoryRoom = {
roomId: string;
name?: string;
createdAt: number;
eventId: string;
};
function normalizeBoundedString(value: unknown, maxChars: number): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
return [...trimmed].slice(0, maxChars).join("");
}
function readPreferredString(params: {
content: Record<string, unknown>;
primary: string;
fallback: string;
maxChars: number;
}): string | undefined {
if (Object.hasOwn(params.content, params.primary)) {
return normalizeBoundedString(params.content[params.primary], params.maxChars);
}
return normalizeBoundedString(params.content[params.fallback], params.maxChars);
}
function isNewerEvent(
candidate: { createdAt: number; eventId: string },
current: { createdAt: number; eventId: string } | undefined,
): boolean {
return (
!current ||
candidate.createdAt > current.createdAt ||
(candidate.createdAt === current.createdAt && candidate.eventId < current.eventId)
);
}
function fallbackPublicKeyLabel(publicKey: string): string {
return `${publicKey.slice(0, 8)}...${publicKey.slice(-6)}`;
}
function matchesDirectoryQuery(entry: ChannelDirectoryEntry, query: string): boolean {
if (!query) {
return true;
}
return [entry.id, entry.name, entry.handle].some((value) => value?.toLowerCase().includes(query));
}
function applyQueryAndLimit(
entries: ChannelDirectoryEntry[],
params: { query?: string | null; limit?: number | null },
): ChannelDirectoryEntry[] {
const query = params.query?.trim().toLowerCase() ?? "";
const limit =
typeof params.limit === "number" && params.limit > 0 ? Math.floor(params.limit) : undefined;
const result: ChannelDirectoryEntry[] = [];
for (const entry of entries) {
if (!matchesDirectoryQuery(entry, query)) {
continue;
}
result.push(entry);
if (limit !== undefined && result.length >= limit) {
break;
}
}
return result;
}
export function parseBuzzDirectoryProfileEvent(event: Event): BuzzDirectoryProfile | undefined {
const publicKey = event.pubkey.trim().toLowerCase();
if (event.kind !== BUZZ_PROFILE_KIND || !HEX_PUBLIC_KEY_PATTERN.test(publicKey)) {
return undefined;
}
let content: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(event.content);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return undefined;
}
content = parsed as Record<string, unknown>;
} catch {
return undefined;
}
return {
publicKey,
displayName: readPreferredString({
content,
primary: "display_name",
fallback: "name",
maxChars: MAX_DIRECTORY_NAME_CHARS,
}),
handle: normalizeBoundedString(content.nip05, MAX_DIRECTORY_HANDLE_CHARS),
avatarUrl: readPreferredString({
content,
primary: "picture",
fallback: "image",
maxChars: MAX_DIRECTORY_URL_CHARS,
}),
createdAt: event.created_at,
eventId: event.id,
};
}
export function parseBuzzDirectoryRoomEvent(event: Event): BuzzDirectoryRoom | undefined {
if (event.kind !== BUZZ_ROOM_METADATA_KIND) {
return undefined;
}
const roomId = event.tags
.find((tag) => tag[0] === "d")?.[1]
?.trim()
.toLowerCase();
if (!roomId) {
return undefined;
}
try {
parseBuzzTarget(roomId);
} catch {
return undefined;
}
return {
roomId,
name: normalizeBoundedString(
event.tags.find((tag) => tag[0] === "name")?.[1],
MAX_DIRECTORY_NAME_CHARS,
),
createdAt: event.created_at,
eventId: event.id,
};
}
export class BuzzDirectoryState {
readonly #publicKey: string;
readonly #fallbackProfileName: string;
readonly #configuredRoomIds: Set<string>;
readonly #profileLimit: number;
#memberships = new Map<string, BuzzRoomMembership>();
#profilePublicKeys = new Set<string>();
#profiles = new Map<string, BuzzDirectoryProfile>();
#rooms = new Map<string, BuzzDirectoryRoom>();
constructor(params: {
publicKey: string;
fallbackProfileName: string;
channelIds: string[];
profileLimit?: number;
}) {
this.#publicKey = params.publicKey.trim().toLowerCase();
this.#fallbackProfileName = params.fallbackProfileName.trim() || "OpenClaw";
this.#configuredRoomIds = new Set(params.channelIds.map(parseBuzzTarget));
const requestedProfileLimit = params.profileLimit ?? DEFAULT_BUZZ_DIRECTORY_PROFILE_LIMIT;
this.#profileLimit =
Number.isFinite(requestedProfileLimit) && requestedProfileLimit > 0
? Math.floor(requestedProfileLimit)
: DEFAULT_BUZZ_DIRECTORY_PROFILE_LIMIT;
this.#profilePublicKeys.add(this.#publicKey);
}
replaceMemberships(memberships: ReadonlyMap<string, BuzzRoomMembership>): boolean {
const nextMemberships = new Map<string, BuzzRoomMembership>();
const memberPublicKeys = new Set<string>();
for (const roomId of this.#configuredRoomIds) {
const membership = memberships.get(roomId);
if (!membership) {
continue;
}
nextMemberships.set(roomId, membership);
for (const publicKey of membership.members) {
memberPublicKeys.add(publicKey);
}
}
memberPublicKeys.delete(this.#publicKey);
const nextProfilePublicKeys = new Set<string>([
this.#publicKey,
...[...memberPublicKeys].toSorted().slice(0, this.#profileLimit - 1),
]);
const profileSelectionChanged =
nextProfilePublicKeys.size !== this.#profilePublicKeys.size ||
[...nextProfilePublicKeys].some((publicKey) => !this.#profilePublicKeys.has(publicKey));
this.#memberships = nextMemberships;
this.#profilePublicKeys = nextProfilePublicKeys;
for (const publicKey of this.#profiles.keys()) {
if (!nextProfilePublicKeys.has(publicKey)) {
this.#profiles.delete(publicKey);
}
}
return profileSelectionChanged;
}
profilePublicKeys(): string[] {
return [...this.#profilePublicKeys];
}
applyProfileEvent(event: Event): boolean {
const profile = parseBuzzDirectoryProfileEvent(event);
if (
!profile ||
!this.#profilePublicKeys.has(profile.publicKey) ||
!isNewerEvent(profile, this.#profiles.get(profile.publicKey))
) {
return false;
}
this.#profiles.set(profile.publicKey, profile);
return true;
}
applyRoomEvent(event: Event): boolean {
const room = parseBuzzDirectoryRoomEvent(event);
if (
!room ||
!this.#configuredRoomIds.has(room.roomId) ||
!isNewerEvent(room, this.#rooms.get(room.roomId))
) {
return false;
}
this.#rooms.set(room.roomId, room);
return true;
}
resolveSenderName(publicKey: string): string {
const normalized = publicKey.trim().toLowerCase();
return this.#profiles.get(normalized)?.displayName ?? fallbackPublicKeyLabel(normalized);
}
self(): ChannelDirectoryEntry {
return this.#buildUserEntry(this.#publicKey);
}
listPeers(params: { query?: string | null; limit?: number | null }): ChannelDirectoryEntry[] {
const peers = new Set<string>();
for (const membership of this.#memberships.values()) {
for (const publicKey of membership.members) {
if (publicKey !== this.#publicKey) {
peers.add(publicKey);
}
}
}
const entries = [...peers]
.map((publicKey) => this.#buildUserEntry(publicKey))
.toSorted(compareDirectoryEntries);
return applyQueryAndLimit(entries, params);
}
listGroups(params: { query?: string | null; limit?: number | null }): ChannelDirectoryEntry[] {
const entries = [...this.#configuredRoomIds]
.map((roomId) => this.#buildRoomEntry(roomId))
.toSorted(compareDirectoryEntries);
return applyQueryAndLimit(entries, params);
}
listGroupMembers(params: { groupId: string; limit?: number | null }): ChannelDirectoryEntry[] {
let roomId: string;
try {
roomId = parseBuzzTarget(params.groupId);
} catch {
return [];
}
const membership = this.#memberships.get(roomId);
if (!membership) {
return [];
}
const entries = [...membership.members]
.map((publicKey) => {
const entry = this.#buildUserEntry(publicKey);
return {
...entry,
raw: {
publicKey,
role: membership.roles.get(publicKey),
roomId,
},
} satisfies ChannelDirectoryEntry;
})
.toSorted(compareDirectoryEntries);
return applyQueryAndLimit(entries, { limit: params.limit });
}
#buildUserEntry(publicKey: string): ChannelDirectoryEntry {
const profile = this.#profiles.get(publicKey);
const name =
profile?.displayName ??
(publicKey === this.#publicKey
? this.#fallbackProfileName
: fallbackPublicKeyLabel(publicKey));
return {
kind: "user",
id: publicKey,
name,
handle: profile?.handle,
avatarUrl: profile?.avatarUrl,
raw: { publicKey },
};
}
#buildRoomEntry(roomId: string): ChannelDirectoryEntry {
const room = this.#rooms.get(roomId);
return {
kind: "group",
id: buildBuzzTarget(roomId),
name: room?.name ?? roomId,
handle: room?.name ? `#${room.name}` : undefined,
raw: { roomId },
};
}
}
function compareDirectoryEntries(a: ChannelDirectoryEntry, b: ChannelDirectoryEntry): number {
const aLabel = a.name ?? a.handle ?? a.id;
const bLabel = b.name ?? b.handle ?? b.id;
return aLabel.localeCompare(bLabel) || a.id.localeCompare(b.id);
}