openclaw/test/outbound-sanitize-text-delivery.test.ts
Cestercian b22ebb1b50
fix: keep unspaced Slack/Google Chat angle-link labels (#136306)
Closes #136303

## What Problem This Solves

Slack and Google Chat can format a labeled link without a space as `<url|Label>`. Plain-text outbound delivery consumed that input as an autolink and sent the raw `url|Label` text instead of the visible label.

## Root cause

`src/infra/outbound/sanitize-text.ts` applied the angle-bracket autolink replacement before its labeled-link projection. Its URL class included `|`, so the first replacement removed the brackets and hid the label delimiter from the second replacement.

## Fix

Stop the autolink URL class at `|`. The existing shared labeled-link projection then returns the visible label. Ordinary autolinks, mail links, code spans, and HTML stripping keep their existing behavior.

## Evidence

- Current-main red probe at `6190b505d5`: HTTPS and `mailto:` `<url|Label>` inputs leaked `url|Label`; ordinary autolink, code-span, and HTML probes passed.
- Current-main red delivery harness: Google Chat, default Telegram, IRC, and WhatsApp sent the raw URL-plus-label text through the shared outbound boundary.
- Exact-head green delivery test: `test/outbound-sanitize-text-delivery.test.ts` covers Google Chat, default Telegram, IRC, WhatsApp, and direct text/media through the delivery core.
- Exact-head green Telegram integration test: `extensions/telegram/src/send.telegram-http.test.ts` drives the public Telegram plain-text contract and production Telegram sender through a local HTTP boundary.
- Exact-head live Telegram Test Server proof: the leased real-user recorder received one SUT message with exact text `Manual` after the exact production sanitizer sent through the Test Server Bot API.
- Focused tests: 97 sanitizer tests, 41 Google Chat formatter tests, 8 shared outbound delivery tests, and 17 Telegram HTTP integration tests passed.
- Focused oxlint, format, conflict-marker, max-lines, assertion-safety, coercion-helper, and diff checks passed.

Production delta: +2/-2. Test delta: +124/-4. The test growth adds the missing sibling delivery and production Telegram HTTP proof while keeping the contributor's owner-boundary regression coverage.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-09-04 14:02:08 +05:30

183 lines
6.7 KiB
TypeScript

// Root-owned integration combines shared delivery with public plugin surfaces.
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { googlechatPlugin } from "../extensions/googlechat/api.js";
import { ircPlugin } from "../extensions/irc/api.js";
import { telegramOutbound } from "../extensions/telegram/api.js";
import { whatsappPlugin } from "../extensions/whatsapp/api.js";
import { createDirectTextMediaOutbound } from "../src/channels/plugins/outbound/direct-text-media.js";
import type { ChannelOutboundAdapter } from "../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../src/config/types.openclaw.js";
import { deliverOutboundPayloadsCore } from "../src/infra/outbound/deliver-core.js";
import { prepareOutboundPayloadBatch } from "../src/infra/outbound/deliver-prepare.js";
import { createEmptyPluginRegistry } from "../src/plugins/registry.js";
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../src/test-utils/channel-plugins.js";
const literalCode = '`<p class="literal">code</p>`';
const fixtures = [
{
text: `before<p title="a>b">inside</p>after\n\n${literalCode}`,
plainText: `before\ninside\nafter\n\n${literalCode}`,
},
{
text: `before<div title='a>b'>inside</div>after\n\n${literalCode}`,
plainText: `before\ninside\nafter\n\n${literalCode}`,
},
{
text: 'before<a href="`hidden`">click</a> then `visible`',
plainText: "beforeclick then `visible`",
},
];
const payloads = fixtures.map(({ text }) => ({ text }));
const sharedPlainTextSiblings: ReadonlyArray<
readonly [
label: string,
channel: "googlechat" | "irc" | "whatsapp",
source: Pick<ChannelOutboundAdapter, "sanitizeText" | "normalizePayload">,
]
> = [
[
"Google Chat",
"googlechat",
expectDefined(googlechatPlugin.outbound, "googlechatPlugin.outbound"),
],
["IRC", "irc", expectDefined(ircPlugin.outbound, "ircPlugin.outbound")],
["WhatsApp", "whatsapp", expectDefined(whatsappPlugin.outbound, "whatsappPlugin.outbound")],
];
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
});
describe("HTML sanitization through outbound delivery", () => {
it.each(["default Telegram", "rich Telegram", "direct text/media"] as const)(
"preserves the %s transport contract",
async (mode) => {
const send = vi.fn(async (_to: string, _text: string) => ({
messageId: "fixture-message",
chatId: "12345",
}));
const channel = mode === "direct text/media" ? "imessage" : "telegram";
const cfg: OpenClawConfig =
mode === "rich Telegram" ? { channels: { telegram: { richMessages: true } } } : {};
const outbound =
channel === "telegram"
? telegramOutbound
: createDirectTextMediaOutbound({
channel,
resolveSender: () => send,
resolveMaxBytes: () => undefined,
buildTextOptions: () => ({}),
buildMediaOptions: () => ({}),
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: channel,
source: "test",
plugin: createOutboundTestPlugin({ id: channel, outbound }),
},
]),
);
const params = { cfg, channel, to: "12345", payloads, deps: { telegram: send } };
const preparedBatch = await prepareOutboundPayloadBatch(params);
const results = await deliverOutboundPayloadsCore({ ...params, preparedBatch });
expect(results).toHaveLength(payloads.length);
expect(send.mock.calls.map(([to, text]) => ({ to, text }))).toEqual(
fixtures.map(({ text, plainText }) => ({
to: "12345",
text: mode === "rich Telegram" ? text : plainText,
})),
);
},
);
it.each(["default Telegram", "direct text/media"] as const)(
"keeps the unspaced angle-link label on the %s delivery boundary",
async (mode) => {
const send = vi.fn(async (_to: string, _text: string) => ({
messageId: "fixture-message",
chatId: "12345",
}));
const channel = mode === "direct text/media" ? "imessage" : "telegram";
const outbound =
channel === "telegram"
? telegramOutbound
: createDirectTextMediaOutbound({
channel,
resolveSender: () => send,
resolveMaxBytes: () => undefined,
buildTextOptions: () => ({}),
buildMediaOptions: () => ({}),
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: channel,
source: "test",
plugin: createOutboundTestPlugin({ id: channel, outbound }),
},
]),
);
const params = {
cfg: {} satisfies OpenClawConfig,
channel,
to: "12345",
payloads: [{ text: "<https://example.com/a.pdf|Manual>" }],
deps: { telegram: send },
};
const preparedBatch = await prepareOutboundPayloadBatch(params);
const results = await deliverOutboundPayloadsCore({ ...params, preparedBatch });
expect(results).toHaveLength(1);
expect(send.mock.calls.map(([to, text]) => ({ to, text }))).toEqual([
{ to: "12345", text: "Manual" },
]);
},
);
it.each(sharedPlainTextSiblings)(
"keeps the unspaced angle-link label on the %s outbound delivery boundary",
async (_label, channel, source) => {
const send = vi.fn(
async (params: Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0]) => ({
channel,
messageId: "fixture-message",
to: params.to,
text: params.text,
}),
);
const outbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
...(source.sanitizeText ? { sanitizeText: source.sanitizeText } : {}),
...(source.normalizePayload ? { normalizePayload: source.normalizePayload } : {}),
sendText: send,
};
setActivePluginRegistry(
createTestRegistry([
{
pluginId: channel,
source: "test",
plugin: createOutboundTestPlugin({ id: channel, outbound }),
},
]),
);
const params = {
cfg: {} satisfies OpenClawConfig,
channel,
to: "12345",
payloads: [{ text: "<https://example.com/a.pdf|Manual>" }],
};
const preparedBatch = await prepareOutboundPayloadBatch(params);
const results = await deliverOutboundPayloadsCore({ ...params, preparedBatch });
expect(results).toHaveLength(1);
expect(send.mock.calls.map(([call]) => ({ to: call.to, text: call.text }))).toEqual([
{ to: "12345", text: "Manual" },
]);
},
);
});