Merge Telegram streaming readability fix into local main

The Telegram adapter fix was developed in a detached worktree from a shared base while local main already contained newer desktop fixes. Merge the narrow worktree commit into main so the local checkout keeps those existing commits and gains the Telegram streaming behavior.

Constraint: Local main had already advanced independently after the worktree base.
Rejected: Rebase local main onto the worktree commit | rewrites the user's local main history unnecessarily.
Confidence: high
Scope-risk: narrow
Directive: Keep this as a local integration merge; do not push unless explicitly requested.
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:35:15 +08:00
commit 1431a8a1c1
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]!)