From 8fb9dcd54e07c0faf2ffc5ebd11cb70ae38b6644 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 2 Aug 2026 05:45:10 +0100 Subject: [PATCH] feat: emit native Buzz mention tags --- .../buzz/src/buzz-bus.lifecycle.test.ts | 38 +++++++++ extensions/buzz/src/buzz-bus.test.ts | 13 +++ extensions/buzz/src/buzz-bus.ts | 62 +++++++++++++- extensions/buzz/src/gateway.lifecycle.test.ts | 84 ++++++++++++++++++- extensions/buzz/src/gateway.ts | 13 ++- extensions/buzz/src/mentions.test.ts | 7 +- extensions/buzz/src/mentions.ts | 5 +- extensions/buzz/src/message-event.ts | 16 +++- 8 files changed, 229 insertions(+), 9 deletions(-) diff --git a/extensions/buzz/src/buzz-bus.lifecycle.test.ts b/extensions/buzz/src/buzz-bus.lifecycle.test.ts index 821c4828a7cd..a9c65285e4fe 100644 --- a/extensions/buzz/src/buzz-bus.lifecycle.test.ts +++ b/extensions/buzz/src/buzz-bus.lifecycle.test.ts @@ -305,6 +305,44 @@ describe("Buzz bus lifecycle", () => { expect(relayMocks.close).toHaveBeenCalledOnce(); }); + it("resolves standalone native mentions from the room snapshot before publishing", async () => { + relayMocks.auth.mockResolvedValue("ok"); + relayMocks.profileEvents = [ + finalizeEvent( + { + kind: 0, + created_at: 1_700_000_000, + content: JSON.stringify({ display_name: "Alice" }), + tags: [], + }, + Uint8Array.from(Buffer.from(SENDER_PRIVATE_KEY, "hex")), + ), + ]; + + await sendBuzzTextOneShot({ + relayUrl: "wss://buzz.example.com", + privateKey: PRIVATE_KEY, + channelId: CHANNEL_ID, + text: "Hello @Alice", + threadId: "root-id", + }); + + expect(relayMocks.publish.mock.calls[0]?.[0]).toMatchObject({ + kind: 9, + content: "Hello @Alice", + tags: [ + ["h", CHANNEL_ID], + ["e", "root-id", "", "reply"], + ["p", SENDER_PUBLIC_KEY], + ], + }); + expect(relayMocks.subscriptions.some((entry) => subscriptionIncludesKind(entry, 39002))).toBe( + true, + ); + expect(relayMocks.subscriptions.some((entry) => subscriptionIncludesKind(entry, 0))).toBe(true); + expect(relayMocks.close).toHaveBeenCalledOnce(); + }); + it("sends room and thread typing without waiting for a relay acknowledgement", async () => { relayMocks.auth.mockResolvedValue("ok"); const bus = await startBuzzBus({ diff --git a/extensions/buzz/src/buzz-bus.test.ts b/extensions/buzz/src/buzz-bus.test.ts index e91df4e5c204..694d0f79eec4 100644 --- a/extensions/buzz/src/buzz-bus.test.ts +++ b/extensions/buzz/src/buzz-bus.test.ts @@ -199,6 +199,19 @@ describe("Buzz message events", () => { ["e", "root-id", "", "root"], ["e", "parent-id", "", "reply"], ]); + expect( + buildBuzzMessageTags({ + channelId: "channel-id", + threadId: "root-id", + replyToId: "parent-id", + mentionedPubkeys: ["B".repeat(64), "b".repeat(64)], + }), + ).toEqual([ + ["h", "channel-id"], + ["e", "root-id", "", "root"], + ["e", "parent-id", "", "reply"], + ["p", "b".repeat(64)], + ]); }); it("validates the Buzz NIP-OA authentication tag shape", () => { diff --git a/extensions/buzz/src/buzz-bus.ts b/extensions/buzz/src/buzz-bus.ts index 135131d8747f..9a60109da6b3 100644 --- a/extensions/buzz/src/buzz-bus.ts +++ b/extensions/buzz/src/buzz-bus.ts @@ -1,7 +1,12 @@ import { type Relay, finalizeEvent, type Event } from "nostr-tools"; import { createChannelReplayGuard } from "openclaw/plugin-sdk/persistent-dedupe"; -import { queryBuzzDirectoryRooms, startBuzzDirectoryRelay } from "./directory-relay.js"; +import { + queryBuzzDirectoryProfiles, + queryBuzzDirectoryRooms, + startBuzzDirectoryRelay, +} from "./directory-relay.js"; import { BuzzDirectoryState } from "./directory-state.js"; +import { hasBuzzMentionSyntax, resolveBuzzMessageMentions } from "./mentions.js"; import { BUZZ_NORMAL_MESSAGE_KIND, BUZZ_TYPING_INDICATOR_KIND, @@ -21,6 +26,7 @@ import { resolveBuzzRoomHistoryLimit, } from "./replay-dispatch.js"; import { startBuzzRoomMembershipNotifications } from "./room-membership-notification.js"; +import { queryBuzzRoomMemberships } from "./room-membership-query.js"; import { createBuzzRoomMembershipTracker } from "./room-membership-tracker.js"; import { resolveBuzzSubscriptionBudget } from "./subscription-budget.js"; import { decodeBuzzPrivateKey, resolveBuzzPublicKey } from "./types.js"; @@ -41,6 +47,7 @@ export interface BuzzBus { text: string; threadId?: string; replyToId?: string; + mentionedPubkeys?: string[]; }) => Promise; sendTyping: (params: { channelId: string; @@ -56,6 +63,7 @@ function buildBuzzTextEvent(params: { text: string; threadId?: string; replyToId?: string; + mentionedPubkeys?: string[]; }): Event { return finalizeEvent( { @@ -150,6 +158,47 @@ export async function sendBuzzTextOneShot(params: { replyToId?: string; }): Promise { const secretKey = decodeBuzzPrivateKey(params.privateKey); + if (hasBuzzMentionSyntax(params.text)) { + const signal = AbortSignal.timeout(30_000); + const publicKey = resolveBuzzPublicKey(params.privateKey); + const { relay, relayPublicKey } = await connectAuthenticatedBuzzRelaySession({ + relayUrl: params.relayUrl, + secretKey, + authTag: parseBuzzAuthTag(params.authTag ?? ""), + signal, + }); + try { + const directory = new BuzzDirectoryState({ + publicKey, + fallbackProfileName: "OpenClaw", + channelIds: [params.channelId], + }); + directory.replaceMemberships( + await queryBuzzRoomMemberships({ + relay, + relayPublicKey, + channelIds: [params.channelId], + signal, + }), + ); + await queryBuzzDirectoryProfiles({ + relay, + state: directory, + publicKeys: directory.profilePublicKeys(), + signal, + }); + const mentionedPubkeys = resolveBuzzMessageMentions({ + text: params.text, + members: directory.mentionMembers(params.channelId), + senderPublicKey: publicKey, + }); + const event = buildBuzzTextEvent({ ...params, secretKey, mentionedPubkeys }); + await relay.publish(event); + return event.id; + } finally { + relay.close(); + } + } const relay = await connectAuthenticatedBuzzRelay({ relayUrl: params.relayUrl, secretKey, @@ -237,9 +286,16 @@ export async function startBuzzBus(options: { publicKey, directory, refreshDirectory: async () => await directoryRelay?.refreshRooms(options.channelIds), - sendText: async ({ channelId, text, threadId, replyToId }) => { + sendText: async ({ channelId, text, threadId, replyToId, mentionedPubkeys }) => { signal.throwIfAborted(); - const event = buildBuzzTextEvent({ secretKey, channelId, text, threadId, replyToId }); + const event = buildBuzzTextEvent({ + secretKey, + channelId, + text, + threadId, + replyToId, + mentionedPubkeys, + }); await relay.publish(event); return event.id; }, diff --git a/extensions/buzz/src/gateway.lifecycle.test.ts b/extensions/buzz/src/gateway.lifecycle.test.ts index 3d4b1ea67422..9cde11c219e0 100644 --- a/extensions/buzz/src/gateway.lifecycle.test.ts +++ b/extensions/buzz/src/gateway.lifecycle.test.ts @@ -18,6 +18,7 @@ const gatewayMocks = vi.hoisted(() => ({ | undefined, onMessageError: undefined as ((error: Error) => void) | undefined, onFatalError: undefined as ((error: Error) => void) | undefined, + currentBus: undefined as BuzzBus | undefined, resolveAgentIdentity: vi.fn(), resolveAgentRoute: vi.fn(), startBuzzBus: vi.fn(), @@ -63,6 +64,7 @@ describe("Buzz gateway lifecycle", () => { gatewayMocks.onMessage = undefined; gatewayMocks.onMessageError = undefined; gatewayMocks.onFatalError = undefined; + gatewayMocks.currentBus = undefined; gatewayMocks.busSendText.mockResolvedValue("event-id"); gatewayMocks.busSendTyping.mockResolvedValue(undefined); gatewayMocks.sendBuzzTextOneShot.mockResolvedValue("standalone-event-id"); @@ -95,7 +97,9 @@ describe("Buzz gateway lifecycle", () => { gatewayMocks.onMessage = options.onMessage; gatewayMocks.onMessageError = options.onMessageError; gatewayMocks.onFatalError = options.onFatalError; - return createMockBus(); + const bus = createMockBus(); + gatewayMocks.currentBus = bus; + return bus; }, ); }); @@ -250,6 +254,84 @@ describe("Buzz gateway lifecycle", () => { await expect(lifecycle).resolves.toBeUndefined(); }); + it("resolves native mentions from the active bus directory without a relay lookup", async () => { + const abortController = new AbortController(); + const memberPublicKey = "b".repeat(64); + const cfg = { + channels: { + buzz: { + relayUrl: "wss://buzz.example.com", + privateKey: PRIVATE_KEY, + groups: { [CHANNEL_ID]: {} }, + }, + }, + } as OpenClawConfig; + const account = resolveBuzzAccount({ cfg }); + const ctx = { + cfg, + accountId: account.accountId, + account, + runtime: {}, + abortSignal: abortController.signal, + log: { info: vi.fn(), error: vi.fn() }, + getStatus: vi.fn(), + setStatus: vi.fn(), + } as unknown as ChannelGatewayContext; + const lifecycle = startBuzzGatewayAccount(ctx); + await vi.waitFor(() => expect(gatewayMocks.currentBus).toBeDefined()); + const bus = gatewayMocks.currentBus; + if (!bus) { + throw new Error("expected active Buzz bus"); + } + bus.directory.replaceMemberships( + new Map([ + [ + CHANNEL_ID, + { + roomId: CHANNEL_ID, + createdAt: 1_700_000_000, + eventId: "1".repeat(64), + publisherPublicKey: "f".repeat(64), + members: new Set([BOT_PUBLIC_KEY, memberPublicKey]), + roles: new Map([ + [BOT_PUBLIC_KEY, "bot"], + [memberPublicKey, "member"], + ]), + }, + ], + ]), + ); + bus.directory.applyProfileEvent({ + id: "2".repeat(64), + kind: 0, + pubkey: memberPublicKey, + created_at: 1_700_000_000, + content: JSON.stringify({ display_name: "Alice" }), + sig: "e".repeat(128), + tags: [], + }); + + await buzzOutboundAdapter.sendText({ + cfg, + to: `buzz:${CHANNEL_ID}`, + text: "Hello @Alice", + accountId: "default", + threadId: "root-id", + }); + + expect(gatewayMocks.busSendText).toHaveBeenCalledWith({ + channelId: CHANNEL_ID, + text: "Hello @Alice", + threadId: "root-id", + replyToId: undefined, + mentionedPubkeys: [memberPublicKey], + }); + expect(gatewayMocks.sendBuzzTextOneShot).not.toHaveBeenCalled(); + + abortController.abort(); + await expect(lifecycle).resolves.toBeUndefined(); + }); + it("uses the active bus for heartbeat typing without destabilizing the account", async () => { const abortController = new AbortController(); const cfg = { diff --git a/extensions/buzz/src/gateway.ts b/extensions/buzz/src/gateway.ts index d0154c966f5f..713fc28c2725 100644 --- a/extensions/buzz/src/gateway.ts +++ b/extensions/buzz/src/gateway.ts @@ -5,6 +5,7 @@ import { computeBackoff, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env" import type { ChannelGatewayContext } from "../runtime-api.js"; import { sendBuzzTextOneShot, startBuzzBus, type BuzzBus } from "./buzz-bus.js"; import { handleBuzzInbound } from "./inbound.js"; +import { resolveBuzzMessageMentions } from "./mentions.js"; import { getBuzzRuntime } from "./runtime.js"; import { buildBuzzTarget, isConfiguredBuzzChannel, parseBuzzTarget } from "./target.js"; import { @@ -241,8 +242,18 @@ export const buzzOutboundAdapter = { threadId: threadId == null ? undefined : String(threadId), replyToId: replyToId == null ? undefined : String(replyToId), }; + const mentionedPubkeys = bus + ? resolveBuzzMessageMentions({ + text: message, + members: bus.directory.mentionMembers(channelId), + senderPublicKey: bus.publicKey, + }) + : []; const messageId = bus - ? await bus.sendText(outboundMessage) + ? await bus.sendText({ + ...outboundMessage, + ...(mentionedPubkeys.length > 0 ? { mentionedPubkeys } : {}), + }) : await sendBuzzTextOneShot({ relayUrl: account.relayUrl, privateKey: account.privateKey, diff --git a/extensions/buzz/src/mentions.test.ts b/extensions/buzz/src/mentions.test.ts index 058dc31a3933..ef775bdd12fd 100644 --- a/extensions/buzz/src/mentions.test.ts +++ b/extensions/buzz/src/mentions.test.ts @@ -1,6 +1,10 @@ import { nip19 } from "nostr-tools"; import { describe, expect, it } from "vitest"; -import { resolveBuzzMessageMentions, type BuzzMentionMember } from "./mentions.js"; +import { + hasBuzzMentionSyntax, + resolveBuzzMessageMentions, + type BuzzMentionMember, +} from "./mentions.js"; const BOT_PUBLIC_KEY = "a".repeat(64); const ALICE_PUBLIC_KEY = "b".repeat(64); @@ -83,5 +87,6 @@ describe("Buzz outbound mentions", () => { senderPublicKey: BOT_PUBLIC_KEY, }), ).toEqual([]); + expect(hasBuzzMentionSyntax("mail user@example.com")).toBe(false); }); }); diff --git a/extensions/buzz/src/mentions.ts b/extensions/buzz/src/mentions.ts index 6929a631e4ed..e82fd3d5ca93 100644 --- a/extensions/buzz/src/mentions.ts +++ b/extensions/buzz/src/mentions.ts @@ -164,7 +164,7 @@ function normalizeMembers(members: readonly BuzzMentionMember[]): Map 0 || extractNostrPubkeys(stripped).length > 0; } export function resolveBuzzMessageMentions(params: { @@ -174,7 +174,8 @@ export function resolveBuzzMessageMentions(params: { }): string[] { const stripped = stripCodeRegions(params.text); const explicitPublicKeys = extractNostrPubkeys(stripped); - const hasMentionText = stripped.includes("@") || explicitPublicKeys.length > 0; + const fallbackNames = extractMentionNames(stripped, []); + const hasMentionText = fallbackNames.length > 0 || explicitPublicKeys.length > 0; if (!hasMentionText) { return []; } diff --git a/extensions/buzz/src/message-event.ts b/extensions/buzz/src/message-event.ts index 2e035ca539fd..dce04c92b002 100644 --- a/extensions/buzz/src/message-event.ts +++ b/extensions/buzz/src/message-event.ts @@ -1,6 +1,7 @@ import { Buffer } from "node:buffer"; import type { Event } from "nostr-tools"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { BUZZ_MENTION_MAX_COUNT } from "./mentions.js"; export const BUZZ_NORMAL_MESSAGE_KIND = 9; export const BUZZ_TYPING_INDICATOR_KIND = 20_002; @@ -18,7 +19,7 @@ type BuzzInboundMessageKind = (typeof BUZZ_INBOUND_MESSAGE_KINDS)[number]; // own stricter validator. Keep inbound admission aligned with those limits. const BUZZ_MESSAGE_CONTENT_MAX_BYTES = 256 * 1024; const BUZZ_DIFF_CONTENT_MAX_BYTES = 60 * 1024; -const BUZZ_MENTION_MAX_COUNT = 50; +const HEX_PUBLIC_KEY_PATTERN = /^[0-9a-f]{64}$/u; const BUZZ_DIFF_CONTEXT_FIELD_MAX_CHARS = 256; const BUZZ_DIFF_AGENT_CONTEXT_MAX_CHARS = 4_000; const BUZZ_DIFF_AGENT_CONTEXT_TRUNCATED_SUFFIX = "\n...[Buzz diff truncated for model context]"; @@ -260,6 +261,7 @@ export function buildBuzzMessageTags(params: { channelId: string; threadId?: string; replyToId?: string; + mentionedPubkeys?: readonly string[]; }): string[][] { const tags: string[][] = [["h", params.channelId]]; const parentId = params.replyToId ?? params.threadId; @@ -269,5 +271,17 @@ export function buildBuzzMessageTags(params: { if (parentId) { tags.push(["e", parentId, "", "reply"]); } + const mentionedPubkeys = [ + ...new Set((params.mentionedPubkeys ?? []).map((publicKey) => publicKey.trim().toLowerCase())), + ]; + if (mentionedPubkeys.length > BUZZ_MENTION_MAX_COUNT) { + throw new Error(`Buzz messages support at most ${BUZZ_MENTION_MAX_COUNT} mentions`); + } + for (const publicKey of mentionedPubkeys) { + if (!HEX_PUBLIC_KEY_PATTERN.test(publicKey)) { + throw new Error("Buzz mentions require 64-character hex public keys"); + } + tags.push(["p", publicKey]); + } return tags; }