mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53: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>
152 lines
5.1 KiB
TypeScript
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)
|
||
)
|
||
}
|