diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index 12fb56045258..4e5666e1379f 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -126,6 +126,52 @@ describe("nostr outbound cfg threading", () => { expect(sanitizeText({ text, payload: { text } })).toBe(expected); }); + it.each([ + { + name: "preserves a short reply as one encrypted message", + text: "Hello from Nostr.", + expectedChunkCount: 1, + joinWith: "", + }, + { + name: "splits long replies at word boundaries", + text: `${"word ".repeat(1_200)}final`, + expectedChunkCount: 2, + joinWith: " ", + }, + { + name: "preserves newline-delimited reply order", + text: `${"line\n".repeat(1_200)}final`, + expectedChunkCount: 2, + joinWith: "\n", + }, + { + name: "hard-splits an uninterrupted oversized reply", + text: "x".repeat(8_001), + expectedChunkCount: 3, + joinWith: "", + }, + { + name: "preserves Unicode when an odd prefix shifts the chunk boundary", + text: `a${"😀".repeat(2_500)}`, + expectedChunkCount: 2, + joinWith: "", + }, + ])("$name", ({ text, expectedChunkCount, joinWith }) => { + const outbound = nostrPlugin.outbound; + const textChunkLimit = outbound?.textChunkLimit; + expect(textChunkLimit).toBe(4_000); + expect(outbound?.chunker).toBeTypeOf("function"); + if (!outbound?.chunker || textChunkLimit === undefined) { + throw new Error("Expected Nostr outbound text chunking"); + } + + const chunks = outbound.chunker(text, textChunkLimit); + expect(chunks).toHaveLength(expectedChunkCount); + expect(chunks.every((chunk) => chunk.length <= textChunkLimit)).toBe(true); + expect(chunks.join(joinWith)).toBe(text); + }); + it("converts tables before projecting markdown to Nostr plain text", async () => { const { resolveMarkdownTableMode, convertMarkdownTables } = installOutboundRuntime( vi.fn((text: string) => (text === "***" ? text : "**Table:** [docs](https://example.com)")), diff --git a/extensions/nostr/src/gateway.ts b/extensions/nostr/src/gateway.ts index 4cdb37d5597a..2b063a73b6fe 100644 --- a/extensions/nostr/src/gateway.ts +++ b/extensions/nostr/src/gateway.ts @@ -10,7 +10,11 @@ import { import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { sanitizeAssistantVisibleText, stripMarkdown } from "openclaw/plugin-sdk/text-chunking"; +import { + chunkTextForOutbound, + sanitizeAssistantVisibleText, + stripMarkdown, +} from "openclaw/plugin-sdk/text-chunking"; import type { ChannelOutboundAdapter, ChannelPlugin } from "./channel-api.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; import { startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; @@ -23,7 +27,7 @@ type NostrGatewayStart = NonNullable< >; type NostrOutboundAdapter = Pick< ChannelOutboundAdapter, - "deliveryCapabilities" | "deliveryMode" | "textChunkLimit" | "sendText" + "chunker" | "deliveryCapabilities" | "deliveryMode" | "textChunkLimit" | "sendText" > & { sendText: NonNullable; sanitizeText: NonNullable; @@ -316,6 +320,9 @@ export const nostrPairingTextAdapter = { export const nostrOutboundAdapter: NostrOutboundAdapter = { deliveryMode: "direct", textChunkLimit: 4000, + // The outbound planner ignores textChunkLimit unless the adapter also + // supplies its chunker, causing oversized encrypted events to be rejected. + chunker: chunkTextForOutbound, sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text), deliveryCapabilities: { durableFinal: {