mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
feat: emit native Buzz mention tags
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user