fix(channels): stop leaking markdown on plain-text transports (#112926)

* fix(channels): unify plain-text markdown projection

* fix(synology-chat): satisfy link formatter lint

* test(ui): repair sidebar catalog activity fixture
This commit is contained in:
Peter Steinberger
2026-07-23 02:13:55 -04:00
committed by GitHub
parent 07f671724c
commit b67217ffab
7 changed files with 72 additions and 57 deletions

View File

@@ -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<typeof DispatchInboundDirectDm>[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();
});

View File

@@ -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();
});

View File

@@ -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", {

View File

@@ -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](<https://example.com) [bad title](https://example.com "oops') ![logo](https://example.com/logo.png) ${malformedLink}`,
to: "user1",
});
expect(result.channel).toBe("synology-chat");
@@ -538,6 +539,12 @@ describe("createSynologyChatPlugin", () => {
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** <https://example.com/a_(b)|the docs> <https://example.com|titled> \`[literal](https://example.com)\` \\[escaped](https://example.com) [x > y](https://example.com) [bad](<https://example.com) [bad title](https://example.com "oops') ![logo](https://example.com/logo.png) ${malformedLink}`,
"user1",
true,
);
});
it("sendMedia throws when missing incomingUrl", async () => {

View File

@@ -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]|[^\\\]\n])+)\]\((https?:\/\/(?:\\[^\n]|[^()\s<>\\])+(?:\((?:\\[^\n]|[^()\s<>\\])*\)(?:\\[^\n]|[^()\s<>\\])*)*)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?\)/g;
const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynologyChatAccount>({
channelKey: CHANNEL_ID,
@@ -262,7 +268,15 @@ async function sendSynologyChatText(
): Promise<SynologyChatOutboundResult> {
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");
}

View File

@@ -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", () => {

View File

@@ -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(/(?<![\p{L}\p{N}\p{M}])_(?!_)([^_]+)_(?![\p{L}\p{N}\p{M}])/gu, "$1")
// Strikethrough (~~text~~)
.replace(/~~([^~]+)~~/g, "$1")
// Code blocks
.replace(/```[\s\S]*?```/g, (block) => 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();
}
/**