diff --git a/extensions/nostr/src/channel.inbound.test.ts b/extensions/nostr/src/channel.inbound.test.ts index ead170453d16..47d0d5379126 100644 --- a/extensions/nostr/src/channel.inbound.test.ts +++ b/extensions/nostr/src/channel.inbound.test.ts @@ -50,13 +50,13 @@ function createMockBus() { function createRuntimeHarness() { const recordInboundSession = vi.fn(async () => {}); const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => { - await dispatcherOptions.deliver({ text: "|a|b|" }); + await dispatcherOptions.deliver({ text: "**Table:** [docs](https://example.com)" }); }); const runtime = { channel: { text: { resolveMarkdownTableMode: vi.fn(() => "off"), - convertMarkdownTables: vi.fn((text: string) => `converted:${text}`), + convertMarkdownTables: vi.fn((text: string) => text), }, commands: { shouldComputeCommandAuthorized: vi.fn(() => true), @@ -169,7 +169,8 @@ describe("nostr inbound gateway path", () => { it("routes allowed DMs through the standard reply pipeline", async () => { mocks.dispatchInboundDirectDm.mockImplementationOnce( async (params: Parameters[0]) => { - await params.deliver({ text: "|a|b|" }); + await params.deliver({ text: "**Table:** [docs](https://example.com)" }); + await params.deliver({ text: "***" }); }, ); const { cleanup } = await startGatewayHarness({ @@ -222,7 +223,7 @@ describe("nostr inbound gateway path", () => { turnAdoptionLifecycle: expect.objectContaining({ admission: "exclusive" }), }), ); - expect(sendReply).toHaveBeenCalledWith("converted:|a|b|"); + expect(sendReply).toHaveBeenCalledWith("Table: docs (https://example.com)"); await cleanup.stop(); }); diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index bc9dae0bcc33..db873d6b98e4 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -85,9 +85,9 @@ describe("nostr outbound cfg threading", () => { mocks.startNostrBus.mockReset(); }); - it("uses resolved cfg when converting markdown tables before send", async () => { + it("converts tables before projecting markdown to Nostr plain text", async () => { const { resolveMarkdownTableMode, convertMarkdownTables } = installOutboundRuntime( - vi.fn((text: string) => `converted:${text}`), + vi.fn((text: string) => (text === "***" ? text : "**Table:** [docs](https://example.com)")), ); const { cleanup, sendDm } = await startOutboundAccount(); @@ -106,7 +106,15 @@ describe("nostr outbound cfg threading", () => { }); expect(convertMarkdownTables).toHaveBeenCalledWith("|a|b|", "off"); expect(mocks.normalizePubkey).toHaveBeenCalledWith("NPUB123"); - expect(sendDm).toHaveBeenCalledWith("normalized-npub123", "converted:|a|b|"); + expect(sendDm).toHaveBeenCalledWith("normalized-npub123", "Table: docs (https://example.com)"); + await expect( + nostrOutboundAdapter.sendText({ + cfg: cfg as OpenClawConfig, + to: "NPUB123", + text: "***", + accountId: "default", + }), + ).rejects.toThrow("requires non-empty text"); await cleanup.stop(); }); diff --git a/extensions/nostr/src/gateway.ts b/extensions/nostr/src/gateway.ts index ff262dfa6728..08fc4015e3dd 100644 --- a/extensions/nostr/src/gateway.ts +++ b/extensions/nostr/src/gateway.ts @@ -10,6 +10,7 @@ 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 { 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"; @@ -201,7 +202,12 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => { channel: "nostr", accountId: account.accountId, }); - await reply(runtime.channel.text.convertMarkdownTables(outboundText, tableMode)); + const message = stripMarkdown( + runtime.channel.text.convertMarkdownTables(outboundText, tableMode), + ); + if (message) { + await reply(message); + } }, onRecordError: (err) => { ctx.log?.error?.( @@ -324,7 +330,10 @@ export const nostrOutboundAdapter: NostrOutboundAdapter = { channel: "nostr", accountId: aid, }); - const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode); + const message = stripMarkdown(core.channel.text.convertMarkdownTables(text ?? "", tableMode)); + if (!message) { + throw new Error("Nostr send requires non-empty text after markdown stripping."); + } const normalizedTo = normalizePubkey(to); const eventId = await bus.sendDm(normalizedTo, message); return attachChannelToResult("nostr", { diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index a66607353369..c3c60c6cf5a5 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -519,6 +519,7 @@ describe("createSynologyChatPlugin", () => { it("sendText returns OutboundDeliveryResult on success", async () => { const plugin = synologyChatPlugin; + const malformedLink = `[${"\\".repeat(32)}`; const result = await plugin.outbound.sendText({ cfg: { channels: { @@ -530,7 +531,7 @@ describe("createSynologyChatPlugin", () => { }, }, }, - text: "hello", + text: `**Read** [the docs](https://example.com/a_(b)) [titled](https://example.com "Documentation") \`[literal](https://example.com)\` \\[escaped](https://example.com) [x > y](https://example.com) [bad]( { expect(result.messageId).toMatch(/^sc-\d+$/); expect(result.receipt.primaryPlatformMessageId).toBe(result.messageId); expect(result.receipt.parts[0]?.kind).toBe("text"); + expect(mockSendMessage).toHaveBeenLastCalledWith( + "https://nas/incoming", + `**Read** \`[literal](https://example.com)\` \\[escaped](https://example.com) [x > y](https://example.com) [bad]( { diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 45f67b49c461..044fdadeced3 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -31,7 +31,11 @@ import { normalizeLowercaseStringOrEmpty, normalizeStringEntriesLower, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; +import { + findCodeRegions, + isInsideCode, + sanitizeAssistantVisibleText, +} from "openclaw/plugin-sdk/text-chunking"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { synologyChatApprovalAuth } from "./approval-auth.js"; import { sendMessage, sendFileUrl } from "./client.js"; @@ -51,6 +55,8 @@ import { import type { ResolvedSynologyChatAccount } from "./types.js"; const CHANNEL_ID = "synology-chat"; +const SYNOLOGY_MARKDOWN_LINK_RE = + /(?\\])+(?:\((?:\\[^\n]|[^()\s<>\\])*\)(?:\\[^\n]|[^()\s<>\\])*)*)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?\)/g; const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver({ channelKey: CHANNEL_ID, @@ -262,7 +268,15 @@ async function sendSynologyChatText( ): Promise { const account = resolveOutboundAccount(ctx.cfg ?? {}, ctx.accountId); const incomingUrl = requireIncomingUrl(account); - const ok = await sendMessage(incomingUrl, ctx.text, ctx.to, account.allowInsecureSsl); + const codeRegions = findCodeRegions(ctx.text); + const text = ctx.text.replace(SYNOLOGY_MARKDOWN_LINK_RE, (match, label, url, offset) => { + const slashCount = ctx.text.slice(0, offset).match(/\\+$/)?.[0].length ?? 0; + if (slashCount % 2 === 1 || isInsideCode(offset, codeRegions) || /[<>|]/.test(label + url)) { + return match; + } + return `<${url.replace(/\\([()])/g, "$1")}|${label.replace(/\\([[\]])/g, "$1")}>`; + }); + const ok = await sendMessage(incomingUrl, text, ctx.to, account.allowInsecureSsl); if (!ok) { throw new Error("Failed to send message to Synology Chat"); } diff --git a/extensions/twitch/src/utils/markdown.test.ts b/extensions/twitch/src/utils/markdown.test.ts index 40b2c0e510c2..7c68d97d4d5b 100644 --- a/extensions/twitch/src/utils/markdown.test.ts +++ b/extensions/twitch/src/utils/markdown.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from "vitest"; -import { chunkTextForTwitch } from "./markdown.js"; +import { chunkTextForTwitch, stripMarkdownForTwitch } from "./markdown.js"; + +describe("stripMarkdownForTwitch", () => { + it.each([ + [ + "keeps labeled link destinations", + "Read **the [docs](https://example.com/docs)**", + "Read the docs (https://example.com/docs)", + ], + ])("%s", (_name, input, expected) => { + expect(stripMarkdownForTwitch(input)).toBe(expected); + }); +}); describe("chunkTextForTwitch", () => { it("strips markdown and keeps surrogate pairs intact at hard boundaries", () => { diff --git a/extensions/twitch/src/utils/markdown.ts b/extensions/twitch/src/utils/markdown.ts index 1e92fbe14fc5..feadd297b6c4 100644 --- a/extensions/twitch/src/utils/markdown.ts +++ b/extensions/twitch/src/utils/markdown.ts @@ -2,53 +2,17 @@ * Markdown utilities for Twitch chat * * Twitch chat doesn't support markdown formatting, so we strip it before sending. - * Based on OpenClaw's markdownToText in src/agents/tools/web-fetch-utils.ts. */ -import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +import { chunkTextForOutbound, stripMarkdown } from "openclaw/plugin-sdk/text-chunking"; -/** - * Strip markdown formatting from text for Twitch compatibility. - * - * Removes images, links, bold, italic, strikethrough, code blocks, inline code, - * headers, and list formatting. Replaces newlines with spaces since Twitch - * is a single-line chat medium. - * - * @param markdown - The markdown text to strip - * @returns Plain text with markdown removed - */ +/** Strip markdown, then flatten newlines for Twitch's single-line chat. */ export function stripMarkdownForTwitch(markdown: string): string { - return ( - markdown - // Images - .replace(/!\[[^\]]*]\([^)]+\)/g, "") - // Links - .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") - // Bold (**text**) - .replace(/\*\*([^*]+)\*\*/g, "$1") - // Bold (__text__) - .replace(/__([^_]+)__/g, "$1") - // Italic (*text*) - .replace(/\*([^*]+)\*/g, "$1") - // Italic (_text_) - .replace(/(? block.replace(/```[^\n]*\n?/g, "").replace(/```/g, "")) - // Inline code - .replace(/`([^`]+)`/g, "$1") - // Headers - .replace(/^#{1,6}\s+/gm, "") - // Lists - .replace(/^\s*[-*+]\s+/gm, "") - .replace(/^\s*\d+\.\s+/gm, "") - // Normalize whitespace - .replace(/\r/g, "") // Remove carriage returns - .replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines - .replace(/\n/g, " ") // Replace newlines with spaces (for Twitch) - .replace(/[ \t]{2,}/g, " ") // Reduce multiple spaces to single - .trim() - ); + return stripMarkdown(markdown, { linkStyle: "label-and-url" }) + .replace(/\r/g, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n/g, " ") + .replace(/[ \t]{2,}/g, " ") + .trim(); } /**