fix(nostr): deliver oversized encrypted replies as ordered chunks (#115816)

Apply the shared, surrogate-safe outbound chunker to the existing Nostr text limit so the real delivery planner produces ordered NIP-04 events. Preserve assistant-visible sanitization and prove short, word, newline, hard-split, and odd-prefix Unicode boundaries.

Co-authored-by: liyuanbin <li.yuanbin1@xydigit.com>
This commit is contained in:
Peter Steinberger
2026-07-29 05:47:01 -04:00
committed by GitHub
parent 0063e46069
commit 85d2d698ff
2 changed files with 55 additions and 2 deletions
@@ -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)")),
+9 -2
View File
@@ -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<ChannelOutboundAdapter["sendText"]>;
sanitizeText: NonNullable<ChannelOutboundAdapter["sanitizeText"]>;
@@ -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: {