mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Feishu CardKit reply sends can fail after the platform has already accepted the message. Reusing one outbound UUID across the initial reply send and the create fallback keeps the fallback idempotent instead of risking a second visible message. Constraint: Feishu im.message.reply and im.message.create both accept uuid idempotency keys Rejected: Removing the create fallback entirely | would regress CardKit create/send fallback behavior when the original reply truly did not land Confidence: high Scope-risk: narrow Directive: Keep reply fallback sends on the same outbound UUID; do not regenerate it per API attempt Tested: bun test feishu/__tests__/streaming-card.test.ts Tested: bun test feishu/__tests__/cardkit.test.ts feishu/__tests__/streaming-card.test.ts Tested: cd adapters && bun run test:feishu Tested: bun run check:adapters Tested: cd adapters && bunx tsc --noEmit
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
/**
|
||
* 飞书 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
|
||
|
||
const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000
|
||
|
||
function getImCardRequestTimeoutMs(): number {
|
||
const raw = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS
|
||
const parsed = raw ? Number(raw) : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
|
||
return Number.isFinite(parsed) && parsed > 0
|
||
? parsed
|
||
: DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS
|
||
}
|
||
|
||
export async function withImCardRequestTimeout<T>(
|
||
api: string,
|
||
request: () => Promise<T>,
|
||
): Promise<T> {
|
||
const timeoutMs = getImCardRequestTimeoutMs()
|
||
let timer: ReturnType<typeof setTimeout> | undefined
|
||
|
||
try {
|
||
return await Promise.race([
|
||
Promise.resolve().then(request),
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => {
|
||
reject(new Error(`${api} timed out after ${timeoutMs}ms`))
|
||
}, timeoutMs)
|
||
}),
|
||
])
|
||
} finally {
|
||
if (timer) clearTimeout(timer)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 withImCardRequestTimeout('card.create', () =>
|
||
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,
|
||
uuid?: string,
|
||
): Promise<string> {
|
||
const content = JSON.stringify({
|
||
type: 'card',
|
||
data: { card_id: cardId },
|
||
})
|
||
|
||
if (replyToMessageId) {
|
||
const resp = await withImCardRequestTimeout('im.message.reply', () =>
|
||
client.im.message.reply({
|
||
path: { message_id: replyToMessageId },
|
||
data: { content, msg_type: 'interactive', uuid },
|
||
}),
|
||
)
|
||
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 withImCardRequestTimeout('im.message.create', () =>
|
||
client.im.message.create({
|
||
params: { receive_id_type: 'chat_id' },
|
||
data: {
|
||
receive_id: chatId,
|
||
msg_type: 'interactive',
|
||
content,
|
||
uuid,
|
||
},
|
||
}),
|
||
)
|
||
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 withImCardRequestTimeout('cardElement.content', () =>
|
||
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 withImCardRequestTimeout('card.settings', () =>
|
||
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 withImCardRequestTimeout('card.update', () =>
|
||
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}`,
|
||
})
|
||
}
|