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

295 lines
8.9 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.

/**
* 飞书 CardKit API 薄封装
*
* 这是生产路径的核心openclaw-lark 的 CardKit 主路径等价实现。
*
* 五步流程:
* 1. createCardEntity() —— 创建卡片实体,返回 card_id
* 2. sendCardAsMessage() —— 通过 IM 消息把卡片挂到聊天窗,返回 message_id
* 3. streamCardContent() —— 循环调用,按 element_id 增量追加文本
* 4. setCardStreamingMode() —— 关闭流式模式(收尾前必须做)
* 5. updateCardKitCard() —— 全量替换卡片为最终态
*
* 关键约束:
* - 每次 3/4/5 类调用必须携带**单调递增**的 sequence否则飞书拒绝
* - streamCardContent 传的是**完整累计文本**,不是 delta
* - 必须关闭 streaming_mode 后卡片才能被用户交互
*
* 参考实现: openclaw-lark/src/card/cardkit.ts
*/
import type * as Lark from '@larksuiteoapi/node-sdk'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 流式 markdown 元素的固定 element_id。卡片 JSON 里用这个 id 标记要被
* `cardElement.content()` 更新的那一个 markdown 元素。 */
export const STREAMING_ELEMENT_ID = 'streaming_content'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* SDK 返回的通用响应结构。
* SDK 的 TypeScript 类型不完整,运行时实际返回 { code, msg, data }。
* 我们统一当成 CardKitResponse 处理以免到处 `as any`。
*/
type CardKitResponse = {
code?: number
msg?: string
data?: Record<string, unknown>
[key: string]: unknown
}
/** 非零 code 时抛出的结构化错误。字段与 Lark SDK 的标准错误对齐,
* 可被 card-errors.ts 的 parseCardApiError 识别。 */
export class CardKitApiError extends Error {
readonly code: number
readonly msg: string
constructor(params: { api: string; code: number; msg: string; context: string }) {
const { api, code, msg, context } = params
super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`)
this.name = 'CardKitApiError'
this.code = code
this.msg = msg
}
}
type LarkClient = Lark.Client
// ---------------------------------------------------------------------------
// Response check
// ---------------------------------------------------------------------------
/**
* 检查 CardKit 响应的 body-level code。非 0 → 抛 CardKitApiError。
*
* Fail-fast 策略: 让 streaming-card 用 try/catch 配合 card-errors 统一
* 判断是速率限制还是真错误。
*/
function assertCardKitOk(params: {
resp: CardKitResponse
api: string
context: string
}): void {
const { resp, api, context } = params
const code = resp.code
if (code !== undefined && code !== 0) {
throw new CardKitApiError({
api,
code,
msg: typeof resp.msg === 'string' ? resp.msg : '',
context,
})
}
}
// ---------------------------------------------------------------------------
// Step 1 — createCardEntity
// ---------------------------------------------------------------------------
/**
* 创建一张 CardKit 卡片实体,返回 card_id。
*
* 此时卡片还没挂到任何聊天窗。需要再调 sendCardAsMessage 才能显示。
*
* @param client Lark SDK client
* @param card Schema 2.0 格式的卡片 JSON
* @returns 飞书分配的 card_id失败时抛错
*/
export async function createCardEntity(
client: LarkClient,
card: Record<string, unknown>,
): Promise<string> {
// SDK 返回类型不完整cast 到运行时实际结构
const resp = (await client.cardkit.v1.card.create({
data: {
type: 'card_json',
data: JSON.stringify(card),
},
})) as unknown as CardKitResponse
assertCardKitOk({
resp,
api: 'card.create',
context: `cardLen=${JSON.stringify(card).length}`,
})
// 兼容不同 SDK 包装层data.card_id 优先,回退顶层 card_id
const cardId =
(resp.data?.card_id as string | undefined) ??
(resp.card_id as string | undefined)
if (!cardId) {
throw new CardKitApiError({
api: 'card.create',
code: resp.code ?? -1,
msg: 'response missing card_id',
context: `resp=${JSON.stringify(resp).slice(0, 200)}`,
})
}
return cardId
}
// ---------------------------------------------------------------------------
// Step 2 — sendCardAsMessage
// ---------------------------------------------------------------------------
/**
* 把 CardKit 卡片通过 IM 消息挂到聊天窗。
*
* content 格式: `{"type":"card","data":{"card_id":"xxx"}}`
* msg_type 固定为 `interactive`。
*
* @param client Lark SDK client
* @param chatId 目标 chat_id
* @param cardId CardKit card_id由 createCardEntity 产生)
* @param replyToMessageId 可选。如果提供,走 im.message.reply否则 im.message.create
* @returns 飞书分配的 message_id
*/
export async function sendCardAsMessage(
client: LarkClient,
chatId: string,
cardId: string,
replyToMessageId?: string,
): Promise<string> {
const content = JSON.stringify({
type: 'card',
data: { card_id: cardId },
})
if (replyToMessageId) {
const resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content, msg_type: 'interactive' },
})
const messageId = resp.data?.message_id
if (!messageId) {
throw new CardKitApiError({
api: 'im.message.reply',
code: -1,
msg: 'response missing message_id',
context: `cardId=${cardId}`,
})
}
return messageId
}
const resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
msg_type: 'interactive',
content,
},
})
const messageId = resp.data?.message_id
if (!messageId) {
throw new CardKitApiError({
api: 'im.message.create',
code: -1,
msg: 'response missing message_id',
context: `chatId=${chatId} cardId=${cardId}`,
})
}
return messageId
}
// ---------------------------------------------------------------------------
// Step 3 — streamCardContent
// ---------------------------------------------------------------------------
/**
* 流式更新指定 element 的内容。飞书自动对比旧内容做 diff在客户端
* 渲染打字机效果。
*
* **重要**: `content` 必须传**完整累计文本**,不是 delta。
* sequence 必须**单调递增**,否则飞书拒绝。
*
* @param client Lark SDK client
* @param cardId CardKit card_id
* @param elementId 要更新的元素 id通常是 STREAMING_ELEMENT_ID
* @param content 完整累计文本
* @param sequence 单调递增序列号
*/
export async function streamCardContent(
client: LarkClient,
cardId: string,
elementId: string,
content: string,
sequence: number,
): Promise<void> {
const resp = (await client.cardkit.v1.cardElement.content({
data: { content, sequence },
path: { card_id: cardId, element_id: elementId },
})) as unknown as CardKitResponse
assertCardKitOk({
resp,
api: 'cardElement.content',
context: `seq=${sequence} len=${content.length}`,
})
}
// ---------------------------------------------------------------------------
// Step 4 — setCardStreamingMode
// ---------------------------------------------------------------------------
/**
* 开/关卡片的流式模式。收尾前必须调用 `streamingMode: false`
* 否则卡片会保持"只读"状态,用户点按钮没反应。
*/
export async function setCardStreamingMode(
client: LarkClient,
cardId: string,
streamingMode: boolean,
sequence: number,
): Promise<void> {
const resp = (await client.cardkit.v1.card.settings({
data: {
settings: JSON.stringify({ streaming_mode: streamingMode }),
sequence,
},
path: { card_id: cardId },
})) as unknown as CardKitResponse
assertCardKitOk({
resp,
api: 'card.settings',
context: `seq=${sequence} streaming_mode=${streamingMode}`,
})
}
// ---------------------------------------------------------------------------
// Step 5 — updateCardKitCard
// ---------------------------------------------------------------------------
/**
* 全量替换卡片为新的 JSON。用于流式结束后把卡片切换成最终态
* (加 header template、footer、完成样式等
*/
export async function updateCardKitCard(
client: LarkClient,
cardId: string,
card: Record<string, unknown>,
sequence: number,
): Promise<void> {
const resp = (await client.cardkit.v1.card.update({
data: {
card: { type: 'card_json', data: JSON.stringify(card) },
sequence,
},
path: { card_id: cardId },
})) as unknown as CardKitResponse
assertCardKitOk({
resp,
api: 'card.update',
context: `seq=${sequence} cardId=${cardId}`,
})
}