refactor: unify markdown formatting pipeline

This commit is contained in:
Peter Steinberger
2026-01-15 00:12:29 +00:00
parent 0d0b77ded6
commit bd7d362d3b
16 changed files with 1245 additions and 350 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { markdownToSignalTextChunks } from "../../signal/format.js";
import { deliverOutboundPayloads, normalizeOutboundPayloads } from "./deliver.js";
describe("deliverOutboundPayloads", () => {
@@ -22,7 +23,9 @@ describe("deliverOutboundPayloads", () => {
expect(sendTelegram).toHaveBeenCalledTimes(2);
for (const call of sendTelegram.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ accountId: undefined, verbose: false }));
expect(call[2]).toEqual(
expect.objectContaining({ accountId: undefined, verbose: false, textMode: "html" }),
);
}
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({ channel: "telegram", chatId: "c1" });
@@ -53,7 +56,7 @@ describe("deliverOutboundPayloads", () => {
expect(sendTelegram).toHaveBeenCalledWith(
"123",
"hi",
expect.objectContaining({ accountId: "default", verbose: false }),
expect.objectContaining({ accountId: "default", verbose: false, textMode: "html" }),
);
});
@@ -75,11 +78,46 @@ describe("deliverOutboundPayloads", () => {
expect.objectContaining({
mediaUrl: "https://x.test/a.jpg",
maxBytes: 2 * 1024 * 1024,
textMode: "plain",
textStyles: [],
}),
);
expect(results[0]).toMatchObject({ channel: "signal", messageId: "s1" });
});
it("chunks Signal markdown using the format-first chunker", async () => {
const sendSignal = vi
.fn()
.mockResolvedValue({ messageId: "s1", timestamp: 123 });
const cfg: ClawdbotConfig = {
channels: { signal: { textChunkLimit: 20 } },
};
const text = `Intro\\n\\n\`\`\`\`md\\n${"y".repeat(60)}\\n\`\`\`\\n\\nOutro`;
const expectedChunks = markdownToSignalTextChunks(text, 20);
await deliverOutboundPayloads({
cfg,
channel: "signal",
to: "+1555",
payloads: [{ text }],
deps: { sendSignal },
});
expect(sendSignal).toHaveBeenCalledTimes(expectedChunks.length);
expectedChunks.forEach((chunk, index) => {
expect(sendSignal).toHaveBeenNthCalledWith(
index + 1,
"+1555",
chunk.text,
expect.objectContaining({
accountId: undefined,
textMode: "plain",
textStyles: chunk.styles,
}),
);
});
});
it("chunks WhatsApp text and returns all results", async () => {
const sendWhatsApp = vi
.fn()

View File

@@ -1,11 +1,13 @@
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { sendMessageDiscord } from "../../discord/send.js";
import type { sendMessageIMessage } from "../../imessage/send.js";
import type { sendMessageSignal } from "../../signal/send.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
import { sendMessageSignal } from "../../signal/send.js";
import type { sendMessageSlack } from "../../slack/send.js";
import type { sendMessageTelegram } from "../../telegram/send.js";
import type { sendMessageWhatsApp } from "../../web/outbound.js";
@@ -154,6 +156,7 @@ export async function deliverOutboundPayloads(params: {
const accountId = params.accountId;
const deps = params.deps;
const abortSignal = params.abortSignal;
const sendSignal = params.deps?.sendSignal ?? sendMessageSignal;
const results: OutboundDeliveryResult[] = [];
const handler = await createChannelHandler({
cfg,
@@ -170,6 +173,16 @@ export async function deliverOutboundPayloads(params: {
fallbackLimit: handler.textChunkLimit,
})
: undefined;
const isSignalChannel = channel === "signal";
const signalMaxBytes = isSignalChannel
? resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
})
: undefined;
const sendTextChunks = async (text: string) => {
throwIfAborted(abortSignal);
@@ -183,13 +196,63 @@ export async function deliverOutboundPayloads(params: {
}
};
const sendSignalText = async (text: string, styles: SignalTextStyleRange[]) => {
throwIfAborted(abortSignal);
return {
channel: "signal" as const,
...(await sendSignal(to, text, {
maxBytes: signalMaxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: styles,
})),
};
};
const sendSignalTextChunks = async (text: string) => {
throwIfAborted(abortSignal);
let signalChunks =
textLimit === undefined
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY)
: markdownToSignalTextChunks(text, textLimit);
if (signalChunks.length === 0 && text) {
signalChunks = [{ text, styles: [] }];
}
for (const chunk of signalChunks) {
throwIfAborted(abortSignal);
results.push(await sendSignalText(chunk.text, chunk.styles));
}
};
const sendSignalMedia = async (caption: string, mediaUrl: string) => {
throwIfAborted(abortSignal);
const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY)[0] ?? {
text: caption,
styles: [],
};
return {
channel: "signal" as const,
...(await sendSignal(to, formatted.text, {
mediaUrl,
maxBytes: signalMaxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: formatted.styles,
})),
};
};
const normalizedPayloads = normalizeOutboundPayloads(payloads);
for (const payload of normalizedPayloads) {
try {
throwIfAborted(abortSignal);
params.onPayload?.(payload);
if (payload.mediaUrls.length === 0) {
await sendTextChunks(payload.text);
if (isSignalChannel) {
await sendSignalTextChunks(payload.text);
} else {
await sendTextChunks(payload.text);
}
continue;
}
@@ -198,7 +261,11 @@ export async function deliverOutboundPayloads(params: {
throwIfAborted(abortSignal);
const caption = first ? payload.text : "";
first = false;
results.push(await handler.sendMedia(caption, url));
if (isSignalChannel) {
results.push(await sendSignalMedia(caption, url));
} else {
results.push(await handler.sendMedia(caption, url));
}
}
} catch (err) {
if (!params.bestEffort) throw err;