mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 07:31:33 +00:00
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:
committed by
GitHub
parent
07f671724c
commit
b67217ffab
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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')  ${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')  ${malformedLink}`,
|
||||
"user1",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("sendMedia throws when missing incomingUrl", async () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user