From 9716ae37bd17a77f68321bf28a54f1193f2fa346 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 29 Jul 2026 21:48:30 +0100 Subject: [PATCH] feat: accept Buzz rich and diff messages --- .../buzz/src/buzz-bus.lifecycle.test.ts | 57 ++++- extensions/buzz/src/buzz-bus.test.ts | 130 ++++++++++- extensions/buzz/src/buzz-bus.ts | 7 +- extensions/buzz/src/gateway.lifecycle.test.ts | 2 + extensions/buzz/src/inbound.test.ts | 75 ++++++- extensions/buzz/src/inbound.ts | 32 +-- extensions/buzz/src/message-event.ts | 201 +++++++++++++++++- 7 files changed, 479 insertions(+), 25 deletions(-) diff --git a/extensions/buzz/src/buzz-bus.lifecycle.test.ts b/extensions/buzz/src/buzz-bus.lifecycle.test.ts index 3713d86625b9..0001bb9be086 100644 --- a/extensions/buzz/src/buzz-bus.lifecycle.test.ts +++ b/extensions/buzz/src/buzz-bus.lifecycle.test.ts @@ -63,6 +63,12 @@ vi.mock("nostr-tools", async (importOriginal) => { }); import { sendBuzzTextOneShot, startBuzzBus } from "./buzz-bus.js"; +import { + BUZZ_DIFF_MESSAGE_KIND, + BUZZ_INBOUND_MESSAGE_KINDS, + BUZZ_RICH_MESSAGE_KIND, + type BuzzInboundMessage, +} from "./message-event.js"; const PRIVATE_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; const SENDER_PRIVATE_KEY = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; @@ -175,7 +181,7 @@ describe("Buzz bus lifecycle", () => { it("deduplicates replayed relay events by event id", async () => { relayMocks.auth.mockResolvedValue("ok"); - const onMessage = vi.fn(async () => {}); + const onMessage = vi.fn(async (_message: BuzzInboundMessage) => {}); const bus = await startBuzzBus({ accountId: ACCOUNT_ID, relayUrl: "wss://buzz.example.com", @@ -203,6 +209,55 @@ describe("Buzz bus lifecycle", () => { await bus.close(); }); + it("subscribes to and dispatches every supported Buzz timeline message kind", async () => { + relayMocks.auth.mockResolvedValue("ok"); + const receivedKinds: number[] = []; + const onMessage = vi.fn(async (message: BuzzInboundMessage) => { + receivedKinds.push(message.kind); + }); + const bus = await startBuzzBus({ + accountId: ACCOUNT_ID, + relayUrl: "wss://buzz.example.com", + privateKey: PRIVATE_KEY, + channelIds: [CHANNEL_ID], + onMessage, + }); + const messageSubscription = relayMocks.subscriptions.find((entry) => + entry.filter.kinds?.includes(9), + ); + expect(messageSubscription?.filter.kinds).toEqual([...BUZZ_INBOUND_MESSAGE_KINDS]); + + const richEvent = finalizeEvent( + { + kind: BUZZ_RICH_MESSAGE_KIND, + created_at: 1_700_000_000, + content: "**rich**", + tags: [["h", CHANNEL_ID]], + }, + Uint8Array.from(Buffer.from(SENDER_PRIVATE_KEY, "hex")), + ); + const diffEvent = finalizeEvent( + { + kind: BUZZ_DIFF_MESSAGE_KIND, + created_at: 1_700_000_001, + content: "@@ -1 +1 @@\n-old\n+new", + tags: [ + ["h", CHANNEL_ID], + ["repo", "https://github.com/openclaw/openclaw"], + ["commit", "abcdef1"], + ], + }, + Uint8Array.from(Buffer.from(SENDER_PRIVATE_KEY, "hex")), + ); + + messageSubscription?.handlers.onevent(richEvent); + messageSubscription?.handlers.onevent(diffEvent); + + await vi.waitFor(() => expect(onMessage).toHaveBeenCalledTimes(2)); + expect(receivedKinds).toEqual([BUZZ_RICH_MESSAGE_KIND, BUZZ_DIFF_MESSAGE_KIND]); + await bus.close(); + }); + it("isolates message failures from fatal relay failures", async () => { relayMocks.auth.mockResolvedValue("ok"); relayMocks.profileEvents = [ diff --git a/extensions/buzz/src/buzz-bus.test.ts b/extensions/buzz/src/buzz-bus.test.ts index 696bc7b7f8de..1f8a667b1b2a 100644 --- a/extensions/buzz/src/buzz-bus.test.ts +++ b/extensions/buzz/src/buzz-bus.test.ts @@ -1,6 +1,13 @@ import { finalizeEvent } from "nostr-tools"; import { describe, expect, it } from "vitest"; -import { buildBuzzMessageTags, parseBuzzMessageEvent } from "./message-event.js"; +import { + BUZZ_DIFF_MESSAGE_KIND, + BUZZ_NORMAL_MESSAGE_KIND, + BUZZ_RICH_MESSAGE_KIND, + buildBuzzMessageTags, + formatBuzzMessageForAgent, + parseBuzzMessageEvent, +} from "./message-event.js"; import { parseBuzzAuthTag } from "./relay-auth.js"; const SECRET_KEY = Uint8Array.from( @@ -26,6 +33,7 @@ describe("Buzz message events", () => { expect(parseBuzzMessageEvent(event)).toMatchObject({ id: event.id, + kind: BUZZ_NORMAL_MESSAGE_KIND, text: "hello OpenClaw", channelId: "7c4a6d2a-2ed9-4b4e-a5e2-4d705ee9b34c", threadId: "root-id", @@ -34,6 +42,89 @@ describe("Buzz message events", () => { }); }); + it("parses Buzz rich-content messages", () => { + const event = finalizeEvent( + { + kind: BUZZ_RICH_MESSAGE_KIND, + created_at: 1_700_000_000, + content: "**hello** OpenClaw", + tags: [["h", "7c4a6d2a-2ed9-4b4e-a5e2-4d705ee9b34c"]], + }, + SECRET_KEY, + ); + + expect(parseBuzzMessageEvent(event)).toMatchObject({ + kind: BUZZ_RICH_MESSAGE_KIND, + text: "**hello** OpenClaw", + }); + }); + + it("parses and formats Buzz structured diff events", () => { + const event = finalizeEvent( + { + kind: BUZZ_DIFF_MESSAGE_KIND, + created_at: 1_700_000_000, + content: "@@ -1 +1 @@\n-old\n+new", + tags: [ + ["h", "7c4a6d2a-2ed9-4b4e-a5e2-4d705ee9b34c"], + ["repo", "https://github.com/openclaw/openclaw"], + ["commit", "abcdef1234567890"], + ["file", "extensions/buzz/src/message-event.ts"], + ["parent-commit", "1234567890abcdef"], + ["branch", "feature/buzz", "main"], + ["pr", "113419"], + ["l", "typescript"], + ["description", "Preserve\nstructured diff context"], + ["truncated", "true"], + ["alt", "Buzz plugin diff"], + ["e", "root-id", "", "root"], + ["e", "reply-id", "", "reply"], + ], + }, + SECRET_KEY, + ); + + const message = parseBuzzMessageEvent(event); + expect(message).toMatchObject({ + kind: BUZZ_DIFF_MESSAGE_KIND, + threadId: "root-id", + replyToId: "reply-id", + diff: { + repoUrl: "https://github.com/openclaw/openclaw", + commitSha: "abcdef1234567890", + filePath: "extensions/buzz/src/message-event.ts", + parentCommitSha: "1234567890abcdef", + sourceBranch: "feature/buzz", + targetBranch: "main", + pullRequestNumber: 113419, + language: "typescript", + description: "Preserve\nstructured diff context", + truncated: true, + altText: "Buzz plugin diff", + }, + }); + expect(message && formatBuzzMessageForAgent(message)).toBe( + [ + "[Buzz structured diff]", + "Repository: https://github.com/openclaw/openclaw", + "Commit: abcdef1234567890", + "Parent commit: 1234567890abcdef", + "File: extensions/buzz/src/message-event.ts", + "Branches: feature/buzz -> main", + "Pull request: #113419", + "Language: typescript", + "Description: Preserve structured diff context", + "Alt text: Buzz plugin diff", + "Truncated: yes", + "", + "Unified diff:", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"), + ); + }); + it("ignores non-channel events", () => { const event = finalizeEvent( { kind: 9, created_at: 1_700_000_000, content: "hello", tags: [] }, @@ -42,6 +133,43 @@ describe("Buzz message events", () => { expect(parseBuzzMessageEvent(event)).toBeNull(); }); + it("rejects unsupported, blank, oversized, and malformed diff events", () => { + const sign = (kind: number, content: string, tags: string[][]) => + finalizeEvent({ kind, created_at: 1_700_000_000, content, tags }, SECRET_KEY); + const room = ["h", "7c4a6d2a-2ed9-4b4e-a5e2-4d705ee9b34c"]; + + expect(parseBuzzMessageEvent(sign(1, "hello", [room]))).toBeNull(); + expect(parseBuzzMessageEvent(sign(BUZZ_RICH_MESSAGE_KIND, " \n ", [room]))).toBeNull(); + expect( + parseBuzzMessageEvent(sign(BUZZ_NORMAL_MESSAGE_KIND, "x".repeat(256 * 1024 + 1), [room])), + ).toBeNull(); + expect( + parseBuzzMessageEvent( + sign(BUZZ_DIFF_MESSAGE_KIND, "diff", [ + room, + ["repo", "https://github.com/openclaw/openclaw"], + ]), + ), + ).toBeNull(); + expect( + parseBuzzMessageEvent( + sign(BUZZ_DIFF_MESSAGE_KIND, "x".repeat(60 * 1024 + 1), [ + room, + ["repo", "https://github.com/openclaw/openclaw"], + ["commit", "abcdef1"], + ]), + ), + ).toBeNull(); + expect( + parseBuzzMessageEvent( + sign(BUZZ_NORMAL_MESSAGE_KIND, "hello", [ + room, + ...Array.from({ length: 51 }, (_, index) => ["p", index.toString(16).padStart(64, "0")]), + ]), + ), + ).toBeNull(); + }); + it("builds direct and nested reply tags like the Buzz SDK", () => { expect( buildBuzzMessageTags({ diff --git a/extensions/buzz/src/buzz-bus.ts b/extensions/buzz/src/buzz-bus.ts index 1ec1f5d4b18d..7aafe27ab5fd 100644 --- a/extensions/buzz/src/buzz-bus.ts +++ b/extensions/buzz/src/buzz-bus.ts @@ -1,6 +1,8 @@ import { Relay, finalizeEvent, type Event } from "nostr-tools"; import { createChannelReplayGuard } from "openclaw/plugin-sdk/persistent-dedupe"; import { + BUZZ_INBOUND_MESSAGE_KINDS, + BUZZ_NORMAL_MESSAGE_KIND, buildBuzzMessageTags, parseBuzzMessageEvent, type BuzzInboundMessage, @@ -17,7 +19,6 @@ import { } from "./room-membership.js"; import { decodeBuzzPrivateKey, resolveBuzzPublicKey } from "./types.js"; -const MESSAGE_KIND = 9; const PRESENCE_KIND = 20_001; const PRESENCE_HEARTBEAT_INTERVAL_MS = 30_000; const REPLAY_TTL_MS = 30 * 24 * 60 * 60 * 1000; @@ -49,7 +50,7 @@ function buildBuzzTextEvent(params: { }): Event { return finalizeEvent( { - kind: MESSAGE_KIND, + kind: BUZZ_NORMAL_MESSAGE_KIND, content: params.text, created_at: Math.floor(Date.now() / 1000), tags: buildBuzzMessageTags(params), @@ -614,7 +615,7 @@ export async function startBuzzBus(options: { relay.subscribe( [ { - kinds: [MESSAGE_KIND], + kinds: [...BUZZ_INBOUND_MESSAGE_KINDS], "#h": [channelId], since: options.since ?? sessionStartedAt, }, diff --git a/extensions/buzz/src/gateway.lifecycle.test.ts b/extensions/buzz/src/gateway.lifecycle.test.ts index 9dd1159ba08e..32e18da3483e 100644 --- a/extensions/buzz/src/gateway.lifecycle.test.ts +++ b/extensions/buzz/src/gateway.lifecycle.test.ts @@ -28,6 +28,7 @@ vi.mock("./inbound.js", () => ({ })); import { buzzOutboundAdapter, startBuzzGatewayAccount } from "./gateway.js"; +import { BUZZ_NORMAL_MESSAGE_KIND } from "./message-event.js"; import { setBuzzRuntime } from "./runtime.js"; import { resolveBuzzAccount } from "./types.js"; @@ -312,6 +313,7 @@ describe("Buzz gateway lifecycle", () => { await gatewayMocks.onMessage?.( { id: "event-1", + kind: BUZZ_NORMAL_MESSAGE_KIND, channelId: CHANNEL_ID, senderPubkey: "b".repeat(64), text: "hello", diff --git a/extensions/buzz/src/inbound.test.ts b/extensions/buzz/src/inbound.test.ts index c619c4ee87e2..6b18e88391c0 100644 --- a/extensions/buzz/src/inbound.test.ts +++ b/extensions/buzz/src/inbound.test.ts @@ -4,7 +4,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { BuzzBus } from "./buzz-bus.js"; import { handleBuzzInbound } from "./inbound.js"; -import type { BuzzInboundMessage } from "./message-event.js"; +import { + BUZZ_DIFF_MESSAGE_KIND, + BUZZ_NORMAL_MESSAGE_KIND, + type BuzzInboundMessage, +} from "./message-event.js"; import { setBuzzRuntime } from "./runtime.js"; import type { ResolvedBuzzAccount } from "./types.js"; @@ -40,6 +44,7 @@ function createAccount( function createMessage(overrides: Partial = {}): BuzzInboundMessage { return { id: "event-1", + kind: BUZZ_NORMAL_MESSAGE_KIND, senderPubkey: SENDER_PUBLIC_KEY, text: "hello", channelId: ROOM_ID, @@ -216,6 +221,74 @@ describe("handleBuzzInbound", () => { }); }); + it("provides bounded structured diff context without treating diff content as commands", async () => { + const runtime = createPluginRuntimeMock(); + vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true); + setBuzzRuntime(runtime); + + await handleBuzzInbound({ + account: createAccount({ + groups: { + [ROOM_ID]: { + requireMention: false, + }, + }, + }), + cfg: {} satisfies OpenClawConfig, + bus: createBus(), + message: createMessage({ + kind: BUZZ_DIFF_MESSAGE_KIND, + text: "/status\n@@ -1 +1 @@\n-old\n+new", + diff: { + repoUrl: "https://github.com/openclaw/openclaw", + commitSha: "abcdef1", + description: `line one\n${"x".repeat(1_100)}`, + truncated: true, + }, + }), + }); + + expect(runtime.channel.commands.shouldComputeCommandAuthorized).not.toHaveBeenCalled(); + const context = firstDispatch(runtime).ctxPayload; + expect(context).toMatchObject({ + BuzzEventKind: BUZZ_DIFF_MESSAGE_KIND, + RawBody: "/status\n@@ -1 +1 @@\n-old\n+new", + CommandBody: "", + BodyForCommands: "", + }); + const bodyForAgent = context.BodyForAgent ?? ""; + expect(bodyForAgent).toContain("[Buzz structured diff]"); + expect(bodyForAgent).toContain("Repository: https://github.com/openclaw/openclaw"); + expect(bodyForAgent).toContain("Description: line one "); + expect(bodyForAgent).toContain("Truncated: yes"); + expect(bodyForAgent).toContain("Unified diff:\n/status\n@@ -1 +1 @@\n-old\n+new"); + expect(bodyForAgent.length).toBeLessThan(2_000); + }); + + it("does not treat mention-like text inside a structured diff as a bot mention", async () => { + const runtime = createPluginRuntimeMock(); + vi.mocked(runtime.channel.mentions.matchesMentionPatterns).mockReturnValue(true); + setBuzzRuntime(runtime); + + await handleBuzzInbound({ + account: createAccount(), + cfg: {} satisfies OpenClawConfig, + bus: createBus(), + message: createMessage({ + kind: BUZZ_DIFF_MESSAGE_KIND, + text: "+const owner = '@OpenClaw';", + diff: { + repoUrl: "https://github.com/openclaw/openclaw", + commitSha: "abcdef1", + truncated: false, + }, + }), + }); + + expect(runtime.channel.mentions.matchesMentionPatterns).not.toHaveBeenCalled(); + expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled(); + }); + it("propagates delivery and session-recording failures", async () => { const runtime = createPluginRuntimeMock(); setBuzzRuntime(runtime); diff --git a/extensions/buzz/src/inbound.ts b/extensions/buzz/src/inbound.ts index 039a645c78b0..a5405036db28 100644 --- a/extensions/buzz/src/inbound.ts +++ b/extensions/buzz/src/inbound.ts @@ -5,7 +5,11 @@ import { import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { BuzzBus } from "./buzz-bus.js"; -import type { BuzzInboundMessage } from "./message-event.js"; +import { + BUZZ_DIFF_MESSAGE_KIND, + formatBuzzMessageForAgent, + type BuzzInboundMessage, +} from "./message-event.js"; import { getBuzzRuntime } from "./runtime.js"; import { buildBuzzTarget, parseBuzzTarget } from "./target.js"; import type { ResolvedBuzzAccount } from "./types.js"; @@ -24,21 +28,24 @@ export async function handleBuzzInbound(params: { const { account, cfg, bus, message } = params; const channelId = parseBuzzTarget(message.channelId); const target = buildBuzzTarget(channelId); + const textForAgent = formatBuzzMessageForAgent(message); const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({ cfg, channel: "buzz", accountId: account.accountId, peer: { kind: "group", id: target }, }); - const textMention = runtime.channel.mentions.matchesMentionPatterns( - message.text, - runtime.channel.mentions.buildMentionRegexes(cfg, route.agentId), - ); + const supportsTextInterpretation = message.kind !== BUZZ_DIFF_MESSAGE_KIND; + const textMention = + supportsTextInterpretation && + runtime.channel.mentions.matchesMentionPatterns( + message.text, + runtime.channel.mentions.buildMentionRegexes(cfg, route.agentId), + ); const wasMentioned = message.mentionedPubkeys.includes(bus.publicKey) || textMention; - const shouldComputeCommandAuthorized = runtime.channel.commands.shouldComputeCommandAuthorized( - message.text, - cfg, - ); + const shouldComputeCommandAuthorized = + supportsTextInterpretation && + runtime.channel.commands.shouldComputeCommandAuthorized(message.text, cfg); const hasControlCommand = shouldComputeCommandAuthorized && runtime.channel.text.hasControlCommand(message.text, cfg); const groupConfig = account.config.groups?.[channelId]; @@ -77,7 +84,7 @@ export async function handleBuzzInbound(params: { channel: "Buzz", from: senderName, timestamp: new Date(message.createdAt * 1000), - body: message.text, + body: textForAgent, }); const ctxPayload = buildChannelInboundEventContext({ channel: "buzz", @@ -109,9 +116,9 @@ export async function handleBuzzInbound(params: { }, message: { body, - bodyForAgent: message.text, + bodyForAgent: textForAgent, rawBody: message.text, - commandBody: message.text, + commandBody: supportsTextInterpretation ? message.text : "", }, access: { commands: { authorized: access.commandAccess.authorized }, @@ -120,6 +127,7 @@ export async function handleBuzzInbound(params: { extra: { GroupChannel: channelId, GroupSubject: channelId, + BuzzEventKind: message.kind, }, }); diff --git a/extensions/buzz/src/message-event.ts b/extensions/buzz/src/message-event.ts index d48853ea7aef..24c133cdbc4d 100644 --- a/extensions/buzz/src/message-event.ts +++ b/extensions/buzz/src/message-event.ts @@ -1,9 +1,42 @@ +import { Buffer } from "node:buffer"; import type { Event } from "nostr-tools"; -const MESSAGE_KIND = 9; +export const BUZZ_NORMAL_MESSAGE_KIND = 9; +export const BUZZ_RICH_MESSAGE_KIND = 40_002; +export const BUZZ_DIFF_MESSAGE_KIND = 40_008; +export const BUZZ_INBOUND_MESSAGE_KINDS = [ + BUZZ_NORMAL_MESSAGE_KIND, + BUZZ_RICH_MESSAGE_KIND, + BUZZ_DIFF_MESSAGE_KIND, +] as const; + +export type BuzzInboundMessageKind = (typeof BUZZ_INBOUND_MESSAGE_KINDS)[number]; + +// Buzz relay ingest accepts up to 256 KiB generally; diff events have their +// 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 BUZZ_DIFF_CONTEXT_FIELD_MAX_CHARS = 1_024; +const BUZZ_INBOUND_MESSAGE_KIND_SET = new Set(BUZZ_INBOUND_MESSAGE_KINDS); + +export interface BuzzDiffMetadata { + repoUrl: string; + commitSha: string; + filePath?: string; + parentCommitSha?: string; + sourceBranch?: string; + targetBranch?: string; + pullRequestNumber?: number; + language?: string; + description?: string; + truncated: boolean; + altText?: string; +} export interface BuzzInboundMessage { id: string; + kind: BuzzInboundMessageKind; senderPubkey: string; text: string; channelId: string; @@ -11,18 +44,159 @@ export interface BuzzInboundMessage { threadId?: string; replyToId?: string; mentionedPubkeys: string[]; + diff?: BuzzDiffMetadata; } function tagValue(event: Event, name: string): string | undefined { - return event.tags.find((tag) => tag[0] === name)?.[1]; + const value = event.tags.find((tag) => tag[0] === name)?.[1]?.trim(); + return value ? value : undefined; } function markerTagValue(event: Event, marker: string): string | undefined { - return event.tags.find((tag) => tag[0] === "e" && tag[3] === marker)?.[1]; + const value = event.tags.find((tag) => tag[0] === "e" && tag[3] === marker)?.[1]?.trim(); + return value ? value : undefined; +} + +function isHexAtLeast(value: string, minimumLength: number): boolean { + return value.length >= minimumLength && /^[a-f0-9]+$/iu.test(value); +} + +function parseBuzzDiffMetadata(event: Event): BuzzDiffMetadata | null { + let repoUrl: string | undefined; + let commitSha: string | undefined; + let filePath: string | undefined; + let parentCommitSha: string | undefined; + let sourceBranch: string | undefined; + let targetBranch: string | undefined; + let pullRequestNumber: number | undefined; + let language: string | undefined; + let description: string | undefined; + let truncated = false; + let altText: string | undefined; + + for (const tag of event.tags) { + const name = tag[0]; + const value = tag[1]; + if (!name || value === undefined) { + continue; + } + switch (name) { + case "repo": + if (!value.startsWith("http://") && !value.startsWith("https://")) { + return null; + } + repoUrl ??= value; + break; + case "commit": + if (!isHexAtLeast(value, 7)) { + return null; + } + commitSha ??= value; + break; + case "file": + filePath ??= value; + break; + case "parent-commit": + if (!isHexAtLeast(value, 7)) { + return null; + } + parentCommitSha ??= value; + break; + case "branch": + if (!value || !tag[2]) { + return null; + } + sourceBranch ??= value; + targetBranch ??= tag[2]; + break; + case "pr": { + if (!/^[0-9]+$/u.test(value)) { + return null; + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > 0xffff_ffff) { + return null; + } + pullRequestNumber ??= parsed; + break; + } + case "l": + language ??= value; + break; + case "description": + description ??= value; + break; + case "truncated": + truncated ||= value === "true"; + break; + case "alt": + altText ??= value; + break; + default: + break; + } + } + + if (!repoUrl || !commitSha) { + return null; + } + return { + repoUrl, + commitSha, + filePath, + parentCommitSha, + sourceBranch, + targetBranch, + pullRequestNumber, + language, + description, + truncated, + altText, + }; +} + +function boundedDiffContextValue(value: string): string { + const singleLine = value.replace(/\s+/gu, " ").trim(); + if (singleLine.length <= BUZZ_DIFF_CONTEXT_FIELD_MAX_CHARS) { + return singleLine; + } + return `${singleLine.slice(0, BUZZ_DIFF_CONTEXT_FIELD_MAX_CHARS - 3)}...`; +} + +export function formatBuzzMessageForAgent(message: BuzzInboundMessage): string { + if (message.kind !== BUZZ_DIFF_MESSAGE_KIND || !message.diff) { + return message.text; + } + const { diff } = message; + const metadata = [ + `Repository: ${boundedDiffContextValue(diff.repoUrl)}`, + `Commit: ${boundedDiffContextValue(diff.commitSha)}`, + diff.parentCommitSha + ? `Parent commit: ${boundedDiffContextValue(diff.parentCommitSha)}` + : undefined, + diff.filePath ? `File: ${boundedDiffContextValue(diff.filePath)}` : undefined, + diff.sourceBranch && diff.targetBranch + ? `Branches: ${boundedDiffContextValue(diff.sourceBranch)} -> ${boundedDiffContextValue(diff.targetBranch)}` + : undefined, + diff.pullRequestNumber ? `Pull request: #${diff.pullRequestNumber}` : undefined, + diff.language ? `Language: ${boundedDiffContextValue(diff.language)}` : undefined, + diff.description ? `Description: ${boundedDiffContextValue(diff.description)}` : undefined, + diff.altText ? `Alt text: ${boundedDiffContextValue(diff.altText)}` : undefined, + diff.truncated ? "Truncated: yes" : undefined, + ].filter((line): line is string => Boolean(line)); + + return [`[Buzz structured diff]`, ...metadata, "", "Unified diff:", message.text].join("\n"); } export function parseBuzzMessageEvent(event: Event): BuzzInboundMessage | null { - if (event.kind !== MESSAGE_KIND || !event.content.trim()) { + if ( + !BUZZ_INBOUND_MESSAGE_KIND_SET.has(event.kind) || + !event.content.trim() || + Buffer.byteLength(event.content, "utf8") > + (event.kind === BUZZ_DIFF_MESSAGE_KIND + ? BUZZ_DIFF_CONTENT_MAX_BYTES + : BUZZ_MESSAGE_CONTENT_MAX_BYTES) + ) { return null; } const channelId = tagValue(event, "h"); @@ -31,17 +205,30 @@ export function parseBuzzMessageEvent(event: Event): BuzzInboundMessage | null { } const rootId = markerTagValue(event, "root"); const replyToId = markerTagValue(event, "reply"); + const kind = event.kind as BuzzInboundMessageKind; + const diff = kind === BUZZ_DIFF_MESSAGE_KIND ? parseBuzzDiffMetadata(event) : undefined; + if (kind === BUZZ_DIFF_MESSAGE_KIND && !diff) { + return null; + } + const mentionTagValues = event.tags + .filter((tag) => tag[0] === "p" && Boolean(tag[1])) + .map((tag) => (tag[1] as string).trim().toLowerCase()) + .filter(Boolean); + if (mentionTagValues.length > BUZZ_MENTION_MAX_COUNT) { + return null; + } + const mentionedPubkeys = [...new Set(mentionTagValues)]; return { id: event.id, + kind, senderPubkey: event.pubkey, text: event.content, channelId, createdAt: event.created_at, threadId: rootId ?? replyToId, replyToId, - mentionedPubkeys: event.tags - .filter((tag) => tag[0] === "p" && Boolean(tag[1])) - .map((tag) => tag[1] as string), + mentionedPubkeys, + ...(diff ? { diff } : {}), }; }