From 05b183158c972a9f56249cd30d20f30bbd078efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 21 May 2026 17:34:51 +0800 Subject: [PATCH] fix: keep long Telegram streams readable Telegram Bot API text edits share the same practical message length ceiling as sends, so streaming one growing placeholder can stop exposing new assistant output once the text approaches the limit. The adapter now seals full chunks, opens a fresh editable placeholder, and routes Telegram outbound text through a table-to-bullet formatter for phone readability. Constraint: Telegram text messages and edits must stay below the platform message limit. Rejected: Wait until message_complete to split the answer | users still lose streaming visibility during long replies. Confidence: high Scope-risk: narrow Directive: Keep Telegram-specific streaming state in adapters/telegram/format.ts instead of spreading chunk math across message handlers. Tested: bun test adapters/telegram/__tests__/telegram.test.ts adapters/common/__tests__/format.test.ts Tested: cd adapters && bunx tsc --noEmit Tested: bun run check:adapters Tested: cd desktop && bun run build:sidecars Not-tested: Live Telegram Bot API run with a real bot token. --- adapters/common/__tests__/format.test.ts | 60 +++++++++++++ adapters/common/format.ts | 93 ++++++++++++++++++++ adapters/telegram/__tests__/telegram.test.ts | 65 ++++++++++++++ adapters/telegram/format.ts | 72 +++++++++++++++ adapters/telegram/index.ts | 57 ++++++++++-- 5 files changed, 338 insertions(+), 9 deletions(-) create mode 100644 adapters/telegram/format.ts diff --git a/adapters/common/__tests__/format.test.ts b/adapters/common/__tests__/format.test.ts index 943ee6bb..0597516f 100644 --- a/adapters/common/__tests__/format.test.ts +++ b/adapters/common/__tests__/format.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'bun:test' import { + convertMarkdownTablesToBullets, formatImHelp, formatImStatus, splitMessage, @@ -46,6 +47,65 @@ describe('splitMessage', () => { }) }) +describe('convertMarkdownTablesToBullets', () => { + it('converts pipe tables into row-labeled bullets', () => { + const markdown = [ + 'Before', + '', + '| Feature | Status | Notes |', + '| --- | --- | --- |', + '| Auth | Done | OAuth2 |', + '| API | WIP | REST only |', + '', + 'After', + ].join('\n') + + expect(convertMarkdownTablesToBullets(markdown)).toBe([ + 'Before', + '', + 'Auth', + '• Status: Done', + '• Notes: OAuth2', + '', + 'API', + '• Status: WIP', + '• Notes: REST only', + '', + 'After', + ].join('\n')) + }) + + it('skips empty table cells', () => { + const markdown = [ + '| Item | Value | Notes |', + '| --- | --- | --- |', + '| One | 1 | |', + ].join('\n') + + expect(convertMarkdownTablesToBullets(markdown)).toBe([ + 'One', + '• Value: 1', + ].join('\n')) + }) + + it('leaves non-table pipe text unchanged', () => { + const markdown = 'Use foo | bar as plain text.' + expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown) + }) + + it('does not rewrite pipe tables inside fenced code blocks', () => { + const markdown = [ + '```', + '| Feature | Status |', + '| --- | --- |', + '| Auth | Done |', + '```', + ].join('\n') + + expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown) + }) +}) + describe('formatToolUse', () => { it('includes tool name and input preview', () => { const result = formatToolUse('Bash', { command: 'npm test' }) diff --git a/adapters/common/format.ts b/adapters/common/format.ts index 09e62349..d011d67a 100644 --- a/adapters/common/format.ts +++ b/adapters/common/format.ts @@ -64,6 +64,99 @@ export function splitMessage(text: string, limit: number): string[] { return chunks } +type MarkdownTable = { + headers: string[] + rows: string[][] +} + +function splitMarkdownTableRow(line: string): string[] { + const trimmed = line.trim() + const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed + const withoutTrailingPipe = inner.endsWith('|') ? inner.slice(0, -1) : inner + return withoutTrailingPipe.split('|').map((cell) => cell.trim()) +} + +function isMarkdownTableDivider(line: string): boolean { + const cells = splitMarkdownTableRow(line) + if (cells.length < 2) return false + return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())) +} + +function isPotentialMarkdownTableRow(line: string): boolean { + const trimmed = line.trim() + return trimmed.includes('|') && splitMarkdownTableRow(trimmed).length >= 2 +} + +function isFenceMarker(line: string): boolean { + return /^\s*(```|~~~)/.test(line) +} + +function formatMarkdownTableAsBullets(table: MarkdownTable): string { + const { headers, rows } = table + if (headers.length === 0 || rows.length === 0) return '' + + const output: string[] = [] + + for (const row of rows) { + if (row.every((cell) => !cell)) continue + + const label = row[0] + if (label) output.push(label) + + for (let i = 1; i < Math.max(headers.length, row.length); i++) { + const value = row[i] + if (!value) continue + const header = headers[i] + output.push(`• ${header ? `${header}: ` : `Column ${i}: `}${value}`) + } + + if (output[output.length - 1] !== '') output.push('') + } + + while (output[output.length - 1] === '') output.pop() + return output.join('\n') +} + +/** Convert GitHub-flavored Markdown pipe tables into mobile-friendly bullet lists. */ +export function convertMarkdownTablesToBullets(markdown: string): string { + const lines = markdown.split('\n') + const output: string[] = [] + let inFence = false + let i = 0 + + while (i < lines.length) { + const headerLine = lines[i] ?? '' + + if (isFenceMarker(headerLine)) { + inFence = !inFence + output.push(headerLine) + i += 1 + continue + } + + const dividerLine = lines[i + 1] ?? '' + if (!inFence && isPotentialMarkdownTableRow(headerLine) && isMarkdownTableDivider(dividerLine)) { + const headers = splitMarkdownTableRow(headerLine) + const rows: string[][] = [] + i += 2 + + while (i < lines.length && isPotentialMarkdownTableRow(lines[i] ?? '')) { + rows.push(splitMarkdownTableRow(lines[i] ?? '')) + i += 1 + } + + const rendered = formatMarkdownTableAsBullets({ headers, rows }) + if (rendered) output.push(rendered) + continue + } + + output.push(headerLine) + i += 1 + } + + return output.join('\n') +} + /** Format tool use info for display in IM. */ export function formatToolUse(toolName: string, input: unknown): string { const inp = (input && typeof input === 'object' ? input : {}) as Record diff --git a/adapters/telegram/__tests__/telegram.test.ts b/adapters/telegram/__tests__/telegram.test.ts index 896d30cf..cd4f22d7 100644 --- a/adapters/telegram/__tests__/telegram.test.ts +++ b/adapters/telegram/__tests__/telegram.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'bun:test' import { splitMessage, formatPermissionRequest, truncateInput, escapeMarkdownV2 } from '../../common/format.js' import { parsePermitCallbackData } from '../../common/permission.js' +import { + formatTelegramOutboundText, + formatTelegramStreamingText, + planTelegramStreamingUpdate, +} from '../format.js' /** * Telegram Adapter 翻译逻辑测试 @@ -31,6 +36,66 @@ describe('Telegram message formatting', () => { }) }) + describe('Telegram outbound text formatting', () => { + it('converts markdown tables to bullets before sending', () => { + const markdown = [ + '| Feature | Status |', + '| --- | --- |', + '| Telegram | Done |', + ].join('\n') + + expect(formatTelegramOutboundText(markdown)).toBe([ + 'Telegram', + '• Status: Done', + ].join('\n')) + }) + + it('converts markdown tables during streaming updates too', () => { + const markdown = [ + '| Feature | Status |', + '| --- | --- |', + '| Telegram | Streaming |', + ].join('\n') + + expect(formatTelegramStreamingText(markdown)).toBe([ + 'Telegram', + '• Status: Streaming ▍', + ].join('\n')) + }) + }) + + describe('Telegram streaming updates', () => { + it('keeps short streaming text in the active editable chunk', () => { + expect(planTelegramStreamingUpdate('Hello', ' World', 4000)).toEqual({ + sealedChunks: [], + activeChunk: 'Hello World', + }) + }) + + it('seals overflowing streaming text and carries the remainder forward', () => { + const result = planTelegramStreamingUpdate('a'.repeat(3990), 'b'.repeat(50), 4000) + + expect(result.sealedChunks.length).toBe(1) + expect(result.sealedChunks[0]!.length).toBeLessThanOrEqual(4000) + expect(result.activeChunk).toBe('b'.repeat(40)) + }) + + it('can seal multiple chunks from one large buffered flush', () => { + const result = planTelegramStreamingUpdate('', 'x'.repeat(8500), 4000) + + expect(result.sealedChunks.length).toBe(2) + expect(result.sealedChunks.every((chunk) => chunk.length <= 4000)).toBe(true) + expect(result.activeChunk.length).toBe(500) + }) + + it('keeps an exact-limit remainder editable instead of emitting an empty active chunk', () => { + const result = planTelegramStreamingUpdate('', 'x'.repeat(8000), 4000) + + expect(result.sealedChunks).toEqual(['x'.repeat(4000)]) + expect(result.activeChunk).toBe('x'.repeat(4000)) + }) + }) + describe('permission request formatting', () => { it('formats Bash command request', () => { const result = formatPermissionRequest('Bash', { command: 'npm test' }, 'abcde') diff --git a/adapters/telegram/format.ts b/adapters/telegram/format.ts new file mode 100644 index 00000000..6b7fea27 --- /dev/null +++ b/adapters/telegram/format.ts @@ -0,0 +1,72 @@ +import { + convertMarkdownTablesToBullets, + splitMessage, +} from '../common/format.js' + +export function formatTelegramOutboundText(text: string): string { + return convertMarkdownTablesToBullets(text) +} + +export function formatTelegramStreamingText(text: string): string { + return `${formatTelegramOutboundText(text)} ▍` +} + +export type TelegramStreamingUpdate = { + sealedChunks: string[] + activeChunk: string +} + +export function planTelegramStreamingUpdate( + currentText: string, + deltaText: string, + limit: number, +): TelegramStreamingUpdate { + const fullText = currentText + deltaText + if (formatTelegramOutboundText(fullText).length <= limit) { + return { sealedChunks: [], activeChunk: fullText } + } + + const sealedChunks: string[] = [] + let remaining = fullText + + while (formatTelegramOutboundText(remaining).length > limit) { + const [sealed, rest] = splitOneStreamingChunk(remaining, limit) + sealedChunks.push(sealed) + remaining = rest + + if (!remaining) break + } + + return { sealedChunks, activeChunk: remaining } +} + +function splitOneStreamingChunk(text: string, limit: number): [string, string] { + const roughLimit = Math.min(limit, text.length) + const candidates = [ + text.lastIndexOf('\n\n', roughLimit), + text.lastIndexOf('\n', roughLimit), + text.lastIndexOf('. ', roughLimit), + text.lastIndexOf(' ', roughLimit), + ].filter((index) => index > 0) + + for (const candidate of candidates) { + const splitAt = includeDelimiter(text, candidate) + const sealed = text.slice(0, splitAt).trimEnd() + if (sealed && formatTelegramOutboundText(sealed).length <= limit) { + return [sealed, text.slice(splitAt).trimStart()] + } + } + + const chunks = splitMessage(formatTelegramOutboundText(text), limit) + const firstFormattedChunk = chunks[0] ?? text.slice(0, limit) + if (firstFormattedChunk.length < text.length && text.startsWith(firstFormattedChunk)) { + return [firstFormattedChunk.trimEnd(), text.slice(firstFormattedChunk.length).trimStart()] + } + + const splitAt = Math.max(1, Math.min(limit, text.length)) + return [text.slice(0, splitAt).trimEnd(), text.slice(splitAt).trimStart()] +} + +function includeDelimiter(text: string, splitAt: number): number { + return text[splitAt] === '\n' || text[splitAt] === '.' ? splitAt + 1 : splitAt +} diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index a3985169..9099c4cc 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -18,6 +18,11 @@ import { formatPermissionRequest, splitMessage, } from '../common/format.js' +import { + formatTelegramOutboundText, + formatTelegramStreamingText, + planTelegramStreamingUpdate, +} from './format.js' import { formatPermissionDecisionStatus, formatPermissionInstructions, @@ -37,6 +42,7 @@ import type { PendingUpload } from '../common/attachment/attachment-types.js' import * as fs from 'node:fs/promises' const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096 +const TELEGRAM_STREAMING_TEXT_LIMIT = TELEGRAM_TEXT_LIMIT - 2 // reserve room for cursor // ---------- init ---------- @@ -208,14 +214,14 @@ async function buildStatusText(chatId: string): Promise { async function flushToTelegram(chatId: string, newText: string, isComplete: boolean): Promise { const numericChatId = Number(chatId) const prev = accumulatedText.get(chatId) ?? '' - const fullText = prev + newText - accumulatedText.set(chatId, fullText) const placeholder = placeholders.get(chatId) if (placeholder) { if (isComplete) { - const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT) + const fullText = prev + newText + accumulatedText.set(chatId, fullText) + const chunks = splitMessage(formatTelegramOutboundText(fullText), TELEGRAM_TEXT_LIMIT) try { await bot.api.editMessageText(numericChatId, placeholder.messageId, chunks[0]!) } catch { /* ignore */ } @@ -223,16 +229,45 @@ async function flushToTelegram(chatId: string, newText: string, isComplete: bool await bot.api.sendMessage(numericChatId, chunks[i]!) } } else { - const displayText = fullText.slice(0, TELEGRAM_TEXT_LIMIT - 2) + ' ▍' + const { sealedChunks, activeChunk } = planTelegramStreamingUpdate( + prev, + newText, + TELEGRAM_STREAMING_TEXT_LIMIT, + ) + accumulatedText.set(chatId, activeChunk) try { - await bot.api.editMessageText(numericChatId, placeholder.messageId, displayText) + const firstSealedChunk = sealedChunks.shift() + if (firstSealedChunk) { + const firstSealedFormattedChunks = splitMessage( + formatTelegramOutboundText(firstSealedChunk), + TELEGRAM_TEXT_LIMIT, + ) + await bot.api.editMessageText(numericChatId, placeholder.messageId, firstSealedFormattedChunks[0]!) + for (let i = 1; i < firstSealedFormattedChunks.length; i++) { + await bot.api.sendMessage(numericChatId, firstSealedFormattedChunks[i]!) + } + for (const chunk of sealedChunks) { + const formattedChunks = splitMessage(formatTelegramOutboundText(chunk), TELEGRAM_TEXT_LIMIT) + for (const formattedChunk of formattedChunks) { + await bot.api.sendMessage(numericChatId, formattedChunk) + } + } + const sent = await bot.api.sendMessage(numericChatId, formatTelegramStreamingText(activeChunk)) + placeholders.set(chatId, { chatId, messageId: sent.message_id }) + } else { + await bot.api.editMessageText(numericChatId, placeholder.messageId, formatTelegramStreamingText(activeChunk)) + } } catch { /* ignore */ } } - } else if (isComplete && fullText.trim()) { - const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT) + } else if (isComplete && (prev + newText).trim()) { + const fullText = prev + newText + accumulatedText.set(chatId, fullText) + const chunks = splitMessage(formatTelegramOutboundText(fullText), TELEGRAM_TEXT_LIMIT) for (const chunk of chunks) { await bot.api.sendMessage(numericChatId, chunk) } + } else { + accumulatedText.set(chatId, prev + newText) } if (isComplete) { @@ -392,7 +427,11 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< const text = accumulatedText.get(chatId) if (text?.trim()) { try { - await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, text) + await bot.api.editMessageText( + numericChatId, + placeholders.get(chatId)!.messageId, + formatTelegramOutboundText(text), + ) } catch { /* ignore */ } } placeholders.delete(chatId) @@ -458,7 +497,7 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< const text = accumulatedText.get(chatId) if (text?.trim()) { try { - const chunks = splitMessage(text, TELEGRAM_TEXT_LIMIT) + const chunks = splitMessage(formatTelegramOutboundText(text), TELEGRAM_TEXT_LIMIT) await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, chunks[0]!) for (let i = 1; i < chunks.length; i++) { await bot.api.sendMessage(numericChatId, chunks[i]!)