From 26b0ad7a3c335afbe8f0b15b8c40bf6142199818 Mon Sep 17 00:00:00 2001 From: niyazm524 Date: Sat, 13 Jun 2026 20:09:21 +0300 Subject: [PATCH 1/3] Use Rich Messages for Telegram bridge --- README.md | 2 +- src/server/telegramThreadBridge.ts | 237 +++--- src/utils/telegramMarkdown.test.ts | 53 ++ src/utils/telegramMarkdown.ts | 1088 ++++++++++++++++++++++++++++ 4 files changed, 1282 insertions(+), 98 deletions(-) create mode 100644 src/utils/telegramMarkdown.test.ts create mode 100644 src/utils/telegramMarkdown.ts diff --git a/README.md b/README.md index b5b2fe519..f120129bd 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Bot commands: - `/whoami` show your Telegram user/chat IDs and authorization state - `/help` show command reference -Outgoing assistant messages are sent with Telegram `parse_mode=HTML` for formatting, with automatic plain-text fallback if HTML delivery fails. +Outgoing assistant and bridge status messages are sent with Telegram Rich Messages using markdown input, so headings, lists, tables, quotes, and code blocks render natively. Local file references are degraded to bold text labels, and the bridge falls back to plain `sendMessage` text if Rich Message delivery fails. --- diff --git a/src/server/telegramThreadBridge.ts b/src/server/telegramThreadBridge.ts index 7c16ce558..3a5ff596f 100644 --- a/src/server/telegramThreadBridge.ts +++ b/src/server/telegramThreadBridge.ts @@ -1,5 +1,7 @@ import { basename } from 'node:path' +import { buildTelegramMarkdownChunks } from '../utils/telegramMarkdown.js' + type TelegramUpdate = { update_id?: number message?: { @@ -50,7 +52,8 @@ type TelegramBotCommand = { description: string } -const TELEGRAM_MESSAGE_MAX_LENGTH = 3500 +const TELEGRAM_PLAIN_MESSAGE_MAX_LENGTH = 3500 +const TELEGRAM_RICH_MESSAGE_MAX_LENGTH = 12000 const TELEGRAM_BOT_COMMANDS: TelegramBotCommand[] = [ { command: 'start', description: 'Show quick start and thread picker' }, { command: 'threads', description: 'List recent threads to connect' }, @@ -113,61 +116,7 @@ function normalizeTelegramAllowlist(values: unknown): NormalizedTelegramAllowlis return { allowAllUsers, allowedUserIds } } -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') -} - -function renderMarkdownInlineToTelegramHtml(value: string): string { - let rendered = escapeHtml(value) - rendered = rendered.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1') - rendered = rendered.replace(/`([^`\n]+)`/g, '$1') - rendered = rendered.replace(/\*\*([^*\n][^*\n]*?)\*\*/g, '$1') - rendered = rendered.replace(/__([^_\n][^_\n]*?)__/g, '$1') - rendered = rendered.replace(/\*([^*\n][^*\n]*?)\*/g, '$1') - rendered = rendered.replace(/_([^_\n][^_\n]*?)_/g, '$1') - rendered = rendered.replace(/^(#{1,6})\s+(.+)$/gm, (_match, _hashes, content: string) => `${content}`) - return rendered -} - -function renderMarkdownToTelegramHtml(markdown: string): string { - const normalized = markdown.replace(/\r\n/g, '\n') - const fencedCodeRegex = /```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g - let cursor = 0 - const parts: string[] = [] - let match = fencedCodeRegex.exec(normalized) - - while (match) { - const [fullMatch, lang, code] = match - const matchIndex = match.index - const before = normalized.slice(cursor, matchIndex) - if (before) { - parts.push(renderMarkdownInlineToTelegramHtml(before)) - } - - const escapedCode = escapeHtml((code ?? '').replace(/\n+$/g, '')) - const escapedLang = typeof lang === 'string' ? escapeHtml(lang) : '' - if (escapedLang) { - parts.push(`
${escapedCode}
`) - } else { - parts.push(`
${escapedCode}
`) - } - - cursor = matchIndex + fullMatch.length - match = fencedCodeRegex.exec(normalized) - } - - const tail = normalized.slice(cursor) - if (tail) { - parts.push(renderMarkdownInlineToTelegramHtml(tail)) - } - - return parts.join('') -} - -function splitTelegramText(text: string, maxLength = TELEGRAM_MESSAGE_MAX_LENGTH): string[] { +function splitTelegramText(text: string, maxLength = TELEGRAM_PLAIN_MESSAGE_MAX_LENGTH): string[] { const normalized = text.replace(/\r\n/g, '\n').trim() if (!normalized) return [] if (normalized.length <= maxLength) return [normalized] @@ -196,6 +145,10 @@ function splitTelegramText(text: string, maxLength = TELEGRAM_MESSAGE_MAX_LENGTH return chunks } +function formatBooleanLabel(value: boolean): string { + return value ? 'yes' : 'no' +} + export class TelegramThreadBridge { private token: string private readonly appServer: AppServerLike @@ -337,17 +290,21 @@ export class TelegramThreadBridge { text: string, options: { replyMarkup?: unknown } = {}, ): Promise { - const chunks = splitTelegramText(text) + const chunks = buildTelegramMarkdownChunks(text, TELEGRAM_RICH_MESSAGE_MAX_LENGTH) if (chunks.length === 0) return for (let index = 0; index < chunks.length; index += 1) { const chunk = chunks[index] const replyMarkup = index === 0 ? options.replyMarkup : undefined - const htmlChunk = renderMarkdownToTelegramHtml(chunk) try { - await this.sendMessageRequest(chatId, htmlChunk, { replyMarkup, parseMode: 'HTML' }) + await this.sendRichMessageRequest(chatId, chunk, { replyMarkup }) } catch { - await this.sendMessageRequest(chatId, chunk, { replyMarkup }) + const plainChunks = splitTelegramText(chunk) + for (let fallbackIndex = 0; fallbackIndex < plainChunks.length; fallbackIndex += 1) { + await this.sendMessageRequest(chatId, plainChunks[fallbackIndex], { + replyMarkup: fallbackIndex === 0 ? replyMarkup : undefined, + }) + } } } } @@ -355,18 +312,32 @@ export class TelegramThreadBridge { private async sendMessageRequest( chatId: number, text: string, - options: { replyMarkup?: unknown; parseMode?: 'HTML' } = {}, + options: { replyMarkup?: unknown } = {}, ): Promise { const payload: Record = { chat_id: chatId, text } if (options.replyMarkup) { payload.reply_markup = options.replyMarkup } - if (options.parseMode) { - payload.parse_mode = options.parseMode - } await this.callTelegramApi('sendMessage', payload) } + private async sendRichMessageRequest( + chatId: number, + markdown: string, + options: { replyMarkup?: unknown } = {}, + ): Promise { + const payload: Record = { + chat_id: chatId, + rich_message: { + markdown, + }, + } + if (options.replyMarkup) { + payload.reply_markup = options.replyMarkup + } + await this.callTelegramApi('sendRichMessage', payload) + } + private async syncBotCommands(): Promise { if (!this.token) return await this.callTelegramApi('setMyCommands', { @@ -391,7 +362,11 @@ export class TelegramThreadBridge { } private async sendOnlineMessage(chatId: number): Promise { - await this.sendTelegramMessage(chatId, 'Codex thread bridge went online.') + await this.sendTelegramMessage(chatId, [ + '# Codex Telegram Bridge', + '', + 'Bridge is online and ready.', + ].join('\n')) } private async notifyOnlineForKnownChats(): Promise { @@ -431,7 +406,11 @@ export class TelegramThreadBridge { if (text === '/newthread') { const threadId = await this.createThreadForChat(chatId) - await this.sendTelegramMessage(chatId, `Mapped to new thread: ${threadId}`) + await this.sendTelegramMessage(chatId, [ + '# Thread connected', + '', + `Created and connected a new thread: \`${threadId}\``, + ].join('\n')) return } @@ -439,22 +418,41 @@ export class TelegramThreadBridge { if (threadCommand) { const threadId = threadCommand[1] this.bindChatToThread(chatId, threadId) - await this.sendTelegramMessage(chatId, `Mapped to thread: ${threadId}`) + await this.sendTelegramMessage(chatId, [ + '# Thread connected', + '', + `Connected to existing thread: \`${threadId}\``, + ].join('\n')) return } if (text === '/current') { const threadId = this.threadIdByChatId.get(chatId) await this.sendTelegramMessage(chatId, threadId - ? `Current thread: \`${threadId}\`` - : 'No thread is connected for this chat yet. Use /threads, /newthread, or /thread .') + ? ['# Current thread', '', `Connected thread: \`${threadId}\``].join('\n') + : [ + '# Current thread', + '', + 'No thread is connected for this chat yet.', + '', + 'Use one of these commands:', + '- `/threads`', + '- `/newthread`', + '- `/thread `', + ].join('\n')) return } if (text === '/history') { const threadId = this.threadIdByChatId.get(chatId) if (!threadId) { - await this.sendTelegramMessage(chatId, 'No thread is connected for this chat yet. Use /threads or /newthread first.') + await this.sendTelegramMessage(chatId, [ + '# Recent history', + '', + 'No thread is connected for this chat yet.', + '', + 'Use `/threads` or `/newthread` first.', + ].join('\n')) return } const history = await this.readThreadHistorySummary(threadId) @@ -465,19 +463,24 @@ export class TelegramThreadBridge { if (text === '/status') { const status = this.getStatus() const mappedThreadId = this.threadIdByChatId.get(chatId) ?? 'none' + const lines = [ + '# Bridge status', + '', + `- configured: ${formatBooleanLabel(status.configured)}`, + `- active: ${formatBooleanLabel(status.active)}`, + `- mapped chats: ${String(status.mappedChats)}`, + `- mapped threads: ${String(status.mappedThreads)}`, + `- allowed users: ${String(status.allowedUsers)}`, + `- allow all users: ${formatBooleanLabel(status.allowAllUsers)}`, + `- current chat id: \`${String(chatId)}\``, + `- current thread: ${mappedThreadId === 'none' ? 'not connected' : `\`${mappedThreadId}\``}`, + ] + if (status.lastError) { + lines.push('', '## Last error', '', '```text', status.lastError, '```') + } await this.sendTelegramMessage( chatId, - [ - '**Bridge status**', - `configured: ${String(status.configured)}`, - `active: ${String(status.active)}`, - `mapped chats: ${String(status.mappedChats)}`, - `mapped threads: ${String(status.mappedThreads)}`, - `allowed users: ${String(status.allowedUsers)}`, - `allow all users: ${String(status.allowAllUsers)}`, - `chat ${String(chatId)} thread: \`${mappedThreadId}\``, - status.lastError ? `last error: ${status.lastError}` : '', - ].filter(Boolean).join('\n'), + lines.join('\n'), ) return } @@ -490,11 +493,12 @@ export class TelegramThreadBridge { await this.sendTelegramMessage( chatId, [ - '**Identity**', - `telegram user id: \`${normalizedSenderId}\``, - `chat id: \`${normalizedChatId}\``, - `authorized: ${String(this.isAllowedSender(senderId))}`, - this.allowAllUsers ? 'allowlist mode: `*`' : 'allowlist mode: explicit ids', + '# Identity', + '', + `- telegram user id: \`${normalizedSenderId}\``, + `- chat id: \`${normalizedChatId}\``, + `- authorized: ${formatBooleanLabel(this.isAllowedSender(senderId))}`, + `- allowlist mode: ${this.allowAllUsers ? '`*`' : 'explicit ids'}`, ].join('\n'), ) return @@ -513,7 +517,15 @@ export class TelegramThreadBridge { }) } catch (error) { const message = getErrorMessage(error, 'Failed to forward message to thread') - await this.sendTelegramMessage(chatId, `Forward failed: ${message}`) + await this.sendTelegramMessage(chatId, [ + '# Forward failed', + '', + 'Telegram message could not be forwarded into the Codex thread.', + '', + '```text', + message, + '```', + ].join('\n')) } } @@ -549,7 +561,11 @@ export class TelegramThreadBridge { this.bindChatToThread(chatId, threadId) await this.answerCallbackQuery(callbackId, 'Thread connected') - await this.sendTelegramMessage(chatId, `Connected to thread: ${threadId}`) + await this.sendTelegramMessage(chatId, [ + '# Thread connected', + '', + `Connected to thread: \`${threadId}\``, + ].join('\n')) const history = await this.readThreadHistorySummary(threadId) if (history) { await this.sendTelegramMessage(chatId, history) @@ -569,7 +585,13 @@ export class TelegramThreadBridge { const normalizedSenderId = typeof senderId === 'number' && Number.isFinite(senderId) ? String(Math.trunc(senderId)) : 'unknown' - return `Unauthorized sender.\n\nYour Telegram user ID: ${normalizedSenderId}\nAdd this ID to the bot allowlist before using the bridge.` + return [ + '# Access denied', + '', + `Your Telegram user ID: \`${normalizedSenderId}\``, + '', + 'Add this ID to the bot allowlist before using the bridge.', + ].join('\n') } private unauthorizedCallbackMessage(senderId: unknown): string { @@ -580,8 +602,15 @@ export class TelegramThreadBridge { } private helpMessage(): string { - const rows = TELEGRAM_BOT_COMMANDS.map((command) => `/${command.command} - ${command.description}`) - return ['**Available commands**', ...rows].join('\n') + const rows = TELEGRAM_BOT_COMMANDS.map((command) => `- \`/${command.command}\` - ${command.description}`) + return [ + '# Codex Telegram Bridge', + '', + 'Use the bot to map this chat to a Codex thread and send prompts from Telegram.', + '', + '## Commands', + ...rows, + ].join('\n') } private async answerCallbackQuery(callbackQueryId: string, text: string): Promise { @@ -594,7 +623,13 @@ export class TelegramThreadBridge { private async sendThreadPicker(chatId: number): Promise { const threads = await this.listRecentThreads() if (threads.length === 0) { - await this.sendTelegramMessage(chatId, 'No threads found. Send /newthread to create one.') + await this.sendTelegramMessage(chatId, [ + '# Connect thread', + '', + 'No recent threads were found.', + '', + 'Send `/newthread` to create one.', + ].join('\n')) return } @@ -605,7 +640,11 @@ export class TelegramThreadBridge { }, ]) - await this.sendTelegramMessage(chatId, 'Select a thread to connect:', { + await this.sendTelegramMessage(chatId, [ + '# Connect thread', + '', + 'Choose a recent Codex thread from the buttons below.', + ].join('\n'), { replyMarkup: { inline_keyboard: inlineKeyboard }, }) } @@ -743,23 +782,27 @@ export class TelegramThreadBridge { for (const block of content) { const blockRecord = asRecord(block) if (blockRecord?.type === 'text' && typeof blockRecord.text === 'string' && blockRecord.text.trim()) { - historyRows.push(`User: ${blockRecord.text.trim()}`) + historyRows.push(`## User\n${blockRecord.text.trim()}`) } } } if (type === 'agentMessage' && typeof itemRecord?.text === 'string' && itemRecord.text.trim()) { - historyRows.push(`Assistant: ${itemRecord.text.trim()}`) + historyRows.push(`## Assistant\n${itemRecord.text.trim()}`) } } } if (historyRows.length === 0) { - return 'Thread has no message history yet.' + return [ + '# Recent history', + '', + 'Thread has no message history yet.', + ].join('\n') } const tail = historyRows.slice(-12).join('\n\n') const maxLen = 3800 const summary = tail.length > maxLen ? tail.slice(tail.length - maxLen) : tail - return `Recent history:\n\n${summary}` + return `# Recent history\n\n${summary}` } } diff --git a/src/utils/telegramMarkdown.test.ts b/src/utils/telegramMarkdown.test.ts new file mode 100644 index 000000000..b176eab95 --- /dev/null +++ b/src/utils/telegramMarkdown.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { buildTelegramMarkdownChunks, renderTelegramMarkdown } from './telegramMarkdown' + +describe('renderTelegramMarkdown', () => { + it('keeps markdown structure while degrading local file links to bold text', () => { + const rendered = renderTelegramMarkdown([ + '# Release notes', + '', + '- [README](./README.md)', + '- [Docs](https://example.com/docs)', + '- `/tmp/project/src/index.ts:42`', + ].join('\n')) + + expect(rendered).toContain('# Release notes') + expect(rendered).toContain('- **README**') + expect(rendered).toContain('- [Docs](https://example.com/docs)') + expect(rendered).toContain('- **/tmp/project/src/index\\.ts:42**') + }) + + it('preserves tables and quoted sections', () => { + const rendered = renderTelegramMarkdown([ + '> review this first', + '', + '| file | status |', + '| --- | ---: |', + '| [Spec](./PROJECT_SPEC.md) | done |', + ].join('\n')) + + expect(rendered).toContain('> review this first') + expect(rendered).toContain('| file | status |') + expect(rendered).toContain('| **Spec** | done |') + }) +}) + +describe('buildTelegramMarkdownChunks', () => { + it('splits on block boundaries before falling back to plain slicing', () => { + const chunks = buildTelegramMarkdownChunks([ + '# One', + '', + 'First paragraph.', + '', + '# Two', + '', + 'Second paragraph.', + ].join('\n'), 24) + + expect(chunks).toEqual([ + '# One\n\nFirst paragraph.', + '# Two\n\nSecond paragraph.', + ]) + }) +}) diff --git a/src/utils/telegramMarkdown.ts b/src/utils/telegramMarkdown.ts new file mode 100644 index 000000000..8dd010729 --- /dev/null +++ b/src/utils/telegramMarkdown.ts @@ -0,0 +1,1088 @@ +type TableAlignment = 'left' | 'center' | 'right' | null + +type TaskListItem = { + text: string + checked: boolean +} + +type ListItem = { + paragraphs: string[] + children?: MarkdownBlock[] +} + +type MarkdownBlock = + | { kind: 'paragraph'; value: string } + | { kind: 'heading'; level: number; value: string } + | { kind: 'blockquote'; value: string } + | { kind: 'unorderedList'; items: ListItem[] } + | { kind: 'taskList'; items: TaskListItem[] } + | { kind: 'orderedList'; items: ListItem[]; start: number } + | { kind: 'table'; headers: string[]; rows: string[][]; alignments: TableAlignment[] } + | { kind: 'codeBlock'; language: string; value: string } + | { kind: 'thematicBreak' } + | { kind: 'image'; url: string; alt: string; markdown: string } + +function normalizeMarkdownText(text: string): string { + return text.replace(/\r\n/gu, '\n') +} + +function leadingIndentWidth(line: string): number { + const leadingWhitespace = line.match(/^\s*/u)?.[0] ?? '' + return leadingWhitespace.replace(/\t/gu, ' ').length +} + +function stripIndentedContent(line: string, baseIndent: number): string { + let width = 0 + let index = 0 + while (index < line.length && width < baseIndent) { + const character = line[index] + width += character === '\t' ? 4 : 1 + index += 1 + } + return line.slice(index) +} + +function isBlankMarkdownLine(line: string): boolean { + return line.trim().length === 0 +} + +function readHeading(line: string): { level: number; value: string } | null { + const match = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/u) + if (!match) return null + return { + level: match[1].length, + value: match[2].trim(), + } +} + +function readBlockquoteLine(line: string): string | null { + const match = line.match(/^\s{0,3}>\s?(.*)$/u) + if (!match) return null + return match[1] ?? '' +} + +function readUnorderedListItem(line: string): string | null { + const match = line.match(/^\s*[-*+]\s+(.+)$/u) + return match?.[1]?.trim() ?? null +} + +function readUnorderedListItemMatch(line: string): { indent: number; text: string } | null { + const match = line.match(/^(\s*)[-*+]\s+(.+)$/u) + if (!match) return null + return { + indent: leadingIndentWidth(match[1] ?? ''), + text: match[2]?.trim() ?? '', + } +} + +function readTaskListItem(line: string): TaskListItem | null { + const match = line.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/u) + if (!match) return null + return { + checked: (match[1] ?? ' ').toLowerCase() === 'x', + text: match[2]?.trim() ?? '', + } +} + +function readTaskListItemMatch(line: string): { indent: number; item: TaskListItem } | null { + const match = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.+)$/u) + if (!match) return null + return { + indent: leadingIndentWidth(match[1] ?? ''), + item: { + checked: (match[2] ?? ' ').toLowerCase() === 'x', + text: match[3]?.trim() ?? '', + }, + } +} + +function readOrderedListItemData(line: string): { indent: number; text: string; start: number } | null { + const match = line.match(/^(\s*)(\d+)[.)]\s+(.+)$/u) + if (!match) return null + return { + indent: leadingIndentWidth(match[1] ?? ''), + start: Number.parseInt(match[2] ?? '1', 10) || 1, + text: match[3]?.trim() ?? '', + } +} + +function readOrderedListItem(line: string): string | null { + return readOrderedListItemData(line)?.text ?? null +} + +function readOrderedListItemMatch(line: string): { indent: number; text: string; start: number } | null { + return readOrderedListItemData(line) +} + +function splitMarkdownTableRow(line: string): string[] | null { + const trimmed = line.trim() + if (!trimmed.includes('|')) return null + + let content = trimmed + if (content.startsWith('|')) content = content.slice(1) + if (content.endsWith('|')) content = content.slice(0, -1) + + const cells: string[] = [] + let current = '' + let codeFenceLength = 0 + + for (let index = 0; index < content.length; index += 1) { + const character = content[index] + + if (character === '\\' && content[index + 1] === '|') { + current += '|' + index += 1 + continue + } + + if (character === '`') { + let runLength = 1 + while (content[index + runLength] === '`') runLength += 1 + current += content.slice(index, index + runLength) + if (codeFenceLength === 0) codeFenceLength = runLength + else if (codeFenceLength === runLength) codeFenceLength = 0 + index += runLength - 1 + continue + } + + if (character === '|' && codeFenceLength === 0) { + cells.push(current.trim()) + current = '' + continue + } + + current += character + } + + cells.push(current.trim()) + return cells.some((cell) => cell.length > 0) ? cells : null +} + +function readTableAlignmentRow(line: string): TableAlignment[] | null { + const cells = splitMarkdownTableRow(line) + if (!cells || cells.length === 0) return null + + const alignments = cells.map((cell) => { + const trimmed = cell.replace(/\s+/gu, '') + if (!/^:?-{3,}:?$/u.test(trimmed)) return null + const startsWithColon = trimmed.startsWith(':') + const endsWithColon = trimmed.endsWith(':') + if (startsWithColon && endsWithColon) return 'center' + if (endsWithColon) return 'right' + if (startsWithColon) return 'left' + return null + }) + + return alignments.every((alignment, index) => alignment !== null || /^-+$/u.test(cells[index].replace(/\s+/gu, ''))) + ? alignments + : null +} + +function normalizeTableCells(cells: string[], width: number): string[] { + if (cells.length === width) return cells + if (cells.length > width) return cells.slice(0, width) + return [...cells, ...Array.from({ length: width - cells.length }, () => '')] +} + +function readTableBlock(lines: string[], startIndex: number): Extract | null { + if (startIndex + 1 >= lines.length) return null + + const headerLine = lines[startIndex] + const separatorLine = lines[startIndex + 1] + const headers = splitMarkdownTableRow(headerLine) + const alignments = readTableAlignmentRow(separatorLine) + if (!headers || !alignments) return null + if (headers.length !== alignments.length) return null + + const trimmedHeader = headerLine.trim() + if (!trimmedHeader.startsWith('|') && (trimmedHeader.match(/\|/gu)?.length ?? 0) < 2) return null + + const width = headers.length + const rows: string[][] = [] + let index = startIndex + 2 + while (index < lines.length) { + if (isBlankMarkdownLine(lines[index])) break + const row = splitMarkdownTableRow(lines[index]) + if (!row) break + rows.push(normalizeTableCells(row, width)) + index += 1 + } + + return { + kind: 'table', + headers: normalizeTableCells(headers, width), + rows, + alignments, + } +} + +function isParagraphBreakingLine(line: string): boolean { + return ( + isBlankMarkdownLine(line) || + readFenceStart(line) !== null || + isThematicBreakLine(line) || + readHeading(line) !== null || + readBlockquoteLine(line) !== null || + readTaskListItem(line) !== null || + readUnorderedListItem(line) !== null || + readOrderedListItem(line) !== null + ) +} + +function readListParagraph( + lines: string[], + startIndex: number, + baseIndent = -1, +): { value: string; nextIndex: number } | null { + const paragraphLines: string[] = [] + let index = startIndex + + while (index < lines.length) { + if (isParagraphBreakingLine(lines[index])) break + if (baseIndent >= 0 && leadingIndentWidth(lines[index]) <= baseIndent) break + + paragraphLines.push(baseIndent >= 0 ? stripIndentedContent(lines[index], baseIndent + 1) : lines[index]) + index += 1 + } + + const value = paragraphLines.join('\n').trim() + return value ? { value, nextIndex: index } : null +} + +function findNextNonBlankLineIndex(lines: string[], startIndex: number): number { + for (let index = startIndex; index < lines.length; index += 1) { + if (!isBlankMarkdownLine(lines[index])) return index + } + return -1 +} + +function readNestedListBlocks( + lines: string[], + startIndex: number, + parentIndent: number, + stopAtItem: ((line: string) => { indent: number; text: string } | null) | null = null, + allowLooseChildLists = false, +): { blocks: MarkdownBlock[]; nextIndex: number } | null { + const nestedLines: string[] = [] + let index = startIndex + + while (index < lines.length) { + const line = lines[index] + if (isBlankMarkdownLine(line)) { + const nextNonBlankIndex = findNextNonBlankLineIndex(lines, index + 1) + if (nextNonBlankIndex === -1) { + nestedLines.push('') + index = lines.length + break + } + const nextStopItem = stopAtItem?.(lines[nextNonBlankIndex]) + if (nextStopItem && nextStopItem.indent === parentIndent) break + if (leadingIndentWidth(lines[nextNonBlankIndex]) <= parentIndent) break + nestedLines.push('') + index += 1 + continue + } + + const stopItem = stopAtItem?.(line) + if (stopItem && stopItem.indent === parentIndent) break + + const lineIndent = leadingIndentWidth(line) + const isLooseChildList = allowLooseChildLists && ( + readTaskListItem(line) !== null || + readUnorderedListItem(line) !== null + ) + if (lineIndent <= parentIndent && !isLooseChildList) break + + nestedLines.push( + lineIndent > parentIndent + ? stripIndentedContent(line, parentIndent + 1) + : line.trimStart(), + ) + index += 1 + } + + while (nestedLines.length > 0 && isBlankMarkdownLine(nestedLines[0])) nestedLines.shift() + while (nestedLines.length > 0 && isBlankMarkdownLine(nestedLines[nestedLines.length - 1])) nestedLines.pop() + + if (nestedLines.length === 0) return null + + return { + blocks: parseTextBlocks(nestedLines.join('\n')), + nextIndex: index, + } +} + +function readListItems( + lines: string[], + startIndex: number, + readItem: (line: string) => { indent: number; text: string } | null, + allowLooseChildLists = false, +): { items: ListItem[]; nextIndex: number } | null { + const items: ListItem[] = [] + let index = startIndex + const firstItem = readItem(lines[startIndex]) + if (!firstItem) return null + const baseIndent = firstItem.indent + + while (index < lines.length) { + const itemValue = readItem(lines[index]) + if (itemValue === null || itemValue.indent !== baseIndent) break + + const paragraphs = [itemValue.text] + const children: MarkdownBlock[] = [] + index += 1 + + while (index < lines.length) { + if (isBlankMarkdownLine(lines[index])) { + const nextNonBlankIndex = findNextNonBlankLineIndex(lines, index + 1) + if (nextNonBlankIndex === -1) { + index = lines.length + break + } + const nextSameLevelItem = readItem(lines[nextNonBlankIndex]) + if (nextSameLevelItem && nextSameLevelItem.indent === baseIndent) { + index = nextNonBlankIndex + break + } + if (leadingIndentWidth(lines[nextNonBlankIndex]) <= baseIndent) { + index = nextNonBlankIndex + break + } + index += 1 + continue + } + + const nextSameLevelItem = readItem(lines[index]) + if (nextSameLevelItem && nextSameLevelItem.indent === baseIndent) break + + const hasIndentedChildren = leadingIndentWidth(lines[index]) > baseIndent + const hasLooseChildList = allowLooseChildLists && ( + readTaskListItem(lines[index]) !== null || + readUnorderedListItem(lines[index]) !== null + ) + if (hasIndentedChildren || hasLooseChildList) { + const nestedBlocks = readNestedListBlocks(lines, index, baseIndent, readItem, allowLooseChildLists) + if (nestedBlocks) { + children.push(...nestedBlocks.blocks) + index = nestedBlocks.nextIndex + continue + } + } + + if (leadingIndentWidth(lines[index]) <= baseIndent) break + + const continuation = readListParagraph(lines, index, baseIndent) + if (!continuation) break + paragraphs.push(continuation.value) + index = continuation.nextIndex + } + + items.push(children.length > 0 ? { paragraphs, children } : { paragraphs }) + } + + return items.length > 0 ? { items, nextIndex: index } : null +} + +function isThematicBreakLine(line: string): boolean { + return /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(line.trim()) +} + +function readFenceStart(line: string): { marker: string; language: string } | null { + const match = line.match(/^\s{0,3}(```+|~~~+)\s*([^\s`~][^`]*)?\s*$/u) + if (!match) return null + return { + marker: match[1], + language: (match[2] ?? '').trim(), + } +} + +function parseTextBlocks(text: string): MarkdownBlock[] { + const normalizedText = normalizeMarkdownText(text) + const lines = normalizedText.split('\n') + const blocks: MarkdownBlock[] = [] + let index = 0 + + while (index < lines.length) { + if (isBlankMarkdownLine(lines[index])) { + index += 1 + continue + } + + const fence = readFenceStart(lines[index]) + if (fence) { + index += 1 + const codeLines: string[] = [] + while (index < lines.length) { + if (lines[index].trim() === fence.marker) { + index += 1 + break + } + codeLines.push(lines[index]) + index += 1 + } + blocks.push({ + kind: 'codeBlock', + language: fence.language, + value: codeLines.join('\n'), + }) + continue + } + + if (isThematicBreakLine(lines[index])) { + blocks.push({ kind: 'thematicBreak' }) + index += 1 + continue + } + + const heading = readHeading(lines[index]) + if (heading) { + blocks.push({ kind: 'heading', level: heading.level, value: heading.value }) + index += 1 + continue + } + + const quoteLine = readBlockquoteLine(lines[index]) + if (quoteLine !== null) { + const quoteLines: string[] = [] + while (index < lines.length) { + const nextQuoteLine = readBlockquoteLine(lines[index]) + if (nextQuoteLine === null) break + quoteLines.push(nextQuoteLine) + index += 1 + } + blocks.push({ kind: 'blockquote', value: quoteLines.join('\n').trim() }) + continue + } + + const table = readTableBlock(lines, index) + if (table) { + blocks.push(table) + index += 2 + table.rows.length + continue + } + + const taskItem = readTaskListItem(lines[index]) + if (taskItem !== null) { + const items: TaskListItem[] = [] + const baseIndent = readTaskListItemMatch(lines[index])?.indent ?? 0 + while (index < lines.length) { + const nextItem = readTaskListItemMatch(lines[index]) + if (nextItem === null || nextItem.indent !== baseIndent) break + items.push(nextItem.item) + index += 1 + } + if (items.length > 0) { + blocks.push({ kind: 'taskList', items }) + continue + } + } + + const unorderedItem = readUnorderedListItem(lines[index]) + if (unorderedItem !== null) { + const parsedList = readListItems(lines, index, readUnorderedListItemMatch) + if (parsedList) { + blocks.push({ kind: 'unorderedList', items: parsedList.items }) + index = parsedList.nextIndex + continue + } + if (unorderedItem.length > 0) { + blocks.push({ kind: 'unorderedList', items: [{ paragraphs: [unorderedItem] }] }) + index += 1 + continue + } + } + + const orderedItem = readOrderedListItem(lines[index]) + if (orderedItem !== null) { + const orderedItemMatch = readOrderedListItemMatch(lines[index]) + const parsedList = readListItems(lines, index, readOrderedListItemMatch, true) + if (parsedList) { + blocks.push({ + kind: 'orderedList', + items: parsedList.items, + start: orderedItemMatch?.start ?? 1, + }) + index = parsedList.nextIndex + continue + } + if (orderedItem.length > 0) { + blocks.push({ + kind: 'orderedList', + items: [{ paragraphs: [orderedItem] }], + start: orderedItemMatch?.start ?? 1, + }) + index += 1 + continue + } + } + + const paragraphLines: string[] = [] + while (index < lines.length) { + if (isBlankMarkdownLine(lines[index])) break + if ( + readFenceStart(lines[index]) || + isThematicBreakLine(lines[index]) || + readHeading(lines[index]) || + readTableBlock(lines, index) || + readBlockquoteLine(lines[index]) !== null || + readTaskListItem(lines[index]) !== null || + readUnorderedListItem(lines[index]) !== null || + readOrderedListItem(lines[index]) !== null + ) break + paragraphLines.push(lines[index]) + index += 1 + } + + const value = paragraphLines.join('\n').trim() + if (value) { + blocks.push({ kind: 'paragraph', value }) + } + } + + return blocks +} + +function parseNonCodeMessageBlocks(text: string): MarkdownBlock[] { + if (!text.includes('![') || !text.includes('](')) { + return parseTextBlocks(text) + } + + const blocks: MarkdownBlock[] = [] + const imagePattern = /!\[([^\]]*)\]\(([^)\n]+)\)/gu + let cursor = 0 + + for (const match of text.matchAll(imagePattern)) { + const [fullMatch, altRaw, urlRaw] = match + if (typeof match.index !== 'number') continue + + const start = match.index + const end = start + fullMatch.length + const imageUrl = urlRaw.trim() + if (!imageUrl) continue + + if (start > cursor) { + blocks.push(...parseTextBlocks(text.slice(cursor, start))) + } + + blocks.push({ kind: 'image', url: imageUrl, alt: altRaw.trim(), markdown: fullMatch }) + cursor = end + } + + if (cursor < text.length) { + blocks.push(...parseTextBlocks(text.slice(cursor))) + } + + return blocks +} + +function parseMessageBlocks(text: string): MarkdownBlock[] { + const normalizedText = normalizeMarkdownText(text) + const lines = normalizedText.split('\n') + const blocks: MarkdownBlock[] = [] + let index = 0 + let chunkStart = 0 + + const flushChunk = (endExclusive: number): void => { + if (endExclusive <= chunkStart) return + const chunk = lines.slice(chunkStart, endExclusive).join('\n') + blocks.push(...parseNonCodeMessageBlocks(chunk)) + } + + while (index < lines.length) { + const fence = readFenceStart(lines[index]) + if (!fence) { + index += 1 + continue + } + + flushChunk(index) + + index += 1 + const codeLines: string[] = [] + while (index < lines.length) { + if (lines[index].trim() === fence.marker) { + index += 1 + break + } + codeLines.push(lines[index]) + index += 1 + } + + blocks.push({ + kind: 'codeBlock', + language: fence.language, + value: codeLines.join('\n'), + }) + chunkStart = index + } + + flushChunk(lines.length) + return blocks.length > 0 ? blocks : [{ kind: 'paragraph', value: text }] +} + +function isFilePath(value: string): boolean { + if (!value || /[\r\n]/u.test(value)) return false + if (value.endsWith('/') || value.endsWith('\\')) return false + if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(value) && !value.startsWith('file://') && !value.startsWith('codex://')) return false + + const looksLikeUnixAbsolute = value.startsWith('/') + const looksLikeWindowsAbsolute = /^[A-Za-z]:[\\/]/u.test(value) + const looksLikeRelative = value.startsWith('./') || value.startsWith('../') || value.startsWith('~/') + const looksLikeFileUrl = value.startsWith('file://') + const looksLikeCodexThread = value.startsWith('codex://threads/') + if (looksLikeUnixAbsolute || looksLikeWindowsAbsolute || looksLikeRelative || looksLikeFileUrl || looksLikeCodexThread) return true + + const looksLikeBareFilename = /^[A-Za-z0-9._@() -]+\.[A-Za-z0-9]{1,12}$/u.test(value) + if (looksLikeBareFilename) return true + + return /^[A-Za-z0-9._@() -]+(?:[\\/][A-Za-z0-9._@() -]+)+$/u.test(value) +} + +function normalizeFileUrlToPath(pathValue: string): string { + if (!pathValue.startsWith('file://')) return pathValue + let stripped = pathValue.replace(/^file:\/\//u, '') + try { + stripped = decodeURIComponent(stripped) + } catch { + // Keep best-effort path if decoding fails. + } + if (/^\/[A-Za-z]:\//u.test(stripped)) { + stripped = stripped.slice(1) + } + return stripped +} + +function trimLinkWrappers(value: string): { core: string; leading: string; trailing: string } { + let core = value + let leading = '' + let trailing = '' + + const wrapperPairs: Record = { + '(': ')', + '[': ']', + '{': '}', + '<': '>', + '"': '"', + '\'': '\'', + '`': '`', + '“': '”', + '‘': '’', + } + + while (core.length > 0) { + const opening = core[0] + const closing = Object.prototype.hasOwnProperty.call(wrapperPairs, opening) ? wrapperPairs[opening] : '' + if (!closing || !core.endsWith(closing)) break + leading += opening + trailing += closing + core = core.slice(1, -1) + } + + return { core, leading, trailing } +} + +function parseFileReference(value: string): { path: string; line: number | null } | null { + if (!value) return null + + let pathValue = value.trim() + const wrapped = trimLinkWrappers(pathValue) + pathValue = wrapped.core.trim() + let line: number | null = null + + const hashLineMatch = pathValue.match(/^(.*)#L(\d+)(?:C\d+)?$/u) + if (hashLineMatch) { + pathValue = hashLineMatch[1] + line = Number(hashLineMatch[2]) + } else { + const colonLineMatch = pathValue.match(/^(.*):(\d+)(?::\d+)?$/u) + if (colonLineMatch) { + pathValue = colonLineMatch[1] + line = Number(colonLineMatch[2]) + } + } + + pathValue = normalizeFileUrlToPath(pathValue) + if (!isFilePath(pathValue)) return null + return { path: pathValue, line } +} + +function parseMarkdownLinkToken(value: string): { label: string; target: string } | null { + const trimmed = value.trim() + if (!trimmed.startsWith('[') || !trimmed.endsWith(')')) return null + const labelCloseIndex = trimmed.indexOf(']') + if (labelCloseIndex <= 1) return null + if (trimmed[labelCloseIndex + 1] !== '(') return null + const labelRaw = trimmed.slice(1, labelCloseIndex).trim() + const targetRaw = trimmed.slice(labelCloseIndex + 2, -1).trim() + if (labelRaw.includes('\n') || targetRaw.includes('\n')) return null + const label = trimLinkWrappers(labelRaw).core.trim() || labelRaw + const target = trimLinkWrappers(targetRaw).core.trim() + if (!target) return null + return { label, target } +} + +function escapeInsertedMarkdownText(value: string): string { + return value.replace(/([\\`*_{}\[\]()#+\-.!|>~])/gu, '\\$1') +} + +function isExternalLinkTarget(target: string): boolean { + return /^https?:\/\//u.test(target) || /^tg:\/\//u.test(target) || /^mailto:/u.test(target) +} + +function toBoldLiteral(value: string): string { + const normalized = value.trim() + return normalized ? `**${escapeInsertedMarkdownText(normalized)}**` : '' +} + +function readCodeSpan(source: string, startIndex: number): { raw: string; content: string; end: number } | null { + if (source[startIndex] !== '`') return null + let openLength = 1 + while (source[startIndex + openLength] === '`') openLength += 1 + const delimiter = '`'.repeat(openLength) + + let searchFrom = startIndex + openLength + let closingStart = -1 + while (searchFrom < source.length) { + const candidate = source.indexOf(delimiter, searchFrom) + if (candidate < 0) break + + const hasBacktickBefore = candidate > 0 && source[candidate - 1] === '`' + const hasBacktickAfter = candidate + openLength < source.length && source[candidate + openLength] === '`' + const hasNewLineInside = source.slice(startIndex + openLength, candidate).includes('\n') + + if (!hasBacktickBefore && !hasBacktickAfter && !hasNewLineInside) { + closingStart = candidate + break + } + searchFrom = candidate + 1 + } + + if (closingStart < 0) return null + + return { + raw: source.slice(startIndex, closingStart + openLength), + content: source.slice(startIndex + openLength, closingStart), + end: closingStart + openLength, + } +} + +function findNextMarkdownLink( + source: string, + fromIndex: number, +): { start: number; end: number; token: string } | null { + let linkStart = source.indexOf('[', fromIndex) + while (linkStart >= 0) { + const labelEnd = source.indexOf(']', linkStart + 1) + if (labelEnd < 0) return null + if (source[labelEnd + 1] !== '(') { + linkStart = source.indexOf('[', linkStart + 1) + continue + } + + let depth = 1 + let index = labelEnd + 2 + let hasNewLine = false + while (index < source.length) { + const char = source[index] + if (char === '\n') { + hasNewLine = true + break + } + if (char === '(') depth += 1 + if (char === ')') { + depth -= 1 + if (depth === 0) { + const token = source.slice(linkStart, index + 1) + if (parseMarkdownLinkToken(token)) { + return { start: linkStart, end: index + 1, token } + } + break + } + } + index += 1 + } + + if (hasNewLine) { + linkStart = source.indexOf('[', linkStart + 1) + continue + } + linkStart = source.indexOf('[', linkStart + 1) + } + return null +} + +function replaceMarkdownLinksWithTelegramText(source: string): string { + let cursor = 0 + let output = '' + + while (cursor < source.length) { + const match = findNextMarkdownLink(source, cursor) + if (!match) { + output += source.slice(cursor) + break + } + + output += source.slice(cursor, match.start) + const parsed = parseMarkdownLinkToken(match.token) + if (!parsed) { + output += match.token + cursor = match.end + continue + } + + if (isExternalLinkTarget(parsed.target)) { + output += match.token + cursor = match.end + continue + } + + const label = parsed.label || parsed.target + const fileRef = parseFileReference(parsed.target) + output += toBoldLiteral(fileRef ? `${label}` : label) + cursor = match.end + } + + return output +} + +function replaceBareLocalPaths(source: string): string { + const pattern = /(?:^|[\s(>])((?:file:\/\/|~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/])[^\s<>"'`]+)/gu + let cursor = 0 + let output = '' + + for (const match of source.matchAll(pattern)) { + if (typeof match.index !== 'number') continue + const fullMatch = match[0] + const candidate = match[1] ?? '' + const start = match.index + fullMatch.indexOf(candidate) + const end = start + candidate.length + const trimmedCandidate = candidate.replace(/[.,;:!?,。;:!?、]+$/u, '') + const trailing = candidate.slice(trimmedCandidate.length) + if (!trimmedCandidate) continue + + const ref = parseFileReference(trimmedCandidate) + if (!ref) continue + + output += source.slice(cursor, start) + output += toBoldLiteral(trimmedCandidate) + output += trailing + cursor = end + } + + if (cursor < source.length) { + output += source.slice(cursor) + } + + return output || source +} + +function sanitizeInlineMarkdownForTelegram(text: string): string { + let cursor = 0 + let output = '' + + while (cursor < text.length) { + const nextCodeIndex = text.indexOf('`', cursor) + if (nextCodeIndex < 0) { + const plain = text.slice(cursor) + output += replaceBareLocalPaths(replaceMarkdownLinksWithTelegramText(plain)) + break + } + + const plain = text.slice(cursor, nextCodeIndex) + output += replaceBareLocalPaths(replaceMarkdownLinksWithTelegramText(plain)) + + const codeSpan = readCodeSpan(text, nextCodeIndex) + if (!codeSpan) { + output += '`' + cursor = nextCodeIndex + 1 + continue + } + + const codeContent = codeSpan.content.trim() + const codeLink = parseMarkdownLinkToken(codeContent) + if (codeLink && !isExternalLinkTarget(codeLink.target)) { + output += toBoldLiteral(codeLink.label || codeLink.target) + } else { + const fileRef = parseFileReference(codeContent) + output += fileRef ? toBoldLiteral(fileRef.line ? `${fileRef.path}:${String(fileRef.line)}` : fileRef.path) : codeSpan.raw + } + + cursor = codeSpan.end + } + + return output +} + +function renderListItems(items: ListItem[], indent: string, orderedStart: number | null): string { + return items + .map((item, index) => renderListItem(item, indent, orderedStart === null ? '- ' : `${String(orderedStart + index)}. `)) + .join('\n') +} + +function indentMultiline(value: string, indent: string): string { + return value + .split('\n') + .map((line) => (line.length > 0 ? `${indent}${line}` : indent.trimEnd())) + .join('\n') +} + +function renderListItem(item: ListItem, indent: string, marker: string): string { + const lines: string[] = [] + const paragraphs = item.paragraphs.length > 0 ? item.paragraphs : [''] + lines.push(`${indent}${marker}${sanitizeInlineMarkdownForTelegram(paragraphs[0])}`) + for (const paragraph of paragraphs.slice(1)) { + lines.push(`${indent} ${sanitizeInlineMarkdownForTelegram(paragraph)}`) + } + if (item.children && item.children.length > 0) { + const childMarkdown = renderBlocksToTelegramMarkdown(item.children, `${indent} `) + if (childMarkdown.trim()) { + lines.push(indentMultiline(childMarkdown, '')) + } + } + return lines.join('\n') +} + +function renderTable(block: Extract): string { + const headerLine = `| ${block.headers.map((cell) => sanitizeInlineMarkdownForTelegram(cell)).join(' | ')} |` + const separatorLine = `| ${block.alignments.map((alignment) => { + if (alignment === 'left') return ':---' + if (alignment === 'center') return ':---:' + if (alignment === 'right') return '---:' + return '---' + }).join(' | ')} |` + const rowLines = block.rows.map((row) => `| ${row.map((cell) => sanitizeInlineMarkdownForTelegram(cell)).join(' | ')} |`) + return [headerLine, separatorLine, ...rowLines].join('\n') +} + +function renderBlockToTelegramMarkdown(block: MarkdownBlock, indent = ''): string { + if (block.kind === 'paragraph') { + return indentMultiline(sanitizeInlineMarkdownForTelegram(block.value), indent) + } + if (block.kind === 'heading') { + const level = Math.min(6, Math.max(1, Math.trunc(block.level))) + return `${indent}${'#'.repeat(level)} ${sanitizeInlineMarkdownForTelegram(block.value)}` + } + if (block.kind === 'blockquote') { + const nested = renderTelegramMarkdown(block.value) + return nested + .split('\n') + .map((line) => `${indent}>${line ? ` ${line}` : ''}`) + .join('\n') + } + if (block.kind === 'unorderedList') { + return renderListItems(block.items, indent, null) + } + if (block.kind === 'taskList') { + return block.items + .map((item) => `${indent}- [${item.checked ? 'x' : ' '}] ${sanitizeInlineMarkdownForTelegram(item.text)}`) + .join('\n') + } + if (block.kind === 'orderedList') { + return renderListItems(block.items, indent, block.start) + } + if (block.kind === 'table') { + return indentMultiline(renderTable(block), indent) + } + if (block.kind === 'codeBlock') { + const language = block.language.trim() + const lines = [`${indent}\`\`\`${language}`.trimEnd()] + for (const line of block.value.split('\n')) { + lines.push(line.length > 0 ? `${indent}${line}` : '') + } + lines.push(`${indent}\`\`\``) + return lines.join('\n') + } + if (block.kind === 'thematicBreak') { + return `${indent}---` + } + + const label = block.alt.trim() ? `Image: ${block.alt.trim()}` : 'Image' + return isExternalLinkTarget(block.url) + ? `${indent}[${escapeInsertedMarkdownText(label)}](${block.url})` + : `${indent}${toBoldLiteral(label)}` +} + +function renderBlocksToTelegramMarkdown(blocks: MarkdownBlock[], indent = ''): string { + return blocks + .map((block) => renderBlockToTelegramMarkdown(block, indent).trimEnd()) + .filter((block) => block.length > 0) + .join('\n\n') + .trim() +} + +function splitPlainText(text: string, maxLength: number): string[] { + const normalized = normalizeMarkdownText(text).trim() + if (!normalized) return [] + if (normalized.length <= maxLength) return [normalized] + + const chunks: string[] = [] + let remaining = normalized + + while (remaining.length > maxLength) { + let splitIndex = remaining.lastIndexOf('\n\n', maxLength) + if (splitIndex < Math.floor(maxLength * 0.5)) { + splitIndex = remaining.lastIndexOf('\n', maxLength) + } + if (splitIndex < Math.floor(maxLength * 0.5)) { + splitIndex = remaining.lastIndexOf(' ', maxLength) + } + if (splitIndex <= 0) { + splitIndex = maxLength + } + + const chunk = remaining.slice(0, splitIndex).trim() + if (chunk) chunks.push(chunk) + remaining = remaining.slice(splitIndex).trim() + } + + if (remaining) chunks.push(remaining) + return chunks +} + +export function renderTelegramMarkdown(text: string): string { + const blocks = parseMessageBlocks(text) + return renderBlocksToTelegramMarkdown(blocks) +} + +export function buildTelegramMarkdownChunks(text: string, maxLength = 12000): string[] { + const blocks = parseMessageBlocks(text) + const renderedBlocks = blocks + .map((block) => renderBlockToTelegramMarkdown(block).trim()) + .filter((block) => block.length > 0) + + if (renderedBlocks.length === 0) return [] + + const chunks: string[] = [] + let current = '' + + for (const blockText of renderedBlocks) { + if (!current) { + if (blockText.length <= maxLength) { + current = blockText + continue + } + chunks.push(...splitPlainText(blockText, maxLength)) + continue + } + + const candidate = `${current}\n\n${blockText}` + if (candidate.length <= maxLength) { + current = candidate + continue + } + + chunks.push(current) + if (blockText.length <= maxLength) { + current = blockText + continue + } + chunks.push(...splitPlainText(blockText, maxLength)) + current = '' + } + + if (current) { + chunks.push(current) + } + + return chunks +} From 5fe1aa2b58f8511a9bd88805f76c759a9716942f Mon Sep 17 00:00:00 2001 From: niyazm524 Date: Sat, 13 Jun 2026 20:14:31 +0300 Subject: [PATCH 2/3] Stream Telegram replies with drafts --- src/server/telegramThreadBridge.ts | 139 ++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 2 deletions(-) diff --git a/src/server/telegramThreadBridge.ts b/src/server/telegramThreadBridge.ts index 3a5ff596f..0be8de214 100644 --- a/src/server/telegramThreadBridge.ts +++ b/src/server/telegramThreadBridge.ts @@ -12,6 +12,7 @@ type TelegramUpdate = { } chat?: { id?: number + type?: string } } callback_query?: { @@ -23,6 +24,7 @@ type TelegramUpdate = { message?: { chat?: { id?: number + type?: string } } } @@ -52,6 +54,14 @@ type TelegramBotCommand = { description: string } +type TelegramDraftState = { + draftId: number + threadId: string + turnId: string + rawText: string + renderedText: string +} + const TELEGRAM_PLAIN_MESSAGE_MAX_LENGTH = 3500 const TELEGRAM_RICH_MESSAGE_MAX_LENGTH = 12000 const TELEGRAM_BOT_COMMANDS: TelegramBotCommand[] = [ @@ -158,9 +168,12 @@ export class TelegramThreadBridge { private readonly threadIdByChatId = new Map() private readonly chatIdsByThreadId = new Map>() private readonly lastForwardedTurnByThreadId = new Map() + private readonly draftStateByChatId = new Map() + private readonly draftUnsupportedChatIds = new Set() private active = false private pollingTask: Promise | null = null private nextUpdateOffset = 0 + private nextDraftId = 1 private lastError = '' private readonly onChatSeen?: (chatId: number) => void @@ -338,6 +351,20 @@ export class TelegramThreadBridge { await this.callTelegramApi('sendRichMessage', payload) } + private async sendRichMessageDraftRequest( + chatId: number, + draftId: number, + markdown: string, + ): Promise { + await this.callTelegramApi('sendRichMessageDraft', { + chat_id: chatId, + draft_id: draftId, + rich_message: { + markdown, + }, + }) + } + private async syncBotCommands(): Promise { if (!this.token) return await this.callTelegramApi('setMyCommands', { @@ -376,6 +403,74 @@ export class TelegramThreadBridge { } } + private nextDraftIdentifier(): number { + const next = this.nextDraftId + this.nextDraftId += 1 + if (this.nextDraftId <= 0 || this.nextDraftId > 0x7fffffff) { + this.nextDraftId = 1 + } + return next + } + + private getOrCreateDraftState(chatId: number, threadId: string, turnId: string): TelegramDraftState { + const existing = this.draftStateByChatId.get(chatId) + if (existing && existing.threadId === threadId && existing.turnId === turnId) { + return existing + } + const nextState: TelegramDraftState = { + draftId: this.nextDraftIdentifier(), + threadId, + turnId, + rawText: '', + renderedText: '', + } + this.draftStateByChatId.set(chatId, nextState) + return nextState + } + + private clearDraftStatesForTurn(threadId: string, turnId: string): void { + for (const [chatId, draft] of this.draftStateByChatId.entries()) { + if (draft.threadId === threadId && draft.turnId === turnId) { + this.draftStateByChatId.delete(chatId) + } + } + } + + private clearDraftStatesForThread(threadId: string): void { + for (const [chatId, draft] of this.draftStateByChatId.entries()) { + if (draft.threadId === threadId) { + this.draftStateByChatId.delete(chatId) + } + } + } + + private async updateDraftForThread( + threadId: string, + turnId: string, + nextText: string | null, + ): Promise { + const chatIds = this.chatIdsByThreadId.get(threadId) + if (!chatIds || chatIds.size === 0) return + + const trimmed = (nextText ?? '').trim() + const draftMarkdown = trimmed || 'Thinking…' + const draftChunk = buildTelegramMarkdownChunks(draftMarkdown, TELEGRAM_RICH_MESSAGE_MAX_LENGTH)[0] ?? 'Thinking…' + + for (const chatId of chatIds) { + if (this.draftUnsupportedChatIds.has(chatId)) continue + const draftState = this.getOrCreateDraftState(chatId, threadId, turnId) + if (draftState.renderedText === draftChunk && draftState.rawText === (nextText ?? '')) continue + draftState.rawText = nextText ?? '' + draftState.renderedText = draftChunk + try { + await this.sendRichMessageDraftRequest(chatId, draftState.draftId, draftChunk) + } catch { + this.draftUnsupportedChatIds.add(chatId) + this.draftStateByChatId.delete(chatId) + } + } + } + private async handleIncomingUpdate(update: TelegramUpdate): Promise { if (update.callback_query) { await this.handleCallbackQuery(update.callback_query) @@ -726,17 +821,57 @@ export class TelegramThreadBridge { } private async handleNotification(notification: { method: string; params: unknown }): Promise { + const params = asRecord(notification.params) + const threadIdFromParams = this.extractThreadId(notification) + const turnIdFromParams = this.extractTurnId(notification) + + if (notification.method === 'turn/started' && threadIdFromParams && turnIdFromParams) { + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, 'Thinking…') + return + } + + if (notification.method === 'item/started' && threadIdFromParams && turnIdFromParams) { + const item = asRecord(params?.item) + if (item?.type === 'reasoning') { + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, 'Thinking…') + return + } + if (item?.type === 'agentMessage') { + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, null) + return + } + } + + if (notification.method === 'item/agentMessage/delta' && threadIdFromParams && turnIdFromParams) { + const delta = typeof params?.delta === 'string' ? params.delta : '' + if (!delta) return + const chatIds = this.chatIdsByThreadId.get(threadIdFromParams) + const existingText = chatIds && chatIds.size > 0 + ? Array.from(chatIds) + .map((chatId) => this.draftStateByChatId.get(chatId)) + .find((draft) => draft?.threadId === threadIdFromParams && draft.turnId === turnIdFromParams)?.rawText ?? '' + : '' + const merged = `${existingText}${delta}` + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, merged) + return + } + if (notification.method !== 'turn/completed') return - const threadId = this.extractThreadId(notification) + const threadId = threadIdFromParams if (!threadId) return const chatIds = this.chatIdsByThreadId.get(threadId) if (!chatIds || chatIds.size === 0) return - const turnId = this.extractTurnId(notification) + const turnId = turnIdFromParams const lastForwardedTurnId = this.lastForwardedTurnByThreadId.get(threadId) if (turnId && lastForwardedTurnId === turnId) return const assistantReply = await this.readLatestAssistantMessage(threadId) + if (turnId) { + this.clearDraftStatesForTurn(threadId, turnId) + } else { + this.clearDraftStatesForThread(threadId) + } if (!assistantReply) return for (const chatId of chatIds) { await this.sendTelegramMessage(chatId, assistantReply) From db0b020295c328f3d30ffce7e097158fed3015e8 Mon Sep 17 00:00:00 2001 From: niyazm524 Date: Sun, 14 Jun 2026 12:41:01 +0300 Subject: [PATCH 3/3] Improve Telegram thread recovery --- src/server/telegramThreadBridge.test.ts | 136 ++++++++++++++++ src/server/telegramThreadBridge.ts | 200 ++++++++++++++++++++---- 2 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 src/server/telegramThreadBridge.test.ts diff --git a/src/server/telegramThreadBridge.test.ts b/src/server/telegramThreadBridge.test.ts new file mode 100644 index 000000000..3b89f7fb9 --- /dev/null +++ b/src/server/telegramThreadBridge.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { TelegramThreadBridge } from './telegramThreadBridge.js' + +describe('TelegramThreadBridge', () => { + const originalTelegramBotToken = process.env.TELEGRAM_BOT_TOKEN + const originalTelegramAllowedUserIds = process.env.TELEGRAM_ALLOWED_USER_IDS + + beforeEach(() => { + process.env.TELEGRAM_BOT_TOKEN = 'test-token' + process.env.TELEGRAM_ALLOWED_USER_IDS = '*' + }) + + afterEach(() => { + vi.restoreAllMocks() + restoreEnvValue('TELEGRAM_BOT_TOKEN', originalTelegramBotToken) + restoreEnvValue('TELEGRAM_ALLOWED_USER_IDS', originalTelegramAllowedUserIds) + }) + + it('resumes an existing thread before retrying turn/start', async () => { + const calls: Array<{ method: string; params: unknown }> = [] + let startCalls = 0 + const appServer = { + rpc: vi.fn(async (method: string, params: unknown) => { + calls.push({ method, params }) + if (method === 'turn/start') { + startCalls += 1 + if (startCalls === 1) { + throw new Error('thread not found: test-thread') + } + return { turn: { id: 'turn-2' } } + } + if (method === 'thread/resume') { + return { thread: { id: 'test-thread', turns: [] } } + } + throw new Error(`Unexpected rpc method ${method}`) + }), + onNotification: vi.fn(() => () => {}), + } + + const bridge = new TelegramThreadBridge(appServer) + vi.spyOn(bridge as never as { sendTelegramMessage: (...args: unknown[]) => Promise }, 'sendTelegramMessage') + .mockResolvedValue(undefined) + ;(bridge as unknown as { bindChatToThread: (chatId: number, threadId: string) => void }) + .bindChatToThread(42, 'test-thread') + + await (bridge as unknown as { + handleIncomingUpdate: (update: unknown) => Promise + }).handleIncomingUpdate({ + message: { + text: 'hello', + from: { id: 7 }, + chat: { id: 42 }, + }, + }) + + expect(calls).toEqual([ + { + method: 'turn/start', + params: { + threadId: 'test-thread', + input: [{ type: 'text', text: 'hello' }], + }, + }, + { + method: 'thread/resume', + params: { threadId: 'test-thread' }, + }, + { + method: 'turn/start', + params: { + threadId: 'test-thread', + input: [{ type: 'text', text: 'hello' }], + }, + }, + ]) + }) + + it('resumes an existing thread before binding it from the /thread command', async () => { + const calls: Array<{ method: string; params: unknown }> = [] + const appServer = { + rpc: vi.fn(async (method: string, params: unknown) => { + calls.push({ method, params }) + if (method === 'thread/resume') { + return { thread: { id: 'test-thread', turns: [] } } + } + if (method === 'thread/read') { + return { + thread: { + id: 'test-thread', + turns: [], + }, + } + } + throw new Error(`Unexpected rpc method ${method}`) + }), + onNotification: vi.fn(() => () => {}), + } + + const bridge = new TelegramThreadBridge(appServer) + const sendTelegramMessage = vi.spyOn( + bridge as never as { sendTelegramMessage: (...args: unknown[]) => Promise }, + 'sendTelegramMessage', + ).mockResolvedValue(undefined) + + await (bridge as unknown as { + handleIncomingUpdate: (update: unknown) => Promise + }).handleIncomingUpdate({ + message: { + text: '/thread test-thread', + from: { id: 7 }, + chat: { id: 42 }, + }, + }) + + expect(calls).toEqual([ + { + method: 'thread/resume', + params: { threadId: 'test-thread' }, + }, + { + method: 'thread/read', + params: { threadId: 'test-thread', includeTurns: true }, + }, + ]) + expect(sendTelegramMessage).toHaveBeenCalledWith(42, expect.stringContaining('Connected to existing thread')) + }) +}) + +function restoreEnvValue(name: string, value: string | undefined): void { + if (typeof value === 'undefined') { + delete process.env[name] + return + } + process.env[name] = value +} diff --git a/src/server/telegramThreadBridge.ts b/src/server/telegramThreadBridge.ts index 0be8de214..e3ce75e6c 100644 --- a/src/server/telegramThreadBridge.ts +++ b/src/server/telegramThreadBridge.ts @@ -60,10 +60,12 @@ type TelegramDraftState = { turnId: string rawText: string renderedText: string + phase: 'thinking' | 'agent' } const TELEGRAM_PLAIN_MESSAGE_MAX_LENGTH = 3500 const TELEGRAM_RICH_MESSAGE_MAX_LENGTH = 12000 +const TELEGRAM_DRAFT_REFRESH_MS = 15000 const TELEGRAM_BOT_COMMANDS: TelegramBotCommand[] = [ { command: 'start', description: 'Show quick start and thread picker' }, { command: 'threads', description: 'List recent threads to connect' }, @@ -159,6 +161,11 @@ function formatBooleanLabel(value: boolean): string { return value ? 'yes' : 'no' } +function isThreadNotFoundError(error: unknown): boolean { + const message = getErrorMessage(error, '').toLowerCase() + return message.includes('thread not found') || message.includes('no rollout found for thread id') +} + export class TelegramThreadBridge { private token: string private readonly appServer: AppServerLike @@ -169,6 +176,7 @@ export class TelegramThreadBridge { private readonly chatIdsByThreadId = new Map>() private readonly lastForwardedTurnByThreadId = new Map() private readonly draftStateByChatId = new Map() + private readonly draftRefreshTimerByChatId = new Map>() private readonly draftUnsupportedChatIds = new Set() private active = false private pollingTask: Promise | null = null @@ -203,6 +211,11 @@ export class TelegramThreadBridge { stop(): void { this.active = false + for (const timer of this.draftRefreshTimerByChatId.values()) { + clearTimeout(timer) + } + this.draftRefreshTimerByChatId.clear() + this.draftStateByChatId.clear() } private async pollLoop(): Promise { @@ -423,15 +436,29 @@ export class TelegramThreadBridge { turnId, rawText: '', renderedText: '', + phase: 'thinking', } this.draftStateByChatId.set(chatId, nextState) return nextState } + private clearDraftRefreshTimer(chatId: number): void { + const timer = this.draftRefreshTimerByChatId.get(chatId) + if (timer) { + clearTimeout(timer) + this.draftRefreshTimerByChatId.delete(chatId) + } + } + + private clearDraftState(chatId: number): void { + this.clearDraftRefreshTimer(chatId) + this.draftStateByChatId.delete(chatId) + } + private clearDraftStatesForTurn(threadId: string, turnId: string): void { for (const [chatId, draft] of this.draftStateByChatId.entries()) { if (draft.threadId === threadId && draft.turnId === turnId) { - this.draftStateByChatId.delete(chatId) + this.clearDraftState(chatId) } } } @@ -439,15 +466,46 @@ export class TelegramThreadBridge { private clearDraftStatesForThread(threadId: string): void { for (const [chatId, draft] of this.draftStateByChatId.entries()) { if (draft.threadId === threadId) { - this.draftStateByChatId.delete(chatId) + this.clearDraftState(chatId) } } } + private scheduleDraftRefresh(chatId: number): void { + this.clearDraftRefreshTimer(chatId) + const draftState = this.draftStateByChatId.get(chatId) + if (!draftState || this.draftUnsupportedChatIds.has(chatId) || !this.active) return + const timer = setTimeout(() => { + void this.refreshDraft(chatId) + }, TELEGRAM_DRAFT_REFRESH_MS) + this.draftRefreshTimerByChatId.set(chatId, timer) + } + + private async refreshDraft(chatId: number): Promise { + const draftState = this.draftStateByChatId.get(chatId) + if (!draftState || this.draftUnsupportedChatIds.has(chatId) || !this.active) { + this.clearDraftRefreshTimer(chatId) + return + } + + try { + await this.sendRichMessageDraftRequest( + chatId, + draftState.draftId, + draftState.renderedText || 'Thinking…', + ) + this.scheduleDraftRefresh(chatId) + } catch { + this.draftUnsupportedChatIds.add(chatId) + this.clearDraftState(chatId) + } + } + private async updateDraftForThread( threadId: string, turnId: string, nextText: string | null, + phase: 'thinking' | 'agent', ): Promise { const chatIds = this.chatIdsByThreadId.get(threadId) if (!chatIds || chatIds.size === 0) return @@ -459,14 +517,23 @@ export class TelegramThreadBridge { for (const chatId of chatIds) { if (this.draftUnsupportedChatIds.has(chatId)) continue const draftState = this.getOrCreateDraftState(chatId, threadId, turnId) - if (draftState.renderedText === draftChunk && draftState.rawText === (nextText ?? '')) continue + if ( + draftState.renderedText === draftChunk && + draftState.rawText === (nextText ?? '') && + draftState.phase === phase + ) { + this.scheduleDraftRefresh(chatId) + continue + } draftState.rawText = nextText ?? '' draftState.renderedText = draftChunk + draftState.phase = phase try { await this.sendRichMessageDraftRequest(chatId, draftState.draftId, draftChunk) + this.scheduleDraftRefresh(chatId) } catch { this.draftUnsupportedChatIds.add(chatId) - this.draftStateByChatId.delete(chatId) + this.clearDraftState(chatId) } } } @@ -512,12 +579,7 @@ export class TelegramThreadBridge { const threadCommand = text.match(/^\/thread\s+(\S+)$/) if (threadCommand) { const threadId = threadCommand[1] - this.bindChatToThread(chatId, threadId) - await this.sendTelegramMessage(chatId, [ - '# Thread connected', - '', - `Connected to existing thread: \`${threadId}\``, - ].join('\n')) + await this.connectExistingThread(chatId, threadId, senderId, 'command') return } @@ -606,10 +668,7 @@ export class TelegramThreadBridge { const threadId = await this.ensureThreadForChat(chatId) try { - await this.appServer.rpc('turn/start', { - threadId, - input: [{ type: 'text', text }], - }) + await this.startTurnWithRecovery(threadId, text) } catch (error) { const message = getErrorMessage(error, 'Failed to forward message to thread') await this.sendTelegramMessage(chatId, [ @@ -654,16 +713,21 @@ export class TelegramThreadBridge { return } - this.bindChatToThread(chatId, threadId) - await this.answerCallbackQuery(callbackId, 'Thread connected') - await this.sendTelegramMessage(chatId, [ - '# Thread connected', - '', - `Connected to thread: \`${threadId}\``, - ].join('\n')) - const history = await this.readThreadHistorySummary(threadId) - if (history) { - await this.sendTelegramMessage(chatId, history) + try { + await this.connectExistingThread(chatId, threadId, senderId, 'callback') + await this.answerCallbackQuery(callbackId, 'Thread connected') + } catch (error) { + const message = getErrorMessage(error, 'Failed to connect thread') + await this.answerCallbackQuery(callbackId, 'Failed to connect thread') + await this.sendTelegramMessage(chatId, [ + '# Thread connect failed', + '', + 'Telegram chat could not be connected to the requested Codex thread.', + '', + '```text', + message, + '```', + ].join('\n')) } } @@ -785,6 +849,44 @@ export class TelegramThreadBridge { return this.createThreadForChat(chatId) } + private async connectExistingThread( + chatId: number, + threadId: string, + senderId: number | undefined, + source: 'command' | 'callback', + ): Promise { + await this.appServer.rpc('thread/resume', { threadId }) + this.bindChatToThread(chatId, threadId) + await this.sendTelegramMessage(chatId, [ + '# Thread connected', + '', + `Connected to existing thread: \`${threadId}\``, + ].join('\n')) + const history = await this.readThreadHistorySummary(threadId) + if (history) { + await this.sendTelegramMessage(chatId, history) + } + } + + private async startTurnWithRecovery(threadId: string, text: string): Promise { + const params = { + threadId, + input: [{ type: 'text', text }], + } + + try { + await this.appServer.rpc('turn/start', params) + return + } catch (error) { + if (!isThreadNotFoundError(error)) { + throw error + } + } + + await this.appServer.rpc('thread/resume', { threadId }) + await this.appServer.rpc('turn/start', params) + } + private bindChatToThread(chatId: number, threadId: string): void { const previousThreadId = this.threadIdByChatId.get(chatId) if (previousThreadId && previousThreadId !== threadId) { @@ -826,22 +928,64 @@ export class TelegramThreadBridge { const turnIdFromParams = this.extractTurnId(notification) if (notification.method === 'turn/started' && threadIdFromParams && turnIdFromParams) { - await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, 'Thinking…') + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, 'Thinking…', 'thinking') return } if (notification.method === 'item/started' && threadIdFromParams && turnIdFromParams) { const item = asRecord(params?.item) if (item?.type === 'reasoning') { - await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, 'Thinking…') + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, 'Thinking…', 'thinking') return } if (item?.type === 'agentMessage') { - await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, null) + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, null, 'agent') return } } + if ( + (notification.method === 'item/reasoning/summaryTextDelta' || notification.method === 'item/reasoning/textDelta') && + threadIdFromParams && + turnIdFromParams + ) { + const delta = typeof params?.delta === 'string' ? params.delta : '' + if (!delta) return + const chatIds = this.chatIdsByThreadId.get(threadIdFromParams) + const existingText = chatIds && chatIds.size > 0 + ? Array.from(chatIds) + .map((chatId) => this.draftStateByChatId.get(chatId)) + .find((draft) => ( + draft?.threadId === threadIdFromParams && + draft.turnId === turnIdFromParams && + draft.phase === 'thinking' + ))?.rawText ?? '' + : '' + const merged = `${existingText}${delta}` + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, merged, 'thinking') + return + } + + if (notification.method === 'item/reasoning/summaryPartAdded' && threadIdFromParams && turnIdFromParams) { + const chatIds = this.chatIdsByThreadId.get(threadIdFromParams) + const existingText = chatIds && chatIds.size > 0 + ? Array.from(chatIds) + .map((chatId) => this.draftStateByChatId.get(chatId)) + .find((draft) => ( + draft?.threadId === threadIdFromParams && + draft.turnId === turnIdFromParams && + draft.phase === 'thinking' + ))?.rawText ?? '' + : '' + if (!existingText.trim()) { + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, 'Thinking…', 'thinking') + return + } + const merged = existingText.endsWith('\n\n') ? existingText : `${existingText}\n\n` + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, merged, 'thinking') + return + } + if (notification.method === 'item/agentMessage/delta' && threadIdFromParams && turnIdFromParams) { const delta = typeof params?.delta === 'string' ? params.delta : '' if (!delta) return @@ -852,7 +996,7 @@ export class TelegramThreadBridge { .find((draft) => draft?.threadId === threadIdFromParams && draft.turnId === turnIdFromParams)?.rawText ?? '' : '' const merged = `${existingText}${delta}` - await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, merged) + await this.updateDraftForThread(threadIdFromParams, turnIdFromParams, merged, 'agent') return }