cc-haha/adapters/feishu/markdown-style.ts
程序员阿江(Relakkes) ae586b0d9b feat(feishu): rewrite streaming to CardKit Schema 2.0 with proper markdown
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>
2026-04-11 21:04:22 +08:00

213 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Feishu 卡片 Markdown 样式优化
*
* 背景:
* - 飞书卡片的 `tag: 'markdown'` 元素对 H1~H3 标题有已知渲染异常(字面量显示)。
* 必须降级为 H4/H5 才能正常渲染。
* - Schema 2.0 CardKit 支持完整的 markdown 但需要手动加 `<br>` 间距避免标题/表格/
* 代码块贴得太紧。
* - 卡片有表格数量上限FEISHU_CARD_TABLE_LIMIT=3超出会触发 230099/11310。
* - 图片必须是飞书上传过的 `img_xxx` key其它 URL 会触发 CardKit 错误 200570。
*
* 实现参考: openclaw-lark/src/card/markdown-style.ts + card-error.ts
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 飞书卡片表格数量上限 —— 超出 3 张触发 230099/11310openclaw 2026-03 实测) */
export const FEISHU_CARD_TABLE_LIMIT = 3
// ---------------------------------------------------------------------------
// Public: optimizeMarkdownForFeishu
// ---------------------------------------------------------------------------
/**
* 对将要放入 `tag: 'markdown'` 元素的 markdown 做安全预处理。
*
* - 标题降级: 若原文包含 H1~H3则 H2~H6 → H5H1 → H4
* - 代码块内容受保护,不会被降级
* - Schema 2.0: 连续标题/表格/代码块前后加 `<br>` 间距
* - 连续 3+ 空行压缩为 2
* - 删除非 `img_*` 的 markdown 图片引用(防止 CardKit 200570
* - 任何内部错误都 fallback 到原文,不阻塞消息发送
*
* @param text 原始 markdown
* @param cardVersion 卡片 schema 版本。默认 2 (Schema 2.0 CardKit)
* 1 对应老 Schema 1.0 fallback 路径(已不推荐)。
*/
export function optimizeMarkdownForFeishu(text: string, cardVersion = 2): string {
try {
let r = _optimizeMarkdownForFeishu(text, cardVersion)
r = stripInvalidImageKeys(r)
return r
} catch {
return text
}
}
function _optimizeMarkdownForFeishu(text: string, cardVersion: number): string {
// ── 1. 提取代码块,用占位符保护,处理后再还原 ─────────────────────
// 代码块内的 `#` / `|` 不能被标题降级或表格匹配误伤
const MARK = '___CB_'
const codeBlocks: string[] = []
let r = text.replace(/```[\s\S]*?```/g, (m) => {
return `${MARK}${codeBlocks.push(m) - 1}___`
})
// ── 2. 标题降级 ────────────────────────────────────────────────────
// 只有当原文档(不是保护后的 r包含 H1~H3 时才执行降级
// 顺序: 先 H2~H6 → H5再 H1 → H4
// 若先 H1→H4`####` 会被后面的 `#{2,6}` 再次匹配成 H5
const hasH1toH3 = /^#{1,3} /m.test(text)
if (hasH1toH3) {
r = r.replace(/^#{2,6} (.+)$/gm, '##### $1') // H2~H6 → H5
r = r.replace(/^# (.+)$/gm, '#### $1') // H1 → H4
}
// ── 3. Schema 2.0 段落间距 ────────────────────────────────────────
if (cardVersion >= 2) {
// 3a. 连续标题之间加 <br>
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2')
// 3b. 非表格行直接跟表格行 → 先补空行(保证后续规则生效)
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2')
// 3c. 表格前: 在空行之前插入 <br>(即 `\n\n|` → `\n<br>\n\n|`
r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1')
// 3d. 表格后: 在表格块末尾追加 <br>(跳过后接分隔线/标题/加粗/文末)
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, (m, _table, offset) => {
const after = r.slice(offset + m.length).replace(/^\n+/, '')
if (!after || /^(---|#{4,5} |\*\*)/.test(after)) return m
return m + '\n<br>\n'
})
// 3e. 表格前是普通文本: 只保留 <br>,去掉多余空行
// "text\n\n<br>\n\n|" → "text\n<br>\n|"
r = r.replace(/^((?!#{4,5} )(?!\*\*).+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n$3')
// 3f. 表格前是加粗行: <br> 紧贴加粗行,空行保留在后面
// "**bold**\n\n<br>\n\n|" → "**bold**\n<br>\n\n|"
r = r.replace(/^(\*\*.+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n\n$3')
// 3g. 表格后是普通文本: 去掉多余空行
// "| row |\n\n<br>\ntext" → "| row |\n<br>\ntext"
r = r.replace(/(\|[^\n]*\n)\n(<br>\n)((?!#{4,5} )(?!\*\*))/gm, '$1$2$3')
}
// ── 4. 压缩多余空行3 个以上连续换行 → 2 个)────────────────────
// 必须在还原代码块之前做,否则代码块内部的连续换行会被误伤
r = r.replace(/\n{3,}/g, '\n\n')
// ── 5. 还原代码块 ─────────────────────────────────────────────────
// Schema 2.0 时前后加 <br>,让代码块与周围段落拉开距离
codeBlocks.forEach((block, i) => {
const replacement = cardVersion >= 2 ? `\n<br>\n${block}\n<br>\n` : block
r = r.replace(`${MARK}${i}___`, replacement)
})
return r
}
// ---------------------------------------------------------------------------
// stripInvalidImageKeys
// ---------------------------------------------------------------------------
/** 匹配完整的 markdown 图片语法: `![alt](value)` */
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
/**
* 删除 value 不是飞书 image key (`img_xxx`) 的 markdown 图片引用。
* 防止 CardKit 错误 200570unknown image key
*
* HTTP URL 和本地路径也会被删除 —— 上游ImageResolver负责把它们转成
* `img_xxx`,此函数是 safety net。
*/
function stripInvalidImageKeys(text: string): string {
if (!text.includes('![')) return text
return text.replace(IMAGE_RE, (fullMatch, _alt, value) => {
if (value.startsWith('img_')) return fullMatch
return ''
})
}
// ---------------------------------------------------------------------------
// Table limiting: sanitizeTextForCard
// ---------------------------------------------------------------------------
export type MarkdownTableMatch = {
index: number
length: number
raw: string
}
/**
* 扫描正文里会被飞书卡片**实际渲染**的 markdown 表格位置。
*
* 代码块内的示例表格不会被飞书解析成卡片表格元素,因此要先排除。
* 这份结果供 `sanitizeTextForCard` 判断是否需要降级多余的表格。
*/
export function findMarkdownTablesOutsideCodeBlocks(text: string): MarkdownTableMatch[] {
// 先扫描代码块区间
const codeBlockRanges: Array<{ start: number; end: number }> = []
const codeBlockRegex = /```[\s\S]*?```/g
let cbMatch = codeBlockRegex.exec(text)
while (cbMatch != null) {
codeBlockRanges.push({
start: cbMatch.index,
end: cbMatch.index + cbMatch[0].length,
})
cbMatch = codeBlockRegex.exec(text)
}
const isInsideCodeBlock = (idx: number): boolean =>
codeBlockRanges.some((range) => idx >= range.start && idx < range.end)
// 扫描表格header | sep | body...
const tableRegex = /\|.+\|[\r\n]+\|[-:| ]+\|[\s\S]*?(?=\n\n|\n(?!\|)|$)/g
const matches: MarkdownTableMatch[] = []
let tableMatch = tableRegex.exec(text)
while (tableMatch != null) {
if (!isInsideCodeBlock(tableMatch.index)) {
matches.push({
index: tableMatch.index,
length: tableMatch[0].length,
raw: tableMatch[0],
})
}
tableMatch = tableRegex.exec(text)
}
return matches
}
/**
* 对正文里超出 `tableLimit` 张的 markdown 表格降级为代码块,防止触发
* 230099/11310。前 `tableLimit` 张保持原样(卡片正常渲染),其余用
* 反引号包裹(飞书会当成 code block 而不是表格)。
*/
export function sanitizeTextForCard(
text: string,
tableLimit: number = FEISHU_CARD_TABLE_LIMIT,
): string {
const matches = findMarkdownTablesOutsideCodeBlocks(text)
if (matches.length <= tableLimit) return text
return wrapTablesBeyondLimit(text, matches, Math.max(tableLimit, 0))
}
function wrapTablesBeyondLimit(
text: string,
matches: readonly MarkdownTableMatch[],
keepCount: number,
): string {
if (matches.length <= keepCount) return text
// 从后往前替换,避免前面的替换导致后面的 offset 错乱
let result = text
for (let i = matches.length - 1; i >= keepCount; i--) {
const { index, length, raw } = matches[i]!
const replacement = '```\n' + raw + '\n```'
result = result.slice(0, index) + replacement + result.slice(index + length)
}
return result
}