"use strict"; /** * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT * * CardKit streaming APIs for Lark/Feishu. */ import { LarkClient } from '../core/lark-client'; import { larkLogger } from '../core/lark-logger'; import { normalizeFeishuTarget, normalizeMessageId, resolveReceiveIdType } from '../core/targets'; import { runWithMessageUnavailableGuard } from '../core/message-unavailable'; const log = larkLogger('card/cardkit'); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * 记录 CardKit API 响应日志,检测错误码并抛出异常。 * * 默认 fail-fast:body-level 非零 code 视为业务错误,立即抛出, * 由调用方(streaming-card-controller 等)统一走 catch → guard 处理。 */ function logCardKitResponse(params) { const { resp, api, context } = params; const { code, msg } = resp; log.info(`cardkit ${api} response`, { code, msg, context }); if (code && code !== 0) { log.warn(`cardkit ${api} FAILED`, { code, msg, context, fullResponse: resp }); throw new Error(`cardkit ${api} FAILED: code=${code}, msg=${msg ?? ''}, ${context}`); } } // --------------------------------------------------------------------------- // CardKit streaming APIs // --------------------------------------------------------------------------- /** * Create a card entity via the CardKit API. * * Returns the card_id directly, bypassing the idConvert step. * The card can then be sent via IM API and streamed via CardKit. */ export async function createCardEntity(params) { const { cfg, card, accountId } = params; const client = LarkClient.fromCfg(cfg, accountId).sdk; // SDK 返回类型不完整,运行时包含 code/msg/data 字段 const response = (await client.cardkit.v1.card.create({ data: { type: 'card_json', data: JSON.stringify(card), }, })); // 兼容不同 SDK 包装层:优先 data.card_id,回退顶层 card_id const cardId = (response.data?.card_id ?? response.card_id) ?? null; logCardKitResponse({ resp: response, api: 'card.create', context: `cardId=${cardId}` }); return cardId; } /** * Stream text content to a specific card element using the CardKit API. * * The card automatically diffs the new content against the previous * content and renders incremental changes with a typewriter animation. * * @param params.cardId - CardKit card ID (from `convertMessageToCardId`). * @param params.elementId - The element ID to update (e.g. `STREAMING_ELEMENT_ID`). * @param params.content - The full cumulative text (not a delta). * @param params.sequence - Monotonically increasing sequence number. */ export async function streamCardContent(params) { const { cfg, cardId, elementId, content, sequence, accountId } = params; const client = LarkClient.fromCfg(cfg, accountId).sdk; // SDK 返回类型不完整,运行时包含 code/msg 字段 const resp = (await client.cardkit.v1.cardElement.content({ data: { content, sequence }, path: { card_id: cardId, element_id: elementId }, })); logCardKitResponse({ resp, api: 'cardElement.content', context: `seq=${sequence}, contentLen=${content.length}`, }); } /** * Fully replace a card using the CardKit API. * * Used for the final "complete" state update (with action buttons, green * header, etc.) after streaming finishes. * * @param params.cardId - CardKit card ID. * @param params.card - The new card JSON content. * @param params.sequence - Monotonically increasing sequence number. */ export async function updateCardKitCard(params) { const { cfg, cardId, card, sequence, accountId } = params; const client = LarkClient.fromCfg(cfg, accountId).sdk; // SDK 返回类型不完整,运行时包含 code/msg 字段 const resp = (await client.cardkit.v1.card.update({ data: { card: { type: 'card_json', data: JSON.stringify(card) }, sequence, }, path: { card_id: cardId }, })); logCardKitResponse({ resp, api: 'card.update', context: `seq=${sequence}, cardId=${cardId}`, }); } export async function updateCardKitCardForAuth(params) { return updateCardKitCard(params); } /** * Send an interactive card message by referencing a CardKit card_id. * * The content format is: {"type":"card","data":{"card_id":"xxx"}} * This links the IM message to the CardKit card entity, enabling * streaming updates via cardElement.content(). */ export async function sendCardByCardId(params) { const { cfg, to, cardId, replyToMessageId, replyInThread, accountId } = params; const client = LarkClient.fromCfg(cfg, accountId).sdk; const contentPayload = JSON.stringify({ type: 'card', data: { card_id: cardId }, }); if (replyToMessageId) { // 规范化 message_id,处理合成 ID(如 "om_xxx:auth-complete") const normalizedId = normalizeMessageId(replyToMessageId); const response = await runWithMessageUnavailableGuard({ messageId: normalizedId, operation: 'im.message.reply(interactive.cardkit)', fn: () => client.im.message.reply({ path: { message_id: normalizedId }, data: { content: contentPayload, msg_type: 'interactive', reply_in_thread: replyInThread }, }), }); return { messageId: response?.data?.message_id ?? '', chatId: response?.data?.chat_id ?? '', }; } const target = normalizeFeishuTarget(to); if (!target) { throw new Error(`[feishu-send] Invalid target: "${to}"`); } const receiveIdType = resolveReceiveIdType(target); const response = await client.im.message.create({ // SDK 类型将 receive_id_type 限定为字面量联合,但运行时接受动态值 // eslint-disable-next-line @typescript-eslint/no-explicit-any params: { receive_id_type: receiveIdType }, data: { receive_id: target, msg_type: 'interactive', content: contentPayload, }, }); return { messageId: response?.data?.message_id ?? '', chatId: response?.data?.chat_id ?? '', }; } /** * Close (or open) the streaming mode on a CardKit card. * * Must be called after streaming is complete to restore normal card * behaviour (forwarding, interaction callbacks, etc.). */ export async function setCardStreamingMode(params) { const { cfg, cardId, streamingMode, sequence, accountId } = params; const client = LarkClient.fromCfg(cfg, accountId).sdk; // SDK 返回类型不完整,运行时包含 code/msg 字段 const resp = (await client.cardkit.v1.card.settings({ data: { settings: JSON.stringify({ streaming_mode: streamingMode }), sequence, }, path: { card_id: cardId }, })); logCardKitResponse({ resp, api: 'card.settings', context: `seq=${sequence}, streaming_mode=${streamingMode}`, }); }