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]!)