mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Replace legacy im.message.patch streaming path with a Feishu CardKit
Schema 2.0 pipeline mirroring openclaw-lark's implementation. Desktop
now renders real formatted markdown (headings, tables, code blocks)
instead of literal text, and streaming output updates per chat without
cross-talk.
New modules:
- card-errors.ts: structured Lark SDK error parsing for code 230020
(rate limit -> skip frame) and 230099/11310 (table limit -> disable
CardKit streaming and fall back to settings+update at finalize)
- flush-controller.ts: mutex + needsReflush + long-gap batching
throttler for per-chat flush coordination
- cardkit.ts: thin wrapper over client.cardkit.v1.* (card.create +
im.message.{create,reply} + cardElement.content + card.settings +
card.update), with monotonic sequence and CardKitApiError
- streaming-card.ts: per-chat StreamingCard state machine covering
idle -> creating -> streaming -> finalizing -> completed/aborted,
including patch fallback when CardKit create/send fails
index.ts rewrite:
- replaces MessageBuffer/chatStates/buffers maps with a single
streamingCards Map<chatId, StreamingCard>
- content_start{text}/content_delta create or reuse the card; finalize
runs on message_complete; abort renders a red error card
- /clear and other silent commands stay out of the streaming-card path
so they don't leave empty cards
- all command handlers + normal chat are wrapped in the per-chat
enqueue() serial queue, preventing reply reordering when commands
are fired rapidly
- createSessionForChat() now first resets any stale WS session before
calling connectSession, fixing /projects pick_project leaving the
old session bound to the previous workDir
- normal messages pre-create the card before sendUserMessage so users
see a "☁️ 正在思考中..." loading indicator immediately
- content_start{tool_use} no longer finalizes the current card, letting
one user turn's full text stay in a single card
markdown-style.ts:
- default cardVersion changed from 1 to 2 (Schema 2.0)
- headings H2~H6 demoted to H5, H1 to H4, only when source has H1~H3
- Schema 2.0 <br> padding around headings, tables, code blocks
- stripInvalidImageKeys() drops non-img_* image references
- FEISHU_CARD_TABLE_LIMIT=3 + sanitizeTextForCard() wraps 4th+ table
in a fenced code block to avoid 230099/11310
Tests: 182 pass, 0 fail, 383 expect() calls across 6 files. tsc clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
/**
|
||
* markdown-style 单元测试
|
||
*
|
||
* 覆盖:
|
||
* - 标题降级 (H1~H3 workaround)
|
||
* - 代码块保护
|
||
* - 空行压缩
|
||
* - Schema 2.0: 连续标题/表格/代码块 <br> 间距
|
||
* - stripInvalidImageKeys
|
||
* - sanitizeTextForCard (表格数限制)
|
||
* - findMarkdownTablesOutsideCodeBlocks
|
||
*/
|
||
|
||
import { describe, it, expect } from 'bun:test'
|
||
import {
|
||
optimizeMarkdownForFeishu,
|
||
sanitizeTextForCard,
|
||
findMarkdownTablesOutsideCodeBlocks,
|
||
FEISHU_CARD_TABLE_LIMIT,
|
||
} from '../markdown-style.js'
|
||
|
||
// 默认 cardVersion=2 的 shortcut
|
||
const opt = (text: string, v?: number) => optimizeMarkdownForFeishu(text, v)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 标题降级
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('optimizeMarkdownForFeishu: 标题降级', () => {
|
||
it('H1 → H4 (cardVersion=1 简化检查)', () => {
|
||
expect(opt('# Title', 1)).toBe('#### Title')
|
||
})
|
||
|
||
it('H2 → H5 (cardVersion=1)', () => {
|
||
expect(opt('## Title', 1)).toBe('##### Title')
|
||
})
|
||
|
||
it('H3 → H5 (cardVersion=1)', () => {
|
||
expect(opt('### Title', 1)).toBe('##### Title')
|
||
})
|
||
|
||
it('混合 H1+H2+H3 全部降级 (cardVersion=1)', () => {
|
||
expect(opt('# H1\n## H2\n### H3', 1)).toBe('#### H1\n##### H2\n##### H3')
|
||
})
|
||
|
||
it('纯 H4 文档不触发降级 (cardVersion=1)', () => {
|
||
// 触发条件: 原文必须有 H1~H3
|
||
expect(opt('#### Already H4', 1)).toBe('#### Already H4')
|
||
})
|
||
|
||
it('同时存在 H1 和 H4: H1→H4, 原 H4 → H5 (cardVersion=1)', () => {
|
||
expect(opt('# Top\n#### Sub', 1)).toBe('#### Top\n##### Sub')
|
||
})
|
||
|
||
it('# 后必须有空格才算标题', () => {
|
||
expect(opt('#notaheading', 1)).toBe('#notaheading')
|
||
})
|
||
|
||
it('顺序保证: # 降成 #### 后不会被 #{2,6} 再次吃成 #####', () => {
|
||
// openclaw-lark 源码里的关键注释:顺序不能颠倒
|
||
expect(opt('# Top', 1)).toBe('#### Top')
|
||
})
|
||
|
||
it('无标题文本原样返回', () => {
|
||
expect(opt('just plain text', 1)).toBe('just plain text')
|
||
})
|
||
|
||
it('默认 cardVersion=2 下也能正确降级标题', () => {
|
||
const out = opt('# Title')
|
||
expect(out).toContain('#### Title')
|
||
expect(out).not.toMatch(/^# Title$/m)
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 代码块保护
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('optimizeMarkdownForFeishu: 代码块保护', () => {
|
||
it('代码块内的 # 不被降级 (cardVersion=1)', () => {
|
||
const input = '```\n# not a heading\n## also not\n```'
|
||
expect(opt(input, 1)).toBe(input)
|
||
})
|
||
|
||
it('外部 H1 降级,代码块内 # 保持 (cardVersion=1)', () => {
|
||
const input = '# Real heading\n\n```\n# inside code\n```'
|
||
expect(opt(input, 1)).toBe('#### Real heading\n\n```\n# inside code\n```')
|
||
})
|
||
|
||
it('语言标记的 fenced 代码块也受保护 (cardVersion=1)', () => {
|
||
const input = '## Section\n\n```python\n# python comment\n### not a heading\n```'
|
||
expect(opt(input, 1)).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```')
|
||
})
|
||
|
||
it('多个代码块按顺序保护与还原 (cardVersion=1)', () => {
|
||
const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```'
|
||
expect(opt(input, 1)).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```')
|
||
})
|
||
|
||
it('默认 cardVersion=2 下代码块内 # 仍受保护(语义断言)', () => {
|
||
const out = opt('# Heading\n\n```\n# inside\n```')
|
||
expect(out).toContain('#### Heading') // 外部 H1 降级
|
||
expect(out).toContain('# inside') // 代码块内保留
|
||
expect(out).toContain('```') // fence 保留
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 空行压缩
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('optimizeMarkdownForFeishu: 空行压缩', () => {
|
||
it('3 个换行 → 2 个 (cardVersion=1)', () => {
|
||
expect(opt('line1\n\n\nline2', 1)).toBe('line1\n\nline2')
|
||
})
|
||
|
||
it('5 个换行 → 2 个 (cardVersion=1)', () => {
|
||
expect(opt('line1\n\n\n\n\nline2', 1)).toBe('line1\n\nline2')
|
||
})
|
||
|
||
it('2 个换行保留 (cardVersion=1)', () => {
|
||
expect(opt('line1\n\nline2', 1)).toBe('line1\n\nline2')
|
||
})
|
||
|
||
it('代码块内部连续换行被保留 (cardVersion=1)', () => {
|
||
const input = '# Title\n\n```\nline1\n\n\nline2\n```'
|
||
expect(opt(input, 1)).toBe('#### Title\n\n```\nline1\n\n\nline2\n```')
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Schema 2.0: <br> 间距
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('optimizeMarkdownForFeishu: Schema 2.0 <br> 间距', () => {
|
||
it('cardVersion=2 默认在代码块前后加 <br>', () => {
|
||
const out = opt('text\n\n```\ncode\n```')
|
||
// 代码块前后应包裹 <br>
|
||
expect(out).toContain('<br>\n```')
|
||
expect(out).toContain('```\n<br>')
|
||
})
|
||
|
||
it('cardVersion=1 代码块前后不加 <br>', () => {
|
||
const out = opt('text\n\n```\ncode\n```', 1)
|
||
expect(out).not.toContain('<br>')
|
||
})
|
||
|
||
it('cardVersion=2 连续标题之间加 <br>', () => {
|
||
const out = opt('# A\n# B')
|
||
// H1 降级为 H4,之间插入 <br>
|
||
expect(out).toMatch(/#### A\n<br>\n#### B/)
|
||
})
|
||
|
||
it('cardVersion=2 表格前后加 <br>', () => {
|
||
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
|
||
const out = opt(input)
|
||
// 表格前: <br> 紧贴文本行(规则 3e 压缩多余空行)
|
||
expect(out).toMatch(/text before\n<br>\n\| col1/)
|
||
// 表格后: <br> 跟两个换行到下一段文本
|
||
expect(out).toMatch(/\| v1 \| v2 \|\n<br>\n\ntext after/)
|
||
})
|
||
|
||
it('cardVersion=1 表格前后不加 <br>', () => {
|
||
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
|
||
const out = opt(input, 1)
|
||
expect(out).not.toContain('<br>')
|
||
})
|
||
|
||
it('代码块内的 | 不被当表格处理 (Schema 2.0)', () => {
|
||
const input = '```\n| in code | not a table |\n|---|---|\n```'
|
||
const out = opt(input)
|
||
// 代码块本体应完整保留
|
||
expect(out).toContain('| in code | not a table |')
|
||
// 代码块外应该没有出现表格 br 标记(因为代码块内不算表格)
|
||
// 代码块本身会被 <br> 包裹(Schema 2.0)但不会在 | 周围单独加 <br>
|
||
expect(out).toMatch(/<br>\n```\n\| in code/)
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// stripInvalidImageKeys
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('optimizeMarkdownForFeishu: stripInvalidImageKeys', () => {
|
||
it('img_* 图片 key 保留', () => {
|
||
const out = opt('前缀  后缀')
|
||
expect(out).toContain('')
|
||
})
|
||
|
||
it('http:// URL 图片被删除', () => {
|
||
const out = opt('前缀  后缀')
|
||
expect(out).toBe('前缀 后缀')
|
||
})
|
||
|
||
it('https:// URL 图片被删除', () => {
|
||
const out = opt('')
|
||
expect(out).toBe('')
|
||
})
|
||
|
||
it('本地路径被删除', () => {
|
||
const out = opt('')
|
||
expect(out).toBe('')
|
||
})
|
||
|
||
it('无图片文本原样', () => {
|
||
expect(opt('no images here', 1)).toBe('no images here')
|
||
})
|
||
|
||
it('混合: img_ 保留,URL 删除', () => {
|
||
const out = opt(' 和 ')
|
||
expect(out).toContain('')
|
||
expect(out).not.toContain('bad.com')
|
||
expect(out).not.toContain('![drop]')
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// findMarkdownTablesOutsideCodeBlocks
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('findMarkdownTablesOutsideCodeBlocks', () => {
|
||
it('识别单张表格', () => {
|
||
const text = '| a | b |\n|---|---|\n| 1 | 2 |'
|
||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||
expect(matches.length).toBe(1)
|
||
expect(matches[0]!.raw).toContain('| a | b |')
|
||
})
|
||
|
||
it('识别多张表格', () => {
|
||
const text =
|
||
'| a | b |\n|---|---|\n| 1 | 2 |\n\ntext\n\n| x | y |\n|---|---|\n| 3 | 4 |'
|
||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||
expect(matches.length).toBe(2)
|
||
})
|
||
|
||
it('代码块内的 | 不被算作表格', () => {
|
||
const text = '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```'
|
||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||
expect(matches.length).toBe(0)
|
||
})
|
||
|
||
it('代码块 + 外部表格: 只识别外部的', () => {
|
||
const text =
|
||
'```\n| in | code |\n|---|---|\n| 1 | 2 |\n```\n\n| real | table |\n|---|---|\n| a | b |'
|
||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||
expect(matches.length).toBe(1)
|
||
expect(matches[0]!.raw).toContain('real')
|
||
})
|
||
|
||
it('无表格文本返回空数组', () => {
|
||
expect(findMarkdownTablesOutsideCodeBlocks('just text').length).toBe(0)
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// sanitizeTextForCard
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('sanitizeTextForCard: 表格数量限制', () => {
|
||
function makeTable(label: string): string {
|
||
return `| ${label} h1 | h2 |\n|---|---|\n| v1 | v2 |`
|
||
}
|
||
|
||
it('表格数 ≤ 3 时原样返回', () => {
|
||
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
|
||
expect(sanitizeTextForCard(text)).toBe(text)
|
||
})
|
||
|
||
it('恰好 3 张表格原样返回', () => {
|
||
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
|
||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||
expect(matches.length).toBe(3)
|
||
expect(sanitizeTextForCard(text)).toBe(text)
|
||
})
|
||
|
||
it('4 张表格: 前 3 张保留,第 4 张包裹成 code block', () => {
|
||
const text = [makeTable('A'), makeTable('B'), makeTable('C'), makeTable('D')].join('\n\n')
|
||
const out = sanitizeTextForCard(text)
|
||
// 前 3 张表格原样
|
||
expect(out).toContain(makeTable('A'))
|
||
expect(out).toContain(makeTable('B'))
|
||
expect(out).toContain(makeTable('C'))
|
||
// 第 4 张被包裹
|
||
expect(out).toContain('```\n' + makeTable('D') + '\n```')
|
||
})
|
||
|
||
it('自定义 limit=1: 第 1 张保留,之后全部包裹', () => {
|
||
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
|
||
const out = sanitizeTextForCard(text, 1)
|
||
expect(out).toContain(makeTable('A'))
|
||
expect(out).toContain('```\n' + makeTable('B') + '\n```')
|
||
expect(out).toContain('```\n' + makeTable('C') + '\n```')
|
||
})
|
||
|
||
it('limit=0: 全部包裹', () => {
|
||
const text = makeTable('Solo')
|
||
const out = sanitizeTextForCard(text, 0)
|
||
expect(out).toContain('```\n' + makeTable('Solo') + '\n```')
|
||
})
|
||
|
||
it('无表格原样返回', () => {
|
||
expect(sanitizeTextForCard('no tables here')).toBe('no tables here')
|
||
})
|
||
|
||
it('FEISHU_CARD_TABLE_LIMIT 默认值 = 3', () => {
|
||
expect(FEISHU_CARD_TABLE_LIMIT).toBe(3)
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 边界与真实场景
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('optimizeMarkdownForFeishu: 边界与真实场景', () => {
|
||
it('screenshot 里的 OpenCutSkill 项目结构报告', () => {
|
||
const input = `## OpenCutSkill 项目架构概览
|
||
|
||
### 1. 项目定位
|
||
|
||
Screen Studio 视频自动剪辑工具。
|
||
|
||
### 2. 模块结构
|
||
|
||
\`\`\`
|
||
opencutskill/
|
||
├── cli/
|
||
├── core/
|
||
└── tests/
|
||
\`\`\``
|
||
const out = opt(input)
|
||
// 所有 H2~H3 应被降级为 H5
|
||
expect(out).toContain('##### OpenCutSkill 项目架构概览')
|
||
expect(out).toContain('##### 1. 项目定位')
|
||
expect(out).toContain('##### 2. 模块结构')
|
||
// 代码块内容原封不动
|
||
expect(out).toContain('opencutskill/')
|
||
expect(out).toContain('├── cli/')
|
||
// 原始 ## 字面量不残留
|
||
expect(out).not.toMatch(/^## OpenCutSkill/m)
|
||
expect(out).not.toMatch(/^### 1\./m)
|
||
// 代码块前后有 <br>(Schema 2.0 默认)
|
||
expect(out).toContain('<br>\n```')
|
||
expect(out).toContain('```\n<br>')
|
||
})
|
||
|
||
it('异常输入 fallback 到原文不抛错', () => {
|
||
expect(() => opt('\u0000\uFFFF```unclosed')).not.toThrow()
|
||
})
|
||
|
||
it('空字符串返回空字符串', () => {
|
||
expect(opt('')).toBe('')
|
||
})
|
||
})
|