feat: emit native Buzz mention tags

This commit is contained in:
Shakker
2026-08-02 05:45:10 +01:00
parent c4d355133f
commit 8fb9dcd54e
8 changed files with 229 additions and 9 deletions
@@ -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({
+13
View File
@@ -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", () => {
+59 -3
View File
@@ -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<string>;
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<string> {
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;
},
+83 -1
View File
@@ -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<ResolvedBuzzAccount>;
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 = {
+12 -1
View File
@@ -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,
+6 -1
View File
@@ -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);
});
});
+3 -2
View File
@@ -164,7 +164,7 @@ function normalizeMembers(members: readonly BuzzMentionMember[]): Map<string, Bu
export function hasBuzzMentionSyntax(text: string): boolean {
const stripped = stripCodeRegions(text);
return stripped.includes("@") || stripped.includes(NIP_27_PREFIX);
return extractMentionNames(stripped, []).length > 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 [];
}
+15 -1
View File
@@ -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;
}