cc-haha/adapters/feishu/card-errors.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

152 lines
5.1 KiB
TypeScript
Raw Permalink 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 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)
)
}