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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-21 17:34:51 +08:00
parent a159ee3511
commit 05b183158c
5 changed files with 338 additions and 9 deletions

View File

@ -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' })

View File

@ -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<string, unknown>

View File

@ -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')

View File

@ -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
}

View File

@ -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<string> {
async function flushToTelegram(chatId: string, newText: string, isComplete: boolean): Promise<void> {
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]!)