mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
commit
1431a8a1c1
@ -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' })
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
72
adapters/telegram/format.ts
Normal file
72
adapters/telegram/format.ts
Normal 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
|
||||
}
|
||||
@ -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]!)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user