mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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.
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
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 翻译逻辑测试
|
|
*
|
|
* 由于 grammy Bot 需要实际 Token 才能初始化,
|
|
* 这里测试的是不依赖 Bot 实例的核心翻译逻辑。
|
|
*/
|
|
|
|
describe('Telegram message formatting', () => {
|
|
describe('long message splitting', () => {
|
|
it('splits messages at Telegram 4096 char limit', () => {
|
|
const longText = 'a'.repeat(8000)
|
|
const chunks = splitMessage(longText, 4000)
|
|
expect(chunks.length).toBe(2)
|
|
expect(chunks[0]!.length).toBeLessThanOrEqual(4000)
|
|
expect(chunks[1]!.length).toBeLessThanOrEqual(4000)
|
|
})
|
|
|
|
it('keeps short messages as single chunk', () => {
|
|
const chunks = splitMessage('Hello World', 4000)
|
|
expect(chunks).toEqual(['Hello World'])
|
|
})
|
|
|
|
it('splits at paragraph boundary when possible', () => {
|
|
const text = 'A'.repeat(2000) + '\n\n' + 'B'.repeat(2000)
|
|
const chunks = splitMessage(text, 3000)
|
|
expect(chunks.length).toBe(2)
|
|
})
|
|
})
|
|
|
|
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')
|
|
expect(result).toContain('🔐')
|
|
expect(result).toContain('Bash')
|
|
expect(result).toContain('npm test')
|
|
expect(result).toContain('abcde')
|
|
})
|
|
|
|
it('formats Write file request', () => {
|
|
const result = formatPermissionRequest(
|
|
'Write',
|
|
{ file_path: '/src/index.ts', content: 'console.log("hello")' },
|
|
'fghij',
|
|
)
|
|
expect(result).toContain('Write')
|
|
expect(result).toContain('index.ts')
|
|
expect(result).toContain('fghij')
|
|
})
|
|
|
|
it('truncates long input in permission request', () => {
|
|
const longInput = { command: 'x'.repeat(500) }
|
|
const result = formatPermissionRequest('Bash', longInput, 'xxxxx')
|
|
expect(result.length).toBeLessThan(600)
|
|
})
|
|
})
|
|
|
|
describe('callback_data parsing', () => {
|
|
it('parses permit:requestId:yes format', () => {
|
|
expect(parsePermitCallbackData('permit:abcde:yes')).toEqual({ requestId: 'abcde', allowed: true })
|
|
})
|
|
|
|
it('parses permit:requestId:always format', () => {
|
|
expect(parsePermitCallbackData('permit:abcde:always')).toEqual({
|
|
requestId: 'abcde',
|
|
allowed: true,
|
|
rule: 'always',
|
|
})
|
|
})
|
|
|
|
it('parses permit:requestId:no format', () => {
|
|
expect(parsePermitCallbackData('permit:abcde:no')).toEqual({ requestId: 'abcde', allowed: false })
|
|
})
|
|
|
|
it('ignores non-permit callbacks', () => {
|
|
const data = 'other:action'
|
|
expect(data.startsWith('permit:')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('MarkdownV2 escaping', () => {
|
|
it('escapes underscores', () => {
|
|
expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
|
|
})
|
|
|
|
it('escapes multiple special chars', () => {
|
|
const result = escapeMarkdownV2('file.ts (line 42)')
|
|
expect(result).toBe('file\\.ts \\(line 42\\)')
|
|
})
|
|
|
|
it('handles code blocks safely', () => {
|
|
const result = escapeMarkdownV2('`code`')
|
|
expect(result).toBe('\\`code\\`')
|
|
})
|
|
})
|
|
|
|
describe('whitelist logic', () => {
|
|
it('empty allowedUsers means allow all', () => {
|
|
const allowedUsers: number[] = []
|
|
const isAllowed = (userId: number) =>
|
|
allowedUsers.length === 0 || allowedUsers.includes(userId)
|
|
expect(isAllowed(12345)).toBe(true)
|
|
expect(isAllowed(99999)).toBe(true)
|
|
})
|
|
|
|
it('non-empty allowedUsers filters correctly', () => {
|
|
const allowedUsers = [111, 222]
|
|
const isAllowed = (userId: number) =>
|
|
allowedUsers.length === 0 || allowedUsers.includes(userId)
|
|
expect(isAllowed(111)).toBe(true)
|
|
expect(isAllowed(222)).toBe(true)
|
|
expect(isAllowed(333)).toBe(false)
|
|
})
|
|
})
|
|
})
|