diff --git a/index.ts b/index.ts
index bc8891b..6a0f675 100644
--- a/index.ts
+++ b/index.ts
@@ -534,6 +534,450 @@ function getModelMenuItems(
: state.allModels;
}
+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 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 inFence = false;
+ for (const rawLine of lines) {
+ const line = rawLine ?? "";
+ if (isFencedCodeStart(line)) {
+ inFence = !inFence;
+ continue;
+ }
+ if (inFence) {
+ output.push(line);
+ 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");
+}
+
+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 = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2");
+ result = result.replace(/~~(.+?)~~/g, "$1");
+ result = result.replace(/(\*\*|__)(.+?)\1/g, "$2");
+ result = result.replace(/(\*|_)(.+?)\1/g, "$2");
+ 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 " ".repeat(Math.max(0, Math.min(12, level * 2)));
+}
+
+function isMarkdownTableSeparator(line: string): boolean {
+ return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
+}
+
+function parseMarkdownTableRow(line: string): string[] {
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
+ return trimmed
+ .split("|")
+ .map((cell) => stripInlineMarkdownToPlainText(cell.trim()));
+}
+
+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 renderMarkdownQuoteBlock(lines: string[]): string[] {
+ const inner = lines.map((line) => line.replace(/^\s*>\s?/, "")).join("\n");
+ const rendered = renderMarkdownTextLines(inner).join("\n");
+ return rendered.length > 0 ? [`${rendered}
`] : [];
+}
+
+function isFencedCodeStart(line: string): boolean {
+ return /^\s*```/.test(line);
+}
+
+function isIndentedCodeLine(line: string): boolean {
+ return /^(?:\t| {4,})/.test(line);
+}
+
+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] ?? "";
+ if (isFencedCodeStart(line)) {
+ const language = line.trim().slice(3).trim() || undefined;
+ index += 1;
+ const codeLines: string[] = [];
+ while (index < lines.length && !isFencedCodeStart(lines[index] ?? "")) {
+ codeLines.push(lines[index] ?? "");
+ index += 1;
+ }
+ if (index < lines.length) {
+ index += 1;
+ }
+ renderedBlocks.push(
+ ...renderMarkdownCodeBlock(codeLines.join("\n"), language),
+ );
+ 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 (isIndentedCodeLine(line)) {
+ const codeLines: string[] = [];
+ while (index < lines.length && isIndentedCodeLine(lines[index] ?? "")) {
+ const rawLine = lines[index] ?? "";
+ codeLines.push(
+ rawLine.startsWith("\t") ? rawLine.slice(1) : rawLine.slice(4),
+ );
+ 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) ||
+ isIndentedCodeLine(current) ||
+ /^\s*>/.test(current)
+ )
+ break;
+ if (current.includes("|") && isMarkdownTableSeparator(following)) break;
+ textLines.push(current);
+ index += 1;
+ }
+ renderedBlocks.push(...renderMarkdownTextLines(textLines.join("\n")));
+ }
+ const chunks: string[] = [];
+ let current = "";
+ for (const block of renderedBlocks) {
+ const candidate = current.length === 0 ? block : `${current}\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;
+}
+
+type TelegramRenderMode = "plain" | "markdown" | "html";
+
+interface TelegramRenderedChunk {
+ text: string;
+ parseMode?: "HTML";
+}
+
+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",
+ }));
+}
+
function chunkParagraphs(text: string): string[] {
if (text.length <= MAX_MESSAGE_LENGTH) return [text];
@@ -832,12 +1276,13 @@ export default function (pi: ExtensionAPI) {
const state = previewState;
if (!state) return;
state.flushTimer = undefined;
- const text = state.pendingText.trim();
- if (!text || text === state.lastSentText) return;
+ const rawText = state.pendingText.trim();
+ const previewText = renderMarkdownPreviewText(rawText).trim();
+ if (!previewText || previewText === state.lastSentText) return;
const truncated =
- text.length > MAX_MESSAGE_LENGTH
- ? text.slice(0, MAX_MESSAGE_LENGTH)
- : text;
+ previewText.length > MAX_MESSAGE_LENGTH
+ ? previewText.slice(0, MAX_MESSAGE_LENGTH)
+ : previewText;
if (draftSupport !== "unsupported") {
const draftId = state.draftId ?? allocateDraftId();
@@ -904,26 +1349,86 @@ export default function (pi: ExtensionAPI) {
return state.messageId !== undefined;
}
+ async function finalizeMarkdownPreview(
+ chatId: number,
+ markdown: string,
+ ): Promise {
+ const state = previewState;
+ if (!state) return false;
+ await flushPreview(chatId);
+ const chunks = renderTelegramMessage(markdown, { mode: "markdown" });
+ if (chunks.length === 0) {
+ await clearPreview(chatId);
+ return false;
+ }
+ if (state.mode === "draft") {
+ for (const chunk of chunks) {
+ await callTelegram("sendMessage", {
+ chat_id: chatId,
+ text: chunk.text,
+ parse_mode: chunk.parseMode,
+ });
+ }
+ await clearPreview(chatId);
+ return true;
+ }
+ if (state.messageId !== undefined) {
+ const [firstChunk, ...remainingChunks] = chunks;
+ await callTelegram("editMessageText", {
+ chat_id: chatId,
+ message_id: state.messageId,
+ text: firstChunk.text,
+ parse_mode: firstChunk.parseMode,
+ });
+ for (const chunk of remainingChunks) {
+ await callTelegram("sendMessage", {
+ chat_id: chatId,
+ text: chunk.text,
+ parse_mode: chunk.parseMode,
+ });
+ }
+ previewState = undefined;
+ return true;
+ }
+ return false;
+ }
+
async function sendTextReply(
chatId: number,
_replyToMessageId: number,
text: string,
options?: { parseMode?: "HTML" },
): Promise {
- if (options?.parseMode) {
- const sent = await callTelegram("sendMessage", {
- chat_id: chatId,
- text,
- parse_mode: options.parseMode,
- });
- return sent.message_id;
- }
- const chunks = chunkParagraphs(text);
+ const chunks = renderTelegramMessage(text, {
+ mode: options?.parseMode === "HTML" ? "html" : "plain",
+ });
let lastMessageId: number | undefined;
for (const chunk of chunks) {
const sent = await callTelegram("sendMessage", {
chat_id: chatId,
- text: chunk,
+ text: chunk.text,
+ parse_mode: chunk.parseMode,
+ });
+ lastMessageId = sent.message_id;
+ }
+ return lastMessageId;
+ }
+
+ async function sendMarkdownReply(
+ chatId: number,
+ replyToMessageId: number,
+ markdown: string,
+ ): Promise {
+ const chunks = renderTelegramMessage(markdown, { mode: "markdown" });
+ if (chunks.length === 0) {
+ return sendTextReply(chatId, replyToMessageId, markdown);
+ }
+ let lastMessageId: number | undefined;
+ for (const chunk of chunks) {
+ const sent = await callTelegram("sendMessage", {
+ chat_id: chatId,
+ text: chunk.text,
+ parse_mode: chunk.parseMode,
});
lastMessageId = sent.message_id;
}
@@ -1383,11 +1888,14 @@ export default function (pi: ExtensionAPI) {
ctx: ExtensionContext,
): Promise {
state.mode = "status";
+ const [rendered] = renderTelegramMessage(buildStatusHtml(ctx), {
+ mode: "html",
+ });
await callTelegram("editMessageText", {
chat_id: state.chatId,
message_id: state.messageId,
- text: buildStatusHtml(ctx),
- parse_mode: "HTML",
+ text: rendered?.text ?? "",
+ parse_mode: rendered?.parseMode,
reply_markup: buildStatusReplyMarkup(ctx),
});
}
@@ -1406,10 +1914,13 @@ export default function (pi: ExtensionAPI) {
return;
}
const state = await getModelMenuState(chatId, ctx);
+ const [rendered] = renderTelegramMessage(buildStatusHtml(ctx), {
+ mode: "html",
+ });
const sent = await callTelegram("sendMessage", {
chat_id: chatId,
- text: buildStatusHtml(ctx),
- parse_mode: "HTML",
+ text: rendered?.text ?? "",
+ parse_mode: rendered?.parseMode,
reply_to_message_id: replyToMessageId,
reply_markup: buildStatusReplyMarkup(ctx),
});
@@ -2160,7 +2671,12 @@ export default function (pi: ExtensionAPI) {
(previewState.pendingText.trim().length > 0 ||
previewState.lastSentText.trim().length > 0)
) {
- await finalizePreview(activeTelegramTurn.chatId);
+ const previousText = previewState.pendingText.trim();
+ if (previousText.length > 0) {
+ await finalizeMarkdownPreview(activeTelegramTurn.chatId, previousText);
+ } else {
+ await finalizePreview(activeTelegramTurn.chatId);
+ }
}
previewState = {
mode: draftSupport === "unsupported" ? "message" : "draft",
@@ -2210,21 +2726,15 @@ export default function (pi: ExtensionAPI) {
if (previewState) {
previewState.pendingText = finalText ?? previewState.pendingText;
}
-
- if (finalText && finalText.length <= MAX_MESSAGE_LENGTH) {
- const finalized = await finalizePreview(turn.chatId);
- if (!finalized && turn.queuedAttachments.length > 0 && !finalText) {
- await sendTextReply(
- turn.chatId,
- turn.replyToMessageId,
- "Attached requested file(s).",
- );
+ if (finalText) {
+ const finalized = await finalizeMarkdownPreview(turn.chatId, finalText);
+ if (!finalized) {
+ await clearPreview(turn.chatId);
+ await sendMarkdownReply(turn.chatId, turn.replyToMessageId, finalText);
}
} else {
await clearPreview(turn.chatId);
- if (finalText) {
- await sendTextReply(turn.chatId, turn.replyToMessageId, finalText);
- } else if (turn.queuedAttachments.length > 0) {
+ if (turn.queuedAttachments.length > 0) {
await sendTextReply(
turn.chatId,
turn.replyToMessageId,