/**
* Telegram preview and markdown rendering helpers
* Converts assistant output into Telegram-safe plain text and HTML chunks with chunk-boundary handling
*/
export const MAX_MESSAGE_LENGTH = 4096;
export type DisplayMode = "full" | "compact" | "text";
export type TelegramAssistantDisplayBlock =
| { type: "text"; text: string }
| { type: "thinking"; text: string }
| { type: "tool_call"; name: string; argsText?: string }
| {
type: "tool_result";
text: string;
toolName?: string;
detailsText?: string;
isError?: boolean;
}
| { type: "unknown"; label: string; text: string };
function truncateDisplayText(
text: string,
maxLength: number,
): { text: string; truncated: boolean } {
if (text.length <= maxLength) {
return { text, truncated: false };
}
return {
text: `${text.slice(0, Math.max(0, maxLength - 1))}…`,
truncated: true,
};
}
function renderMarkdownQuote(text: string): string {
return text
.split(/\r?\n/)
.map((line) => `> ${line.length > 0 ? line : "\u00A0"}`)
.join("\n");
}
function renderToolArgsMarkdown(argsText: string): string {
const trimmed = argsText.trim();
if (trimmed.length === 0) return "";
if (trimmed.includes("\n") || trimmed.length > 120) {
return `\n\n\`\`\`json\n${trimmed}\n\`\`\``;
}
return ` ${"`"}${trimmed}${"`"}`;
}
const COMPACT_TRUNCATE = 500;
const COMPACT_TRUNCATION_NOTICE = "[compact trace truncated; use /trace for full]";
function renderMarkdownFenceBlock(language: string, text: string): string {
const fence = text.includes("```") && !text.includes("~~~") ? "~~~" : "```";
return `${fence}${language}\n${text}\n${fence}`;
}
function normalizeTraceOutputText(text: string): string {
return text
.replace(/\r\n/g, "\n")
.replace(/\r(?!\n)/g, "\n")
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
}
function renderTracedTextSection(
label: string,
text: string,
mode: DisplayMode,
language = "text",
): string | undefined {
const normalized = normalizeTraceOutputText(text).trimEnd();
if (!normalized) return undefined;
const truncated =
mode === "compact"
? truncateDisplayText(normalized, COMPACT_TRUNCATE)
: { text: normalized, truncated: false };
const notice = truncated.truncated ? `\n${COMPACT_TRUNCATION_NOTICE}` : "";
return `**${label}**\n${renderMarkdownFenceBlock(language, truncated.text)}${notice}`;
}
export function renderBlockMessage(
block: TelegramAssistantDisplayBlock,
mode: DisplayMode,
): string | undefined {
if (block.type === "text") return undefined;
if (block.type === "thinking") {
const trimmed = block.text.trim();
if (!trimmed) return undefined;
const truncated =
mode === "compact"
? truncateDisplayText(trimmed, COMPACT_TRUNCATE)
: { text: trimmed, truncated: false };
const content = truncated.truncated
? `${truncated.text}\n${COMPACT_TRUNCATION_NOTICE}`
: truncated.text;
return `**Thinking**\n${renderMarkdownQuote(content)}`;
}
if (block.type === "tool_call") {
const argsText = block.argsText ?? "";
const truncated =
mode === "compact"
? truncateDisplayText(argsText, COMPACT_TRUNCATE)
: { text: argsText, truncated: false };
return `**Tool call** \`${block.name}\`${truncated.text ? renderToolArgsMarkdown(truncated.text) : ""}${truncated.truncated ? `\n\n${COMPACT_TRUNCATION_NOTICE}` : ""}`;
}
if (block.type === "tool_result") {
if (mode === "text") return undefined;
const sections: string[] = [];
const header = block.toolName
? `**Tool result** \`${block.toolName}\`${block.isError ? " (error)" : ""}`
: `**Tool result**${block.isError ? " (error)" : ""}`;
sections.push(header);
const output = renderTracedTextSection(
block.toolName === "bash" ? "output" : "result",
block.text,
mode,
"text",
);
if (output) sections.push(output);
const details = block.detailsText
? renderTracedTextSection("details", block.detailsText, mode, "json")
: undefined;
if (details) sections.push(details);
return sections.length > 1 ? sections.join("\n\n") : undefined;
}
if (block.type === "unknown") {
if (mode === "text") return undefined;
const content = renderTracedTextSection("content", block.text, mode, "json");
return content ? `**Trace block** \`${block.label}\`\n\n${content}` : undefined;
}
}
// --- Escaping ---
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(//g, ">");
}
// --- Plain Preview Rendering ---
function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
if (line.length <= maxLength) return [line];
const words = line.split(/\s+/).filter(Boolean);
if (words.length === 0) return [line];
const parts: string[] = [];
let current = "";
for (const word of words) {
const candidate = current.length === 0 ? word : `${current} ${word}`;
if (candidate.length <= maxLength) {
current = candidate;
continue;
}
if (current.length > 0) {
parts.push(current);
current = "";
}
if (word.length <= maxLength) {
current = word;
continue;
}
for (let i = 0; i < word.length; i += maxLength) {
parts.push(word.slice(i, i + maxLength));
}
}
if (current.length > 0) {
parts.push(current);
}
return parts.length > 0 ? parts : [line];
}
function stripInlineMarkdownToPlainText(text: string): string {
let result = text;
result = result.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, "$1");
result = result.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1");
result = result.replace(/<((?:https?:\/\/|mailto:)[^>]+)>/g, "$1");
result = result.replace(/`([^`\n]+)`/g, "$1");
result = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2");
result = result.replace(/(\*\*|__)(.+?)\1/g, "$2");
result = result.replace(/(\*|_)(.+?)\1/g, "$2");
result = result.replace(/~~(.+?)~~/g, "$1");
result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
return result;
}
function isMarkdownTableSeparator(line: string): boolean {
return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
}
function parseMarkdownFence(
line: string,
): { marker: "`" | "~"; length: number; info?: string } | undefined {
const match = line.match(/^\s*([`~]{3,})(.*)$/);
if (!match) return undefined;
const fence = match[1] ?? "";
const marker = fence[0];
if ((marker !== "`" && marker !== "~") || /[^`~]/.test(fence)) {
return undefined;
}
if (!fence.split("").every((char) => char === marker)) return undefined;
return {
marker,
length: fence.length,
info: (match[2] ?? "").trim() || undefined,
};
}
function isFencedCodeStart(line: string): boolean {
return parseMarkdownFence(line) !== undefined;
}
function isMatchingMarkdownFence(
line: string,
fence: { marker: "`" | "~"; length: number },
): boolean {
const match = line.match(/^\s*([`~]{3,})\s*$/);
if (!match) return false;
const candidate = match[1] ?? "";
return (
candidate.length >= fence.length &&
candidate[0] === fence.marker &&
candidate.split("").every((char) => char === fence.marker)
);
}
function isIndentedCodeLine(line: string): boolean {
return /^(?:\t| {4,})/.test(line);
}
function isIndentedMarkdownStructureLine(line: string): boolean {
const trimmed = line.trimStart();
return (
/^(?:[-*+]|\d+\.)\s+\[([ xX])\]\s+/.test(trimmed) ||
/^(?:[-*+]|\d+\.)\s+/.test(trimmed) ||
/^>\s?/.test(trimmed) ||
/^#{1,6}\s+/.test(trimmed) ||
parseMarkdownFence(trimmed) !== undefined
);
}
function canStartIndentedCodeBlock(lines: string[], index: number): boolean {
const line = lines[index] ?? "";
if (!isIndentedCodeLine(line)) return false;
if (isIndentedMarkdownStructureLine(line)) return false;
if (index === 0) return true;
return (lines[index - 1] ?? "").trim().length === 0;
}
function stripIndentedCodePrefix(line: string): string {
if (line.startsWith("\t")) return line.slice(1);
if (line.startsWith(" ")) return line.slice(4);
return line;
}
export function renderMarkdownPreviewText(markdown: string): string {
const normalized = markdown.replace(/\r\n/g, "\n").trim();
if (normalized.length === 0) return "";
const output: string[] = [];
const lines = normalized.split("\n");
let activeFence: { marker: "`" | "~"; length: number } | undefined;
for (const rawLine of lines) {
const line = rawLine ?? "";
const fence = parseMarkdownFence(line);
if (activeFence) {
if (fence && isMatchingMarkdownFence(line, activeFence)) {
activeFence = undefined;
continue;
}
if (line.trim().length === 0) {
if (output.at(-1) !== "") output.push("");
continue;
}
output.push(line);
continue;
}
if (fence) {
activeFence = { marker: fence.marker, length: fence.length };
continue;
}
if (line.trim().length === 0) {
if (output.at(-1) !== "") output.push("");
continue;
}
if (isMarkdownTableSeparator(line)) {
continue;
}
const heading = line.match(/^\s*#{1,6}\s+(.+)$/);
if (heading) {
output.push(stripInlineMarkdownToPlainText(heading[1] ?? ""));
continue;
}
const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
if (task) {
const indent = " ".repeat((task[1] ?? "").length);
const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
output.push(
`${indent}${marker} ${stripInlineMarkdownToPlainText(task[4] ?? "")}`,
);
continue;
}
const bullet = line.match(/^(\s*)[-*+]\s+(.+)$/);
if (bullet) {
output.push(
`${" ".repeat((bullet[1] ?? "").length)}- ${stripInlineMarkdownToPlainText(bullet[2] ?? "")}`,
);
continue;
}
const numbered = line.match(/^(\s*\d+\.)\s+(.+)$/);
if (numbered) {
output.push(
`${numbered[1]} ${stripInlineMarkdownToPlainText(numbered[2] ?? "")}`,
);
continue;
}
const quote = line.match(/^\s*>\s?(.+)$/);
if (quote) {
output.push(`> ${stripInlineMarkdownToPlainText(quote[1] ?? "")}`);
continue;
}
if (/^\s*([-*_]\s*){3,}\s*$/.test(line)) {
output.push("────────");
continue;
}
output.push(stripInlineMarkdownToPlainText(line));
}
return output.join("\n");
}
// --- Rich Markdown Rendering ---
function renderDelimitedInlineStyle(
text: string,
delimiter: string,
render: (content: string) => string,
): string {
const escapedDelimiter = delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`(^|[^\\p{L}\\p{N}\\\\])(${escapedDelimiter})(?=\\S)(.+?)(?<=\\S)\\2(?=[^\\p{L}\\p{N}]|$)`,
"gu",
);
return text.replace(
pattern,
(_match, prefix: string, _wrapped: string, content: string) => {
return `${prefix}${render(content)}`;
},
);
}
function renderInlineMarkdown(text: string): string {
const tokens: string[] = [];
const makeToken = (html: string): string => {
const token = `\uE000${tokens.length}\uE001`;
tokens.push(html);
return token;
};
let result = text;
result = result.replace(
/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g,
(_match, alt: string, url: string) => {
const label = alt.trim().length > 0 ? alt : url;
return makeToken(`${escapeHtml(label)}`);
},
);
result = result.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(_match, label: string, url: string) => {
return makeToken(`${escapeHtml(label)}`);
},
);
result = result.replace(
/<((?:https?:\/\/|mailto:)[^>]+)>/g,
(_match, url: string) => {
return makeToken(`${escapeHtml(url)}`);
},
);
result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
return makeToken(`${escapeHtml(code)}`);
});
result = escapeHtml(result);
result = renderDelimitedInlineStyle(result, "***", (content) => {
return `${content}`;
});
result = renderDelimitedInlineStyle(result, "___", (content) => {
return `${content}`;
});
result = renderDelimitedInlineStyle(result, "~~", (content) => {
return `${content}`;
});
result = renderDelimitedInlineStyle(result, "**", (content) => {
return `${content}`;
});
result = renderDelimitedInlineStyle(result, "__", (content) => {
return `${content}`;
});
result = renderDelimitedInlineStyle(result, "*", (content) => {
return `${content}`;
});
result = renderDelimitedInlineStyle(result, "_", (content) => {
return `${content}`;
});
result = result.replace(
/(^|[\s>(])(\[(?: |x|X)\])(?=($|[\s<).,:;!?]))/g,
(_match, prefix: string, checkbox: string) => {
const normalized = checkbox.toLowerCase() === "[x]" ? "[x]" : "[ ]";
return `${prefix}${normalized}`;
},
);
result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
return result.replace(
/\uE000(\d+)\uE001/g,
(_match, index: string) => tokens[Number(index)] ?? "",
);
}
function buildListIndent(level: number): string {
return "\u00A0".repeat(Math.max(0, level) * 2);
}
function parseMarkdownTableRow(line: string): string[] {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
return trimmed
.split("|")
.map((cell) => stripInlineMarkdownToPlainText(cell.trim()));
}
function parseMarkdownQuoteLine(
line: string,
): { depth: number; content: string } | undefined {
const match = line.match(/^\s*((?:>\s*)+)(.*)$/);
if (!match) return undefined;
const markers = match[1] ?? "";
const depth = (markers.match(/>/g) ?? []).length;
return {
depth,
content: match[2] ?? "",
};
}
function renderMarkdownTextLines(block: string): string[] {
const rendered: string[] = [];
const lines = block.split("\n");
for (const line of lines) {
if (line.trim().length === 0) continue;
const pieces = splitPlainMarkdownLine(line);
for (const piece of pieces) {
const heading = piece.match(/^(\s*)#{1,6}\s+(.+)$/);
if (heading) {
rendered.push(
`${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}${renderInlineMarkdown(heading[2] ?? "")}`,
);
continue;
}
const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
if (task) {
const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
rendered.push(
`${indent}${marker} ${renderInlineMarkdown(task[4] ?? "")}`,
);
continue;
}
const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/);
if (bullet) {
const indent = buildListIndent(
Math.floor((bullet[1] ?? "").length / 2),
);
rendered.push(
`${indent}- ${renderInlineMarkdown(bullet[2] ?? "")}`,
);
continue;
}
const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/);
if (numbered) {
const indent = buildListIndent(
Math.floor((numbered[1] ?? "").length / 2),
);
rendered.push(
`${indent}${numbered[2]}. ${renderInlineMarkdown(numbered[3] ?? "")}`,
);
continue;
}
const quote = piece.match(/^>\s?(.+)$/);
if (quote) {
rendered.push(
`
${renderInlineMarkdown(quote[1] ?? "")}`, ); continue; } const trimmed = piece.trim(); if (/^([-*_]\s*){3,}$/.test(trimmed)) { rendered.push("────────────"); continue; } rendered.push(renderInlineMarkdown(piece)); } } return rendered; } function renderMarkdownCodeBlock(code: string, language?: string): string[] { const open = language ? `
`
: "";
const close = "
";
const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
const chunks: string[] = [];
let current = "";
const pushCurrent = (): void => {
if (current.length === 0) return;
chunks.push(`${open}${current}${close}`);
current = "";
};
const appendEscapedLine = (escapedLine: string): void => {
if (escapedLine.length <= maxContentLength) {
const candidate =
current.length === 0 ? escapedLine : `${current}\n${escapedLine}`;
if (candidate.length <= maxContentLength) {
current = candidate;
return;
}
pushCurrent();
current = escapedLine;
return;
}
pushCurrent();
for (let i = 0; i < escapedLine.length; i += maxContentLength) {
chunks.push(
`${open}${escapedLine.slice(i, i + maxContentLength)}${close}`,
);
}
};
for (const line of code.split("\n")) {
appendEscapedLine(escapeHtml(line));
}
pushCurrent();
return chunks.length > 0 ? chunks : [`${open}${close}`];
}
function renderMarkdownTableBlock(lines: string[]): string[] {
const rows = lines.map(parseMarkdownTableRow);
const columnCount = Math.max(...rows.map((row) => row.length), 0);
const normalizedRows = rows.map((row) => {
const next = [...row];
while (next.length < columnCount) {
next.push("");
}
return next;
});
const widths = Array.from({ length: columnCount }, (_, columnIndex) => {
return Math.max(
3,
...normalizedRows.map((row) => (row[columnIndex] ?? "").length),
);
});
const formatRow = (row: string[]): string => {
return row
.map((cell, columnIndex) => (cell ?? "").padEnd(widths[columnIndex] ?? 3))
.join(" | ");
};
const separator = widths.map((width) => "-".repeat(width)).join(" | ");
const [header, ...body] = normalizedRows;
const tableLines = [
formatRow(header ?? []),
separator,
...body.map(formatRow),
];
return renderMarkdownCodeBlock(tableLines.join("\n"), "markdown");
}
function chunkRenderedHtmlLines(
lines: string[],
wrapper?: { open: string; close: string },
): string[] {
if (lines.length === 0) return [];
const open = wrapper?.open ?? "";
const close = wrapper?.close ?? "";
const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
const chunks: string[] = [];
let current = "";
const pushCurrent = (): void => {
if (current.length === 0) return;
chunks.push(`${open}${current}${close}`);
current = "";
};
for (const line of lines) {
const candidate = current.length === 0 ? line : `${current}\n${line}`;
if (candidate.length <= maxContentLength) {
current = candidate;
continue;
}
pushCurrent();
if (line.length <= maxContentLength) {
current = line;
continue;
}
for (let i = 0; i < line.length; i += maxContentLength) {
chunks.push(`${open}${line.slice(i, i + maxContentLength)}${close}`);
}
}
pushCurrent();
return chunks;
}
function renderMarkdownTextBlock(block: string): string[] {
return chunkRenderedHtmlLines(renderMarkdownTextLines(block));
}
function renderMarkdownQuoteBlock(lines: string[]): string[] {
const inner = lines
.map((line) => {
const parsed = parseMarkdownQuoteLine(line);
if (!parsed) return line;
const nestedIndent = "\u00A0".repeat(Math.max(0, parsed.depth - 1) * 2);
return `${nestedIndent}${parsed.content}`;
})
.join("\n");
return chunkRenderedHtmlLines(renderMarkdownTextLines(inner), {
open: "",
close: "
",
});
}
function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
const normalized = markdown.replace(/\r\n/g, "\n").trim();
if (normalized.length === 0) return [];
const renderedBlocks: string[] = [];
const lines = normalized.split("\n");
let index = 0;
while (index < lines.length) {
const line = lines[index] ?? "";
const nextLine = lines[index + 1] ?? "";
const fence = parseMarkdownFence(line);
if (fence) {
index += 1;
const codeLines: string[] = [];
while (
index < lines.length &&
!isMatchingMarkdownFence(lines[index] ?? "", fence)
) {
codeLines.push(lines[index] ?? "");
index += 1;
}
if (index < lines.length) {
index += 1;
}
renderedBlocks.push(
...renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
);
while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
index += 1;
}
continue;
}
if (line.trim().length === 0) {
index += 1;
continue;
}
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
const tableLines: string[] = [line];
index += 2;
while (index < lines.length) {
const tableLine = lines[index] ?? "";
if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
break;
}
tableLines.push(tableLine);
index += 1;
}
renderedBlocks.push(...renderMarkdownTableBlock(tableLines));
continue;
}
if (canStartIndentedCodeBlock(lines, index)) {
const codeLines: string[] = [];
while (index < lines.length) {
const rawLine = lines[index] ?? "";
if (rawLine.trim().length === 0) {
codeLines.push("");
index += 1;
continue;
}
if (!isIndentedCodeLine(rawLine)) break;
codeLines.push(stripIndentedCodePrefix(rawLine));
index += 1;
}
renderedBlocks.push(...renderMarkdownCodeBlock(codeLines.join("\n")));
continue;
}
if (/^\s*>/.test(line)) {
const quoteLines: string[] = [];
while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
quoteLines.push(lines[index] ?? "");
index += 1;
}
renderedBlocks.push(...renderMarkdownQuoteBlock(quoteLines));
continue;
}
const textLines: string[] = [];
while (index < lines.length) {
const current = lines[index] ?? "";
const following = lines[index + 1] ?? "";
if (current.trim().length === 0) break;
if (
isFencedCodeStart(current) ||
canStartIndentedCodeBlock(lines, index) ||
/^\s*>/.test(current)
)
break;
if (current.includes("|") && isMarkdownTableSeparator(following)) break;
textLines.push(current);
index += 1;
}
renderedBlocks.push(...renderMarkdownTextBlock(textLines.join("\n")));
}
const chunks: string[] = [];
let current = "";
for (const block of renderedBlocks) {
const candidate = current.length === 0 ? block : `${current}\n\n${block}`;
if (candidate.length <= MAX_MESSAGE_LENGTH) {
current = candidate;
continue;
}
if (current.length > 0) {
chunks.push(current);
current = "";
}
if (block.length <= MAX_MESSAGE_LENGTH) {
current = block;
continue;
}
for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) {
chunks.push(block.slice(i, i + MAX_MESSAGE_LENGTH));
}
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
// --- Unified Telegram Rendering ---
export type TelegramRenderMode = "plain" | "markdown" | "html";
export interface TelegramRenderedChunk {
text: string;
parseMode?: "HTML";
}
function chunkParagraphs(text: string): string[] {
if (text.length <= MAX_MESSAGE_LENGTH) return [text];
const normalized = text.replace(/\r\n/g, "\n");
const paragraphs = normalized.split(/\n\n+/);
const chunks: string[] = [];
let current = "";
const flushCurrent = (): void => {
if (current.trim().length > 0) chunks.push(current);
current = "";
};
const splitLongBlock = (block: string): string[] => {
if (block.length <= MAX_MESSAGE_LENGTH) return [block];
const lines = block.split("\n");
const lineChunks: string[] = [];
let lineCurrent = "";
for (const line of lines) {
const candidate =
lineCurrent.length === 0 ? line : `${lineCurrent}\n${line}`;
if (candidate.length <= MAX_MESSAGE_LENGTH) {
lineCurrent = candidate;
continue;
}
if (lineCurrent.length > 0) {
lineChunks.push(lineCurrent);
lineCurrent = "";
}
if (line.length <= MAX_MESSAGE_LENGTH) {
lineCurrent = line;
continue;
}
for (let i = 0; i < line.length; i += MAX_MESSAGE_LENGTH) {
lineChunks.push(line.slice(i, i + MAX_MESSAGE_LENGTH));
}
}
if (lineCurrent.length > 0) {
lineChunks.push(lineCurrent);
}
return lineChunks;
};
for (const paragraph of paragraphs) {
if (paragraph.length === 0) continue;
const parts = splitLongBlock(paragraph);
for (const part of parts) {
const candidate = current.length === 0 ? part : `${current}\n\n${part}`;
if (candidate.length <= MAX_MESSAGE_LENGTH) {
current = candidate;
} else {
flushCurrent();
current = part;
}
}
}
flushCurrent();
return chunks;
}
export function renderTelegramMessage(
text: string,
options?: { mode?: TelegramRenderMode },
): TelegramRenderedChunk[] {
const mode = options?.mode ?? "plain";
if (mode === "plain") {
return chunkParagraphs(text).map((chunk) => ({ text: chunk }));
}
if (mode === "html") {
return [{ text, parseMode: "HTML" }];
}
return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({
text: chunk,
parseMode: "HTML",
}));
}