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

@@ -0,0 +1,34 @@
---
summary: "Markdown formatting pipeline for outbound channels"
read_when:
- You are changing markdown formatting or chunking for outbound channels
- You are adding a new channel formatter or style mapping
---
# Markdown formatting
Clawdbot formats outbound Markdown by converting it into a shared intermediate
representation (IR) before rendering channel-specific output.
## Pipeline
1. **Parse Markdown -> IR**
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
- Offsets are UTF-16 code units so Signal style ranges align with its API.
2. **Chunk IR (format-first)**
- Chunking happens on the IR text before rendering.
- Inline formatting does not split across chunks; spans are sliced per chunk.
3. **Render per channel**
- **Slack:** mrkdwn tokens (bold/italic/strike/code), links as `<url|label>`.
- **Telegram:** HTML tags (`<b>`, `<i>`, `<s>`, `<code>`, `<pre><code>`, `<a href>`).
- **Signal:** plain text + `text-style` ranges; links become `label (url)` when label differs.
## Link policy
- **Slack:** `[label](url)` -> `<url|label>`; bare URLs are left as-is.
- **Telegram:** `[label](url)` -> `<a href="url">label</a>` (HTML parse mode).
- **Signal:** `[label](url)` -> `label (url)` unless label matches url.
## Spoilers
Spoiler markers (`||spoiler||`) are parsed only for Signal, where they map to
SPOILER style ranges. Other channels treat them as plain text.

View File

@@ -1,4 +1,4 @@
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
@@ -10,7 +10,7 @@ function parseReplyToMessageId(replyToId?: string | null) {
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkMarkdownText,
chunker: markdownToTelegramHtmlChunks,
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
@@ -27,6 +27,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
const replyToMessageId = parseReplyToMessageId(replyToId);
const result = await send(to, text, {
verbose: false,
textMode: "html",
messageThreadId: threadId ?? undefined,
replyToMessageId,
accountId: accountId ?? undefined,
@@ -39,6 +40,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
const result = await send(to, text, {
verbose: false,
mediaUrl,
textMode: "html",
messageThreadId: threadId ?? undefined,
replyToMessageId,
accountId: accountId ?? undefined,

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;

496
src/markdown/ir.ts Normal file
View File

@@ -0,0 +1,496 @@
import MarkdownIt from "markdown-it";
import { chunkText } from "../auto-reply/chunk.js";
type ListState = {
type: "bullet" | "ordered";
index: number;
};
type LinkState = {
href: string;
labelStart: number;
};
type RenderEnv = {
listStack: ListState[];
linkStack: LinkState[];
};
type MarkdownToken = {
type: string;
content?: string;
children?: MarkdownToken[];
attrs?: [string, string][];
attrGet?: (name: string) => string | null;
};
export type MarkdownStyle =
| "bold"
| "italic"
| "strikethrough"
| "code"
| "code_block"
| "spoiler";
export type MarkdownStyleSpan = {
start: number;
end: number;
style: MarkdownStyle;
};
export type MarkdownLinkSpan = {
start: number;
end: number;
href: string;
};
export type MarkdownIR = {
text: string;
styles: MarkdownStyleSpan[];
links: MarkdownLinkSpan[];
};
type OpenStyle = {
style: MarkdownStyle;
start: number;
};
type RenderState = {
text: string;
styles: MarkdownStyleSpan[];
openStyles: OpenStyle[];
links: MarkdownLinkSpan[];
env: RenderEnv;
headingStyle: "none" | "bold";
blockquotePrefix: string;
enableSpoilers: boolean;
};
export type MarkdownParseOptions = {
linkify?: boolean;
enableSpoilers?: boolean;
headingStyle?: "none" | "bold";
blockquotePrefix?: string;
autolink?: boolean;
};
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
const md = new MarkdownIt({
html: false,
linkify: options.linkify ?? true,
breaks: false,
typographer: false,
});
md.enable("strikethrough");
if (options.autolink === false) {
md.disable("autolink");
}
return md;
}
function getAttr(token: MarkdownToken, name: string): string | null {
if (token.attrGet) return token.attrGet(name);
if (token.attrs) {
for (const [key, value] of token.attrs) {
if (key === name) return value;
}
}
return null;
}
function createTextToken(base: MarkdownToken, content: string): MarkdownToken {
return { ...base, type: "text", content, children: undefined };
}
function applySpoilerTokens(tokens: MarkdownToken[]): void {
for (const token of tokens) {
if (token.children && token.children.length > 0) {
token.children = injectSpoilersIntoInline(token.children);
}
}
}
function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] {
const result: MarkdownToken[] = [];
const state = { spoilerOpen: false };
for (const token of tokens) {
if (token.type !== "text") {
result.push(token);
continue;
}
const content = token.content ?? "";
if (!content.includes("||")) {
result.push(token);
continue;
}
let index = 0;
while (index < content.length) {
const next = content.indexOf("||", index);
if (next === -1) {
if (index < content.length) {
result.push(createTextToken(token, content.slice(index)));
}
break;
}
if (next > index) {
result.push(createTextToken(token, content.slice(index, next)));
}
state.spoilerOpen = !state.spoilerOpen;
result.push({
type: state.spoilerOpen ? "spoiler_open" : "spoiler_close",
});
index = next + 2;
}
}
return result;
}
function appendText(state: RenderState, value: string) {
if (!value) return;
state.text += value;
}
function openStyle(state: RenderState, style: MarkdownStyle) {
state.openStyles.push({ style, start: state.text.length });
}
function closeStyle(state: RenderState, style: MarkdownStyle) {
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) {
if (state.openStyles[i]?.style === style) {
const start = state.openStyles[i].start;
state.openStyles.splice(i, 1);
const end = state.text.length;
if (end > start) {
state.styles.push({ start, end, style });
}
return;
}
}
}
function appendParagraphSeparator(state: RenderState) {
if (state.env.listStack.length > 0) return;
appendText(state, "\n\n");
}
function appendListPrefix(state: RenderState) {
const stack = state.env.listStack;
const top = stack[stack.length - 1];
if (!top) return;
top.index += 1;
const indent = " ".repeat(Math.max(0, stack.length - 1));
const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
appendText(state, `${indent}${prefix}`);
}
function renderInlineCode(state: RenderState, content: string) {
if (!content) return;
const start = state.text.length;
appendText(state, content);
state.styles.push({ start, end: start + content.length, style: "code" });
}
function renderCodeBlock(state: RenderState, content: string) {
let code = content ?? "";
if (!code.endsWith("\n")) code = `${code}\n`;
const start = state.text.length;
appendText(state, code);
state.styles.push({ start, end: start + code.length, style: "code_block" });
if (state.env.listStack.length === 0) {
appendText(state, "\n");
}
}
function handleLinkClose(state: RenderState) {
const link = state.env.linkStack.pop();
if (!link?.href) return;
const href = link.href.trim();
if (!href) return;
const start = link.labelStart;
const end = state.text.length;
if (end <= start) {
state.links.push({ start, end, href });
return;
}
state.links.push({ start, end, href });
}
function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
for (const token of tokens) {
switch (token.type) {
case "inline":
if (token.children) renderTokens(token.children, state);
break;
case "text":
appendText(state, token.content ?? "");
break;
case "em_open":
openStyle(state, "italic");
break;
case "em_close":
closeStyle(state, "italic");
break;
case "strong_open":
openStyle(state, "bold");
break;
case "strong_close":
closeStyle(state, "bold");
break;
case "s_open":
openStyle(state, "strikethrough");
break;
case "s_close":
closeStyle(state, "strikethrough");
break;
case "code_inline":
renderInlineCode(state, token.content ?? "");
break;
case "spoiler_open":
if (state.enableSpoilers) openStyle(state, "spoiler");
break;
case "spoiler_close":
if (state.enableSpoilers) closeStyle(state, "spoiler");
break;
case "link_open": {
const href = getAttr(token, "href") ?? "";
state.env.linkStack.push({ href, labelStart: state.text.length });
break;
}
case "link_close":
handleLinkClose(state);
break;
case "image":
appendText(state, token.content ?? "");
break;
case "softbreak":
case "hardbreak":
appendText(state, "\n");
break;
case "paragraph_close":
appendParagraphSeparator(state);
break;
case "heading_open":
if (state.headingStyle === "bold") openStyle(state, "bold");
break;
case "heading_close":
if (state.headingStyle === "bold") closeStyle(state, "bold");
appendParagraphSeparator(state);
break;
case "blockquote_open":
if (state.blockquotePrefix) appendText(state, state.blockquotePrefix);
break;
case "blockquote_close":
appendText(state, "\n");
break;
case "bullet_list_open":
state.env.listStack.push({ type: "bullet", index: 0 });
break;
case "bullet_list_close":
state.env.listStack.pop();
break;
case "ordered_list_open": {
const start = Number(getAttr(token, "start") ?? "1");
state.env.listStack.push({ type: "ordered", index: start - 1 });
break;
}
case "ordered_list_close":
state.env.listStack.pop();
break;
case "list_item_open":
appendListPrefix(state);
break;
case "list_item_close":
appendText(state, "\n");
break;
case "code_block":
case "fence":
renderCodeBlock(state, token.content ?? "");
break;
case "html_block":
case "html_inline":
appendText(state, token.content ?? "");
break;
case "table_open":
case "table_close":
case "thead_open":
case "thead_close":
case "tbody_open":
case "tbody_close":
break;
case "tr_close":
appendText(state, "\n");
break;
case "th_close":
case "td_close":
appendText(state, "\t");
break;
case "hr":
appendText(state, "\n");
break;
default:
if (token.children) renderTokens(token.children, state);
break;
}
}
}
function closeRemainingStyles(state: RenderState) {
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) {
const open = state.openStyles[i];
const end = state.text.length;
if (end > open.start) {
state.styles.push({
start: open.start,
end,
style: open.style,
});
}
}
state.openStyles = [];
}
function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] {
const clamped: MarkdownStyleSpan[] = [];
for (const span of spans) {
const start = Math.max(0, Math.min(span.start, maxLength));
const end = Math.max(start, Math.min(span.end, maxLength));
if (end > start) clamped.push({ start, end, style: span.style });
}
return clamped;
}
function clampLinkSpans(spans: MarkdownLinkSpan[], maxLength: number): MarkdownLinkSpan[] {
const clamped: MarkdownLinkSpan[] = [];
for (const span of spans) {
const start = Math.max(0, Math.min(span.start, maxLength));
const end = Math.max(start, Math.min(span.end, maxLength));
if (end > start) clamped.push({ start, end, href: span.href });
}
return clamped;
}
function mergeStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
const sorted = [...spans].sort((a, b) => {
if (a.start !== b.start) return a.start - b.start;
if (a.end !== b.end) return a.end - b.end;
return a.style.localeCompare(b.style);
});
const merged: MarkdownStyleSpan[] = [];
for (const span of sorted) {
const prev = merged[merged.length - 1];
if (prev && prev.style === span.style && span.start <= prev.end) {
prev.end = Math.max(prev.end, span.end);
continue;
}
merged.push({ ...span });
}
return merged;
}
function sliceStyleSpans(
spans: MarkdownStyleSpan[],
start: number,
end: number,
): MarkdownStyleSpan[] {
if (spans.length === 0) return [];
const sliced: MarkdownStyleSpan[] = [];
for (const span of spans) {
const sliceStart = Math.max(span.start, start);
const sliceEnd = Math.min(span.end, end);
if (sliceEnd > sliceStart) {
sliced.push({
start: sliceStart - start,
end: sliceEnd - start,
style: span.style,
});
}
}
return mergeStyleSpans(sliced);
}
function sliceLinkSpans(
spans: MarkdownLinkSpan[],
start: number,
end: number,
): MarkdownLinkSpan[] {
if (spans.length === 0) return [];
const sliced: MarkdownLinkSpan[] = [];
for (const span of spans) {
const sliceStart = Math.max(span.start, start);
const sliceEnd = Math.min(span.end, end);
if (sliceEnd > sliceStart) {
sliced.push({
start: sliceStart - start,
end: sliceEnd - start,
href: span.href,
});
}
}
return sliced;
}
export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR {
const env: RenderEnv = { listStack: [], linkStack: [] };
const md = createMarkdownIt(options);
const tokens = md.parse(markdown ?? "", env as unknown as object);
if (options.enableSpoilers) {
applySpoilerTokens(tokens as MarkdownToken[]);
}
const state: RenderState = {
text: "",
styles: [],
openStyles: [],
links: [],
env,
headingStyle: options.headingStyle ?? "none",
blockquotePrefix: options.blockquotePrefix ?? "",
enableSpoilers: options.enableSpoilers ?? false,
};
renderTokens(tokens as MarkdownToken[], state);
closeRemainingStyles(state);
const trimmedText = state.text.trimEnd();
const trimmedLength = trimmedText.length;
return {
text: trimmedText,
styles: mergeStyleSpans(clampStyleSpans(state.styles, trimmedLength)),
links: clampLinkSpans(state.links, trimmedLength),
};
}
export function chunkMarkdownIR(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) return [];
if (limit <= 0 || ir.text.length <= limit) return [ir];
const chunks = chunkText(ir.text, limit);
const results: MarkdownIR[] = [];
let cursor = 0;
chunks.forEach((chunk, index) => {
if (!chunk) return;
if (index > 0) {
while (cursor < ir.text.length && /\s/.test(ir.text[cursor] ?? "")) {
cursor += 1;
}
}
const start = cursor;
const end = Math.min(ir.text.length, start + chunk.length);
results.push({
text: chunk,
styles: sliceStyleSpans(ir.styles, start, end),
links: sliceLinkSpans(ir.links, start, end),
});
cursor = end;
});
return results;
}

137
src/markdown/render.ts Normal file
View File

@@ -0,0 +1,137 @@
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
export type RenderStyleMarker = {
open: string;
close: string;
};
export type RenderStyleMap = Partial<Record<MarkdownStyle, RenderStyleMarker>>;
export type RenderLink = {
start: number;
end: number;
open: string;
close: string;
};
export type RenderOptions = {
styleMarkers: RenderStyleMap;
escapeText: (text: string) => string;
buildLink?: (link: MarkdownLinkSpan, text: string) => RenderLink | null;
};
const STYLE_ORDER: MarkdownStyle[] = [
"code_block",
"code",
"bold",
"italic",
"strikethrough",
"spoiler",
];
const STYLE_RANK = new Map<MarkdownStyle, number>(
STYLE_ORDER.map((style, index) => [style, index]),
);
function sortStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
return [...spans].sort((a, b) => {
if (a.start !== b.start) return a.start - b.start;
if (a.end !== b.end) return b.end - a.end;
return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
});
}
export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions): string {
const text = ir.text ?? "";
if (!text) return "";
const styleMarkers = options.styleMarkers;
const styled = sortStyleSpans(
ir.styles.filter((span) => Boolean(styleMarkers[span.style])),
);
const boundaries = new Set<number>();
boundaries.add(0);
boundaries.add(text.length);
const startsAt = new Map<number, MarkdownStyleSpan[]>();
for (const span of styled) {
if (span.start === span.end) continue;
boundaries.add(span.start);
boundaries.add(span.end);
const bucket = startsAt.get(span.start);
if (bucket) bucket.push(span);
else startsAt.set(span.start, [span]);
}
for (const spans of startsAt.values()) {
spans.sort((a, b) => {
if (a.end !== b.end) return b.end - a.end;
return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
});
}
const linkStarts = new Map<number, RenderLink[]>();
const linkEnds = new Map<number, RenderLink[]>();
if (options.buildLink) {
for (const link of ir.links) {
if (link.start === link.end) continue;
const rendered = options.buildLink(link, text);
if (!rendered) continue;
boundaries.add(rendered.start);
boundaries.add(rendered.end);
const openBucket = linkStarts.get(rendered.start);
if (openBucket) openBucket.push(rendered);
else linkStarts.set(rendered.start, [rendered]);
const closeBucket = linkEnds.get(rendered.end);
if (closeBucket) closeBucket.push(rendered);
else linkEnds.set(rendered.end, [rendered]);
}
}
const points = [...boundaries].sort((a, b) => a - b);
const stack: MarkdownStyleSpan[] = [];
let out = "";
for (let i = 0; i < points.length; i += 1) {
const pos = points[i];
while (stack.length && stack[stack.length - 1]?.end === pos) {
const span = stack.pop();
if (!span) break;
const marker = styleMarkers[span.style];
if (marker) out += marker.close;
}
const closingLinks = linkEnds.get(pos);
if (closingLinks && closingLinks.length > 0) {
for (const link of closingLinks) {
out += link.close;
}
}
const openingLinks = linkStarts.get(pos);
if (openingLinks && openingLinks.length > 0) {
for (const link of openingLinks) {
out += link.open;
}
}
const openingStyles = startsAt.get(pos);
if (openingStyles) {
for (const span of openingStyles) {
const marker = styleMarkers[span.style];
if (!marker) continue;
stack.push(span);
out += marker.open;
}
}
const next = points[i + 1];
if (next === undefined) break;
if (next > pos) {
out += options.escapeText(text.slice(pos, next));
}
}
return out;
}

67
src/signal/format.test.ts Normal file
View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { markdownToSignalText } from "./format.js";
describe("markdownToSignalText", () => {
it("renders inline styles", () => {
const res = markdownToSignalText("hi _there_ **boss** ~~nope~~ `code`");
expect(res.text).toBe("hi there boss nope code");
expect(res.styles).toEqual([
{ start: 3, length: 5, style: "ITALIC" },
{ start: 9, length: 4, style: "BOLD" },
{ start: 14, length: 4, style: "STRIKETHROUGH" },
{ start: 19, length: 4, style: "MONOSPACE" },
]);
});
it("renders links as label plus url when needed", () => {
const res = markdownToSignalText(
"see [docs](https://example.com) and https://example.com",
);
expect(res.text).toBe(
"see docs (https://example.com) and https://example.com",
);
expect(res.styles).toEqual([]);
});
it("applies spoiler styling", () => {
const res = markdownToSignalText("hello ||secret|| world");
expect(res.text).toBe("hello secret world");
expect(res.styles).toEqual([{ start: 6, length: 6, style: "SPOILER" }]);
});
it("renders fenced code blocks with monospaced styles", () => {
const res = markdownToSignalText(
"before\n\n```\nconst x = 1;\n```\n\nafter",
);
const prefix = "before\n\n";
const code = "const x = 1;\n";
const suffix = "\nafter";
expect(res.text).toBe(`${prefix}${code}${suffix}`);
expect(res.styles).toEqual([
{ start: prefix.length, length: code.length, style: "MONOSPACE" },
]);
});
it("renders lists without extra block markup", () => {
const res = markdownToSignalText("- one\n- two");
expect(res.text).toBe("• one\n• two");
expect(res.styles).toEqual([]);
});
it("uses UTF-16 code units for offsets", () => {
const res = markdownToSignalText("😀 **bold**");
const prefix = "😀 ";
expect(res.text).toBe(`${prefix}bold`);
expect(res.styles).toEqual([
{ start: prefix.length, length: 4, style: "BOLD" },
]);
});
});

220
src/signal/format.ts Normal file
View File

@@ -0,0 +1,220 @@
import { chunkMarkdownIR, markdownToIR, type MarkdownIR, type MarkdownStyle } from "../markdown/ir.js";
type SignalTextStyle =
| "BOLD"
| "ITALIC"
| "STRIKETHROUGH"
| "MONOSPACE"
| "SPOILER";
export type SignalTextStyleRange = {
start: number;
length: number;
style: SignalTextStyle;
};
export type SignalFormattedText = {
text: string;
styles: SignalTextStyleRange[];
};
type SignalStyleSpan = {
start: number;
end: number;
style: SignalTextStyle;
};
type Insertion = {
pos: number;
length: number;
};
function mapStyle(style: MarkdownStyle): SignalTextStyle | null {
switch (style) {
case "bold":
return "BOLD";
case "italic":
return "ITALIC";
case "strikethrough":
return "STRIKETHROUGH";
case "code":
case "code_block":
return "MONOSPACE";
case "spoiler":
return "SPOILER";
default:
return null;
}
}
function mergeStyles(styles: SignalTextStyleRange[]): SignalTextStyleRange[] {
const sorted = [...styles].sort((a, b) => {
if (a.start !== b.start) return a.start - b.start;
if (a.length !== b.length) return a.length - b.length;
return a.style.localeCompare(b.style);
});
const merged: SignalTextStyleRange[] = [];
for (const style of sorted) {
const prev = merged[merged.length - 1];
if (
prev &&
prev.style === style.style &&
style.start <= prev.start + prev.length
) {
const prevEnd = prev.start + prev.length;
const nextEnd = Math.max(prevEnd, style.start + style.length);
prev.length = nextEnd - prev.start;
continue;
}
merged.push({ ...style });
}
return merged;
}
function clampStyles(
styles: SignalTextStyleRange[],
maxLength: number,
): SignalTextStyleRange[] {
const clamped: SignalTextStyleRange[] = [];
for (const style of styles) {
const start = Math.max(0, Math.min(style.start, maxLength));
const end = Math.min(style.start + style.length, maxLength);
const length = end - start;
if (length > 0) clamped.push({ start, length, style: style.style });
}
return clamped;
}
function applyInsertionsToStyles(
spans: SignalStyleSpan[],
insertions: Insertion[],
): SignalStyleSpan[] {
if (insertions.length === 0) return spans;
const sortedInsertions = [...insertions].sort((a, b) => a.pos - b.pos);
let updated = spans;
for (const insertion of sortedInsertions) {
const next: SignalStyleSpan[] = [];
for (const span of updated) {
if (span.end <= insertion.pos) {
next.push(span);
continue;
}
if (span.start >= insertion.pos) {
next.push({
start: span.start + insertion.length,
end: span.end + insertion.length,
style: span.style,
});
continue;
}
if (span.start < insertion.pos && span.end > insertion.pos) {
if (insertion.pos > span.start) {
next.push({
start: span.start,
end: insertion.pos,
style: span.style,
});
}
const shiftedStart = insertion.pos + insertion.length;
const shiftedEnd = span.end + insertion.length;
if (shiftedEnd > shiftedStart) {
next.push({
start: shiftedStart,
end: shiftedEnd,
style: span.style,
});
}
}
}
updated = next;
}
return updated;
}
function renderSignalText(ir: MarkdownIR): SignalFormattedText {
const text = ir.text ?? "";
if (!text) return { text: "", styles: [] };
const sortedLinks = [...ir.links].sort((a, b) => a.start - b.start);
let out = "";
let cursor = 0;
const insertions: Insertion[] = [];
for (const link of sortedLinks) {
if (link.start < cursor) continue;
out += text.slice(cursor, link.end);
const href = link.href.trim();
const label = text.slice(link.start, link.end);
const trimmedLabel = label.trim();
const comparableHref = href.startsWith("mailto:") ? href.slice("mailto:".length) : href;
if (href) {
if (!trimmedLabel) {
out += href;
insertions.push({ pos: link.end, length: href.length });
} else if (trimmedLabel !== href && trimmedLabel !== comparableHref) {
const addition = ` (${href})`;
out += addition;
insertions.push({ pos: link.end, length: addition.length });
}
}
cursor = link.end;
}
out += text.slice(cursor);
const mappedStyles: SignalStyleSpan[] = ir.styles
.map((span) => {
const mapped = mapStyle(span.style);
if (!mapped) return null;
return { start: span.start, end: span.end, style: mapped };
})
.filter((span): span is SignalStyleSpan => span !== null);
const adjusted = applyInsertionsToStyles(mappedStyles, insertions);
const trimmedText = out.trimEnd();
const trimmedLength = trimmedText.length;
const clamped = clampStyles(
adjusted.map((span) => ({
start: span.start,
length: span.end - span.start,
style: span.style,
})),
trimmedLength,
);
return {
text: trimmedText,
styles: mergeStyles(clamped),
};
}
export function markdownToSignalText(markdown: string): SignalFormattedText {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
});
return renderSignalText(ir);
}
export function markdownToSignalTextChunks(
markdown: string,
limit: number,
): SignalFormattedText[] {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
});
const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) => renderSignalText(chunk));
}

View File

@@ -4,6 +4,7 @@ import { saveMediaBuffer } from "../media/store.js";
import { loadWebMedia } from "../web/media.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalRpcRequest } from "./client.js";
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
export type SignalSendOpts = {
baseUrl?: string;
@@ -12,6 +13,8 @@ export type SignalSendOpts = {
mediaUrl?: string;
maxBytes?: number;
timeoutMs?: number;
textMode?: "markdown" | "plain";
textStyles?: SignalTextStyleRange[];
};
export type SignalSendResult = {
@@ -75,6 +78,9 @@ export async function sendMessageSignal(
const account = opts.account?.trim() || accountInfo.config.account?.trim();
const target = parseTarget(to);
let message = text ?? "";
let messageFromPlaceholder = false;
let textStyles: SignalTextStyleRange[] = [];
const textMode = opts.textMode ?? "markdown";
const maxBytes = (() => {
if (typeof opts.maxBytes === "number") return opts.maxBytes;
if (typeof accountInfo.config.mediaMaxMb === "number") {
@@ -94,6 +100,17 @@ export async function sendMessageSignal(
if (!message && kind) {
// Avoid sending an empty body when only attachments exist.
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;
messageFromPlaceholder = true;
}
}
if (message.trim() && !messageFromPlaceholder) {
if (textMode === "plain") {
textStyles = opts.textStyles ?? [];
} else {
const formatted = markdownToSignalText(message);
message = formatted.text;
textStyles = formatted.styles;
}
}
@@ -102,6 +119,11 @@ export async function sendMessageSignal(
}
const params: Record<string, unknown> = { message };
if (textStyles.length > 0) {
params["text-style"] = textStyles.map(
(style) => `${style.start}:${style.length}:${style.style}`,
);
}
if (account) params.account = account;
if (attachments && attachments.length > 0) {
params.attachments = attachments;

View File

@@ -33,9 +33,9 @@ describe("markdownToSlackMrkdwn", () => {
expect(res).toBe("```\nconst x = 1;\n```");
});
it("renders links with URL in parentheses", () => {
it("renders links with Slack mrkdwn syntax", () => {
const res = markdownToSlackMrkdwn("see [docs](https://example.com)");
expect(res).toBe("see docs (https://example.com)");
expect(res).toBe("see <https://example.com|docs>");
});
it("does not duplicate bare URLs", () => {
@@ -94,7 +94,7 @@ describe("markdownToSlackMrkdwn", () => {
"**Important:** Check the _docs_ at [link](https://example.com)\n\n- first\n- second",
);
expect(res).toBe(
"*Important:* Check the _docs_ at link (https://example.com)\n\n• first\n• second",
"*Important:* Check the _docs_ at <https://example.com|link>\n\n• first\n• second",
);
});
});

View File

@@ -1,33 +1,8 @@
import MarkdownIt from "markdown-it";
import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js";
import { renderMarkdownWithMarkers } from "../markdown/render.js";
type ListState = {
type: "bullet" | "ordered";
index: number;
};
type RenderEnv = {
slackListStack?: ListState[];
slackLinkStack?: { href: string }[];
};
const md = new MarkdownIt({
html: false,
// Slack will auto-link plain URLs; keeping linkify off avoids double-rendering
// (e.g. "https://x.com" becoming "https://x.com (https://x.com)").
linkify: false,
breaks: false,
typographer: false,
});
md.enable("strikethrough");
/**
* Escape special characters for Slack mrkdwn format.
*
* By default, Slack uses angle-bracket markup for mentions and links
* (e.g. "<@U123>", "<https://…|text>"). We preserve those tokens so agents
* can intentionally include them, while escaping other uses of "<" and ">".
*/
// Escape special characters for Slack mrkdwn format.
// Preserve Slack's angle-bracket tokens so mentions and links stay intact.
function escapeSlackMrkdwnSegment(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
@@ -74,165 +49,63 @@ function escapeSlackMrkdwnText(text: string): string {
return out.join("");
}
function getListStack(env: RenderEnv): ListState[] {
if (!env.slackListStack) env.slackListStack = [];
return env.slackListStack;
function buildSlackLink(link: MarkdownLinkSpan, text: string) {
const href = link.href.trim();
if (!href) return null;
const label = text.slice(link.start, link.end);
const trimmedLabel = label.trim();
const comparableHref = href.startsWith("mailto:") ? href.slice("mailto:".length) : href;
const useMarkup =
trimmedLabel.length > 0 && trimmedLabel !== href && trimmedLabel !== comparableHref;
if (!useMarkup) return null;
const safeHref = escapeSlackMrkdwnSegment(href);
return {
start: link.start,
end: link.end,
open: `<${safeHref}|`,
close: ">",
};
}
function getLinkStack(env: RenderEnv): { href: string }[] {
if (!env.slackLinkStack) env.slackLinkStack = [];
return env.slackLinkStack;
}
md.renderer.rules.text = (tokens, idx) => escapeSlackMrkdwnText(tokens[idx]?.content ?? "");
md.renderer.rules.softbreak = () => "\n";
md.renderer.rules.hardbreak = () => "\n";
md.renderer.rules.paragraph_open = () => "";
md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => {
const stack = getListStack(env as RenderEnv);
return stack.length ? "" : "\n\n";
};
md.renderer.rules.heading_open = () => "*";
md.renderer.rules.heading_close = () => "*\n\n";
md.renderer.rules.blockquote_open = () => "> ";
md.renderer.rules.blockquote_close = () => "\n";
md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => {
getListStack(env as RenderEnv).push({ type: "bullet", index: 0 });
return "";
};
md.renderer.rules.bullet_list_close = (_tokens, _idx, _opts, env) => {
getListStack(env as RenderEnv).pop();
return "";
};
md.renderer.rules.ordered_list_open = (tokens, idx, _opts, env) => {
const start = Number(tokens[idx]?.attrGet("start") ?? "1");
getListStack(env as RenderEnv).push({ type: "ordered", index: start - 1 });
return "";
};
md.renderer.rules.ordered_list_close = (_tokens, _idx, _opts, env) => {
getListStack(env as RenderEnv).pop();
return "";
};
md.renderer.rules.list_item_open = (_tokens, _idx, _opts, env) => {
const stack = getListStack(env as RenderEnv);
const top = stack[stack.length - 1];
if (!top) return "";
top.index += 1;
const indent = " ".repeat(Math.max(0, stack.length - 1));
const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
return `${indent}${prefix}`;
};
md.renderer.rules.list_item_close = () => "\n";
// Slack mrkdwn uses _text_ for italic (same as markdown)
md.renderer.rules.em_open = () => "_";
md.renderer.rules.em_close = () => "_";
// Slack mrkdwn uses *text* for bold (single asterisk, not double)
md.renderer.rules.strong_open = () => "*";
md.renderer.rules.strong_close = () => "*";
// Slack mrkdwn uses ~text~ for strikethrough (single tilde)
md.renderer.rules.s_open = () => "~";
md.renderer.rules.s_close = () => "~";
md.renderer.rules.code_inline = (tokens, idx) =>
`\`${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\``;
md.renderer.rules.code_block = (tokens, idx) =>
`\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`;
md.renderer.rules.fence = (tokens, idx) =>
`\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`;
md.renderer.rules.link_open = (tokens, idx, _opts, env) => {
const href = tokens[idx]?.attrGet("href") ?? "";
const stack = getLinkStack(env as RenderEnv);
stack.push({ href });
return "";
};
md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => {
const stack = getLinkStack(env as RenderEnv);
const link = stack.pop();
if (link?.href) {
return ` (${escapeSlackMrkdwnSegment(link.href)})`;
}
return "";
};
md.renderer.rules.image = (tokens, idx) => {
const alt = tokens[idx]?.content ?? "";
return escapeSlackMrkdwnSegment(alt);
};
md.renderer.rules.html_block = (tokens, idx) =>
escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "");
md.renderer.rules.html_inline = (tokens, idx) =>
escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "");
md.renderer.rules.table_open = () => "";
md.renderer.rules.table_close = () => "";
md.renderer.rules.thead_open = () => "";
md.renderer.rules.thead_close = () => "";
md.renderer.rules.tbody_open = () => "";
md.renderer.rules.tbody_close = () => "";
md.renderer.rules.tr_open = () => "";
md.renderer.rules.tr_close = () => "\n";
md.renderer.rules.th_open = () => "";
md.renderer.rules.th_close = () => "\t";
md.renderer.rules.td_open = () => "";
md.renderer.rules.td_close = () => "\t";
md.renderer.rules.hr = () => "\n";
function protectSlackAngleLinks(markdown: string): {
markdown: string;
tokens: string[];
} {
const tokens: string[] = [];
const protectedMarkdown = (markdown ?? "").replace(
/<(?:https?:\/\/|mailto:|tel:|slack:\/\/)[^>\n]+>/g,
(match) => {
const id = tokens.length;
tokens.push(match);
return `⟦clawdbot-slacktok:${id}`;
},
);
return { markdown: protectedMarkdown, tokens };
}
function restoreSlackAngleLinks(text: string, tokens: string[]): string {
let out = text;
for (let i = 0; i < tokens.length; i++) {
out = out.replaceAll(`⟦clawdbot-slacktok:${i}`, tokens[i] ?? "");
}
return out;
}
/**
* Convert standard Markdown to Slack mrkdwn format.
*
* Slack mrkdwn differences from standard Markdown:
* - Bold: *text* (single asterisk, not double)
* - Italic: _text_ (same)
* - Strikethrough: ~text~ (single tilde)
* - Code: `code` (same)
* - Links: <url|text> or plain URL
* - Escape &, <, > as &amp;, &lt;, &gt;
*/
export function markdownToSlackMrkdwn(markdown: string): string {
const env: RenderEnv = {};
const protectedLinks = protectSlackAngleLinks(markdown ?? "");
const rendered = md.render(protectedLinks.markdown, env);
const normalized = rendered
.replace(/[ \t]+\n/g, "\n")
.replace(/\t+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trimEnd();
return restoreSlackAngleLinks(normalized, protectedLinks.tokens);
const ir = markdownToIR(markdown ?? "", {
linkify: false,
autolink: false,
headingStyle: "bold",
blockquotePrefix: "> ",
});
return renderMarkdownWithMarkers(ir, {
styleMarkers: {
bold: { open: "*", close: "*" },
italic: { open: "_", close: "_" },
strikethrough: { open: "~", close: "~" },
code: { open: "`", close: "`" },
code_block: { open: "```\n", close: "```" },
},
escapeText: escapeSlackMrkdwnText,
buildLink: buildSlackLink,
});
}
export function markdownToSlackMrkdwnChunks(markdown: string, limit: number): string[] {
const ir = markdownToIR(markdown ?? "", {
linkify: false,
autolink: false,
headingStyle: "bold",
blockquotePrefix: "> ",
});
const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) =>
renderMarkdownWithMarkers(chunk, {
styleMarkers: {
bold: { open: "*", close: "*" },
italic: { open: "_", close: "_" },
strikethrough: { open: "~", close: "~" },
code: { open: "`", close: "`" },
code_block: { open: "```\n", close: "```" },
},
escapeText: escapeSlackMrkdwnText,
buildLink: buildSlackLink,
}),
);
}

View File

@@ -1,8 +1,8 @@
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import { markdownToSlackMrkdwnChunks } from "../format.js";
import { sendMessageSlack } from "../send.js";
export async function deliverReplies(params: {
@@ -14,7 +14,6 @@ export async function deliverReplies(params: {
textLimit: number;
replyThreadTs?: string;
}) {
const chunkLimit = Math.min(params.textLimit, 4000);
for (const payload of params.replies) {
const threadTs = payload.replyToId ?? params.replyThreadTs;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
@@ -22,15 +21,13 @@ export async function deliverReplies(params: {
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
await sendMessageSlack(params.target, trimmed, {
token: params.token,
threadTs,
accountId: params.accountId,
});
}
const trimmed = text.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
await sendMessageSlack(params.target, trimmed, {
token: params.token,
threadTs,
accountId: params.accountId,
});
} else {
let first = true;
for (const mediaUrl of mediaList) {
@@ -130,7 +127,7 @@ export async function deliverSlackSlashReplies(params: {
.filter(Boolean)
.join("\n");
if (!combined) continue;
for (const chunk of chunkMarkdownText(combined, chunkLimit)) {
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) {
messages.push(chunk);
}
}

View File

@@ -1,12 +1,12 @@
import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api";
import { chunkMarkdownText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { loadWebMedia } from "../web/media.js";
import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js";
import { markdownToSlackMrkdwn } from "./format.js";
import { markdownToSlackMrkdwnChunks } from "./format.js";
import { resolveSlackBotToken } from "./token.js";
const SLACK_TEXT_LIMIT = 4000;
@@ -164,8 +164,7 @@ export async function sendMessageSlack(
const { channelId } = await resolveChannelId(client, recipient);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
const slackFormatted = markdownToSlackMrkdwn(trimmedMessage);
const chunks = chunkMarkdownText(slackFormatted, chunkLimit);
const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit);
const mediaMaxBytes =
typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024

View File

@@ -1,5 +1,5 @@
import { type Bot, InputFile } from "grammy";
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { ReplyToMode } from "../../config/config.js";
import { danger, logVerbose } from "../../globals.js";
@@ -10,7 +10,6 @@ import { isGifMedia } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js";
import type { RuntimeEnv } from "../../runtime.js";
import { loadWebMedia } from "../../web/media.js";
import { markdownToTelegramHtml } from "../format.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
import type { TelegramContext } from "./types.js";
@@ -42,11 +41,14 @@ export async function deliverReplies(params: {
? [reply.mediaUrl]
: [];
if (mediaList.length === 0) {
for (const chunk of chunkMarkdownText(reply.text || "", textLimit)) {
await sendTelegramText(bot, chatId, chunk, runtime, {
const chunks = markdownToTelegramChunks(reply.text || "", textLimit);
for (const chunk of chunks) {
await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId:
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
messageThreadId,
textMode: "html",
plainText: chunk.text,
});
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -155,7 +157,12 @@ async function sendTelegramText(
chatId: string,
text: string,
runtime: RuntimeEnv,
opts?: { replyToMessageId?: number; messageThreadId?: number },
opts?: {
replyToMessageId?: number;
messageThreadId?: number;
textMode?: "markdown" | "html";
plainText?: string;
},
): Promise<number | undefined> {
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
const baseParams: Record<string, unknown> = {
@@ -164,7 +171,8 @@ async function sendTelegramText(
if (threadParams) {
baseParams.message_thread_id = threadParams.message_thread_id;
}
const htmlText = markdownToTelegramHtml(text);
const textMode = opts?.textMode ?? "markdown";
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
try {
const res = await bot.api.sendMessage(chatId, htmlText, {
parse_mode: "HTML",
@@ -175,7 +183,8 @@ async function sendTelegramText(
const errText = formatErrorMessage(err);
if (PARSE_ERR_RE.test(errText)) {
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
const res = await bot.api.sendMessage(chatId, text, {
const fallbackText = opts?.plainText ?? text;
const res = await bot.api.sendMessage(chatId, fallbackText, {
...baseParams,
});
return res.message_id;

View File

@@ -1,138 +1,68 @@
import MarkdownIt from "markdown-it";
import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan, type MarkdownIR } from "../markdown/ir.js";
import { renderMarkdownWithMarkers } from "../markdown/render.js";
type ListState = {
type: "bullet" | "ordered";
index: number;
export type TelegramFormattedChunk = {
html: string;
text: string;
};
type RenderEnv = {
telegramListStack?: ListState[];
telegramLinkStack?: boolean[];
};
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: false,
typographer: false,
});
md.enable("strikethrough");
const { escapeHtml } = md.utils;
function getListStack(env: RenderEnv): ListState[] {
if (!env.telegramListStack) env.telegramListStack = [];
return env.telegramListStack;
function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function getLinkStack(env: RenderEnv): boolean[] {
if (!env.telegramLinkStack) env.telegramLinkStack = [];
return env.telegramLinkStack;
function escapeHtmlAttr(text: string): string {
return escapeHtml(text).replace(/"/g, "&quot;");
}
md.renderer.rules.text = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
function buildTelegramLink(link: MarkdownLinkSpan, _text: string) {
const href = link.href.trim();
if (!href) return null;
if (link.start === link.end) return null;
const safeHref = escapeHtmlAttr(href);
return {
start: link.start,
end: link.end,
open: `<a href="${safeHref}">`,
close: "</a>",
};
}
md.renderer.rules.softbreak = () => "\n";
md.renderer.rules.hardbreak = () => "\n";
md.renderer.rules.paragraph_open = () => "";
md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => {
const stack = getListStack(env as RenderEnv);
return stack.length ? "" : "\n\n";
};
md.renderer.rules.heading_open = () => "";
md.renderer.rules.heading_close = () => "\n\n";
md.renderer.rules.blockquote_open = () => "";
md.renderer.rules.blockquote_close = () => "\n";
md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => {
getListStack(env as RenderEnv).push({ type: "bullet", index: 0 });
return "";
};
md.renderer.rules.bullet_list_close = (_tokens, _idx, _opts, env) => {
getListStack(env as RenderEnv).pop();
return "";
};
md.renderer.rules.ordered_list_open = (tokens, idx, _opts, env) => {
const start = Number(tokens[idx]?.attrGet("start") ?? "1");
getListStack(env as RenderEnv).push({ type: "ordered", index: start - 1 });
return "";
};
md.renderer.rules.ordered_list_close = (_tokens, _idx, _opts, env) => {
getListStack(env as RenderEnv).pop();
return "";
};
md.renderer.rules.list_item_open = (_tokens, _idx, _opts, env) => {
const stack = getListStack(env as RenderEnv);
const top = stack[stack.length - 1];
if (!top) return "";
top.index += 1;
const indent = " ".repeat(Math.max(0, stack.length - 1));
const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
return `${indent}${prefix}`;
};
md.renderer.rules.list_item_close = () => "\n";
md.renderer.rules.em_open = () => "<i>";
md.renderer.rules.em_close = () => "</i>";
md.renderer.rules.strong_open = () => "<b>";
md.renderer.rules.strong_close = () => "</b>";
md.renderer.rules.s_open = () => "<s>";
md.renderer.rules.s_close = () => "</s>";
md.renderer.rules.code_inline = (tokens, idx) =>
`<code>${escapeHtml(tokens[idx]?.content ?? "")}</code>`;
md.renderer.rules.code_block = (tokens, idx) =>
`<pre><code>${escapeHtml(tokens[idx]?.content ?? "")}</code></pre>\n`;
md.renderer.rules.fence = (tokens, idx) =>
`<pre><code>${escapeHtml(tokens[idx]?.content ?? "")}</code></pre>\n`;
md.renderer.rules.link_open = (tokens, idx, _opts, env) => {
const href = tokens[idx]?.attrGet("href") ?? "";
const safeHref = escapeHtml(href);
const stack = getLinkStack(env as RenderEnv);
const hasHref = Boolean(safeHref);
stack.push(hasHref);
return hasHref ? `<a href="${safeHref}">` : "";
};
md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => {
const stack = getLinkStack(env as RenderEnv);
const hasHref = stack.pop();
return hasHref ? "</a>" : "";
};
md.renderer.rules.image = (tokens, idx) => {
const alt = tokens[idx]?.content ?? "";
return escapeHtml(alt);
};
md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.table_open = () => "";
md.renderer.rules.table_close = () => "";
md.renderer.rules.thead_open = () => "";
md.renderer.rules.thead_close = () => "";
md.renderer.rules.tbody_open = () => "";
md.renderer.rules.tbody_close = () => "";
md.renderer.rules.tr_open = () => "";
md.renderer.rules.tr_close = () => "\n";
md.renderer.rules.th_open = () => "";
md.renderer.rules.th_close = () => "\t";
md.renderer.rules.td_open = () => "";
md.renderer.rules.td_close = () => "\t";
md.renderer.rules.hr = () => "\n";
function renderTelegramHtml(ir: MarkdownIR): string {
return renderMarkdownWithMarkers(ir, {
styleMarkers: {
bold: { open: "<b>", close: "</b>" },
italic: { open: "<i>", close: "</i>" },
strikethrough: { open: "<s>", close: "</s>" },
code: { open: "<code>", close: "</code>" },
code_block: { open: "<pre><code>", close: "</code></pre>" },
},
escapeText: escapeHtml,
buildLink: buildTelegramLink,
});
}
export function markdownToTelegramHtml(markdown: string): string {
const env: RenderEnv = {};
const rendered = md.render(markdown ?? "", env);
return rendered
.replace(/[ \t]+\n/g, "\n")
.replace(/\t+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trimEnd();
const ir = markdownToIR(markdown ?? "", {
linkify: true,
headingStyle: "none",
blockquotePrefix: "",
});
return renderTelegramHtml(ir);
}
export function markdownToTelegramChunks(markdown: string, limit: number): TelegramFormattedChunk[] {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
headingStyle: "none",
blockquotePrefix: "",
});
const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) => ({
html: renderTelegramHtml(chunk),
text: chunk.text,
}));
}
export function markdownToTelegramHtmlChunks(markdown: string, limit: number): string[] {
return markdownToTelegramChunks(markdown, limit).map((chunk) => chunk.html);
}

View File

@@ -28,6 +28,8 @@ type TelegramSendOpts = {
maxBytes?: number;
api?: Bot["api"];
retry?: RetryConfig;
textMode?: "markdown" | "html";
plainText?: string;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
asVoice?: boolean;
/** Message ID to reply to (for threading) */
@@ -308,7 +310,8 @@ export async function sendMessageTelegram(
if (!text || !text.trim()) {
throw new Error("Message must be non-empty for Telegram sends");
}
const htmlText = markdownToTelegramHtml(text);
const textMode = opts.textMode ?? "markdown";
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
const textParams = hasThreadParams
? {
parse_mode: "HTML" as const,
@@ -335,11 +338,12 @@ export async function sendMessageTelegram(
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
}
: undefined;
const fallbackText = opts.plainText ?? text;
return await request(
() =>
plainParams
? api.sendMessage(chatId, text, plainParams)
: api.sendMessage(chatId, text),
? api.sendMessage(chatId, fallbackText, plainParams)
: api.sendMessage(chatId, fallbackText),
"message-plain",
).catch((err2) => {
throw wrapChatNotFound(err2);