Files
程序员阿江(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

152 lines
5.1 KiB
TypeScript

/**
* Feishu CardKit API 错误码解析与谓词
*
* 参考实现: openclaw-lark/src/card/card-error.ts + src/core/api-error.ts
*
* Lark SDK 抛出的错误对象结构有多种:
* - SDK 把 Feishu 的 {code, msg} 直接挂在 error 对象上
* - Axios 风格: error.response.data.{code, msg}
* - data.code 嵌套(某些包装层)
*
* 此模块把这些统一成 { code, subCode, errMsg } 结构,
* 供 streaming-card-controller 判断是否跳帧重试、或降级到 Patch 路径。
*/
// ---------------------------------------------------------------------------
// Error code constants
// ---------------------------------------------------------------------------
/** 卡片 API 级别错误码。 */
export const CARD_ERROR = {
/** 发送频率限制。需跳过当前帧,下次 flush 继续。 */
RATE_LIMITED: 230020,
/** 卡片内容创建失败(通用码,需看子错误确认具体原因)。 */
CARD_CONTENT_FAILED: 230099,
} as const
/**
* 230099 的子错误码,嵌套在 msg 的 `ErrCode: xxx` 字段中。
* 11310 是通用的"元素超限"码,需配合 errMsg 匹配具体原因。
*/
export const CARD_CONTENT_SUB_ERROR = {
/** 卡片元素(表格等)数量超限 */
ELEMENT_LIMIT: 11310,
} as const
// ---------------------------------------------------------------------------
// Code extraction
// ---------------------------------------------------------------------------
function coerceCode(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
/**
* 从 Lark SDK 抛错对象中提取飞书 API code。支持三种结构:
* - `{ code }` (SDK 直接挂载)
* - `{ data: { code } }` (响应体嵌套)
* - `{ response: { data: { code } } }` (Axios 风格)
*/
export function extractLarkApiCode(err: unknown): number | undefined {
if (!err || typeof err !== 'object') return undefined
const e = err as {
code?: unknown
data?: { code?: unknown }
response?: { data?: { code?: unknown } }
}
return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code)
}
// ---------------------------------------------------------------------------
// Sub-error extraction
// ---------------------------------------------------------------------------
/**
* 从 msg 字符串里提取子错误码(`ErrCode: xxx`)。
*
* 示例输入:
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ..."
* 返回: 11310
*/
export function extractSubCode(msg: string): number | null {
const match = /ErrCode:\s*(\d+)/.exec(msg)
if (!match) return null
const code = Number(match[1])
return Number.isFinite(code) ? code : null
}
// ---------------------------------------------------------------------------
// Structured error parsing
// ---------------------------------------------------------------------------
export type CardApiErrorInfo = {
code: number
subCode: number | null
errMsg: string
}
/**
* 从任意抛错对象中解析卡片 API 错误结构。
*
* 返回 { code, subCode, errMsg }。无法提取 code 时返回 null。
*/
export function parseCardApiError(err: unknown): CardApiErrorInfo | null {
const code = extractLarkApiCode(err)
if (code === undefined) return null
// 按优先级提取 msg 文本
let errMsg = ''
if (err && typeof err === 'object') {
const e = err as {
msg?: unknown
message?: unknown
response?: { data?: { msg?: unknown } }
}
if (typeof e.msg === 'string') {
errMsg = e.msg
} else if (typeof e.response?.data?.msg === 'string') {
errMsg = e.response.data.msg
} else if (typeof e.message === 'string') {
errMsg = e.message
}
}
const subCode = extractSubCode(errMsg)
return { code, subCode, errMsg }
}
// ---------------------------------------------------------------------------
// Helper predicates
// ---------------------------------------------------------------------------
/** 判断错误是否为卡片发送频率限制(230020)。 */
export function isCardRateLimitError(err: unknown): boolean {
const parsed = parseCardApiError(err)
if (!parsed) return false
return parsed.code === CARD_ERROR.RATE_LIMITED
}
/**
* 判断错误是否为卡片表格数超限。
*
* 匹配条件: code 230099 + subCode 11310 + errMsg 含 "table number over limit"
* (11310 是通用元素超限码,光靠它不够;必须同时检查 errMsg 锁定是表格数量问题)。
*
* 实际生产错误格式(openclaw-lark 2026-03 实测):
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
*/
export function isCardTableLimitError(err: unknown): boolean {
const parsed = parseCardApiError(err)
if (!parsed) return false
return (
parsed.code === CARD_ERROR.CARD_CONTENT_FAILED &&
parsed.subCode === CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT &&
/table number over limit/i.test(parsed.errMsg)
)
}