mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +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>
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
/**
|
||
* cardkit.ts 单元测试
|
||
*
|
||
* 不调用真实的 Lark API —— 用 mock client 捕获调用参数,验证:
|
||
* - 每个函数构造的 payload 结构
|
||
* - 非零 code 响应抛出 CardKitApiError(可被 card-errors 识别)
|
||
* - 缺失关键字段时抛错
|
||
* - sequence 正确传递
|
||
*/
|
||
|
||
import { describe, it, expect } from 'bun:test'
|
||
import {
|
||
createCardEntity,
|
||
sendCardAsMessage,
|
||
streamCardContent,
|
||
setCardStreamingMode,
|
||
updateCardKitCard,
|
||
CardKitApiError,
|
||
STREAMING_ELEMENT_ID,
|
||
} from '../cardkit.js'
|
||
import { isCardRateLimitError, isCardTableLimitError } from '../card-errors.js'
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mock client factory
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type MockCall = { api: string; args: any }
|
||
|
||
function makeMockClient(responses: Record<string, any>) {
|
||
const calls: MockCall[] = []
|
||
const recorder = (api: string, resp: any) => async (args: any) => {
|
||
calls.push({ api, args })
|
||
if (typeof resp === 'function') {
|
||
return resp(args)
|
||
}
|
||
return resp
|
||
}
|
||
const client: any = {
|
||
cardkit: {
|
||
v1: {
|
||
card: {
|
||
create: recorder('cardkit.v1.card.create', responses['card.create']),
|
||
settings: recorder('cardkit.v1.card.settings', responses['card.settings']),
|
||
update: recorder('cardkit.v1.card.update', responses['card.update']),
|
||
},
|
||
cardElement: {
|
||
content: recorder(
|
||
'cardkit.v1.cardElement.content',
|
||
responses['cardElement.content'],
|
||
),
|
||
},
|
||
},
|
||
},
|
||
im: {
|
||
message: {
|
||
create: recorder('im.message.create', responses['im.message.create']),
|
||
reply: recorder('im.message.reply', responses['im.message.reply']),
|
||
},
|
||
},
|
||
}
|
||
return { client, calls }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// createCardEntity
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('createCardEntity', () => {
|
||
it('构造 card_json payload 并返回 card_id', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': {
|
||
code: 0,
|
||
data: { card_id: 'ck_abc_123' },
|
||
},
|
||
})
|
||
const card = { schema: '2.0', body: { elements: [] } }
|
||
const id = await createCardEntity(client, card)
|
||
|
||
expect(id).toBe('ck_abc_123')
|
||
expect(calls.length).toBe(1)
|
||
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
|
||
expect(calls[0]!.args.data.type).toBe('card_json')
|
||
// data.data 应当是 card 的 JSON 字符串
|
||
expect(calls[0]!.args.data.data).toBe(JSON.stringify(card))
|
||
})
|
||
|
||
it('兼容顶层 card_id(某些 SDK 包装层)', async () => {
|
||
const { client } = makeMockClient({
|
||
'card.create': { code: 0, card_id: 'top_level_id' },
|
||
})
|
||
const id = await createCardEntity(client, {})
|
||
expect(id).toBe('top_level_id')
|
||
})
|
||
|
||
it('non-zero code 抛 CardKitApiError', async () => {
|
||
const { client } = makeMockClient({
|
||
'card.create': { code: 230099, msg: 'something failed' },
|
||
})
|
||
await expect(createCardEntity(client, {})).rejects.toThrow(CardKitApiError)
|
||
})
|
||
|
||
it('code=0 但缺 card_id 抛错', async () => {
|
||
const { client } = makeMockClient({
|
||
'card.create': { code: 0, data: {} },
|
||
})
|
||
await expect(createCardEntity(client, {})).rejects.toThrow(/missing card_id/)
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// sendCardAsMessage
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('sendCardAsMessage', () => {
|
||
it('无 replyTo: 走 im.message.create 使用 chat_id', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'im.message.create': { data: { message_id: 'om_new_msg_1' } },
|
||
})
|
||
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz')
|
||
expect(mid).toBe('om_new_msg_1')
|
||
expect(calls.length).toBe(1)
|
||
expect(calls[0]!.api).toBe('im.message.create')
|
||
expect(calls[0]!.args.params.receive_id_type).toBe('chat_id')
|
||
expect(calls[0]!.args.data.receive_id).toBe('oc_chat_123')
|
||
expect(calls[0]!.args.data.msg_type).toBe('interactive')
|
||
// content 格式: {"type":"card","data":{"card_id":"xxx"}}
|
||
const parsed = JSON.parse(calls[0]!.args.data.content)
|
||
expect(parsed).toEqual({ type: 'card', data: { card_id: 'ck_id_xyz' } })
|
||
})
|
||
|
||
it('有 replyTo: 走 im.message.reply', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'im.message.reply': { data: { message_id: 'om_reply_1' } },
|
||
})
|
||
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz', 'om_parent')
|
||
expect(mid).toBe('om_reply_1')
|
||
expect(calls.length).toBe(1)
|
||
expect(calls[0]!.api).toBe('im.message.reply')
|
||
expect(calls[0]!.args.path.message_id).toBe('om_parent')
|
||
const parsed = JSON.parse(calls[0]!.args.data.content)
|
||
expect(parsed.data.card_id).toBe('ck_id_xyz')
|
||
})
|
||
|
||
it('缺 message_id 抛错', async () => {
|
||
const { client } = makeMockClient({
|
||
'im.message.create': { data: {} },
|
||
})
|
||
await expect(sendCardAsMessage(client, 'c', 'ck')).rejects.toThrow(
|
||
/missing message_id/,
|
||
)
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// streamCardContent
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('streamCardContent', () => {
|
||
it('构造 content + sequence payload,path 包含 card_id + element_id', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'cardElement.content': { code: 0 },
|
||
})
|
||
await streamCardContent(client, 'ck_abc', STREAMING_ELEMENT_ID, 'hello', 42)
|
||
|
||
expect(calls.length).toBe(1)
|
||
expect(calls[0]!.api).toBe('cardkit.v1.cardElement.content')
|
||
expect(calls[0]!.args.data).toEqual({ content: 'hello', sequence: 42 })
|
||
expect(calls[0]!.args.path).toEqual({
|
||
card_id: 'ck_abc',
|
||
element_id: STREAMING_ELEMENT_ID,
|
||
})
|
||
})
|
||
|
||
it('STREAMING_ELEMENT_ID 常量 = "streaming_content"', () => {
|
||
expect(STREAMING_ELEMENT_ID).toBe('streaming_content')
|
||
})
|
||
|
||
it('230020 响应可被 isCardRateLimitError 识别', async () => {
|
||
const { client } = makeMockClient({
|
||
'cardElement.content': { code: 230020, msg: 'rate limited' },
|
||
})
|
||
try {
|
||
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
||
expect('should have thrown').toBe('but did not')
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(CardKitApiError)
|
||
expect(isCardRateLimitError(err)).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('230099 + table limit msg 可被 isCardTableLimitError 识别', async () => {
|
||
const { client } = makeMockClient({
|
||
'cardElement.content': {
|
||
code: 230099,
|
||
msg: 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ',
|
||
},
|
||
})
|
||
try {
|
||
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
||
expect('should have thrown').toBe('but did not')
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(CardKitApiError)
|
||
expect(isCardTableLimitError(err)).toBe(true)
|
||
}
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// setCardStreamingMode
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('setCardStreamingMode', () => {
|
||
it('streaming_mode=false + sequence 正确传递', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.settings': { code: 0 },
|
||
})
|
||
await setCardStreamingMode(client, 'ck_xxx', false, 99)
|
||
|
||
expect(calls.length).toBe(1)
|
||
expect(calls[0]!.api).toBe('cardkit.v1.card.settings')
|
||
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_xxx' })
|
||
expect(calls[0]!.args.data.sequence).toBe(99)
|
||
// settings 是 JSON 字符串
|
||
const settings = JSON.parse(calls[0]!.args.data.settings)
|
||
expect(settings).toEqual({ streaming_mode: false })
|
||
})
|
||
|
||
it('streaming_mode=true 也能工作', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.settings': { code: 0 },
|
||
})
|
||
await setCardStreamingMode(client, 'ck', true, 1)
|
||
const settings = JSON.parse(calls[0]!.args.data.settings)
|
||
expect(settings).toEqual({ streaming_mode: true })
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// updateCardKitCard
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('updateCardKitCard', () => {
|
||
it('把 card 包装成 card_json payload + sequence', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.update': { code: 0 },
|
||
})
|
||
const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: 'done' }] } }
|
||
await updateCardKitCard(client, 'ck_final', card, 100)
|
||
|
||
expect(calls.length).toBe(1)
|
||
expect(calls[0]!.api).toBe('cardkit.v1.card.update')
|
||
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_final' })
|
||
expect(calls[0]!.args.data.sequence).toBe(100)
|
||
expect(calls[0]!.args.data.card.type).toBe('card_json')
|
||
expect(calls[0]!.args.data.card.data).toBe(JSON.stringify(card))
|
||
})
|
||
|
||
it('非零 code 抛 CardKitApiError', async () => {
|
||
const { client } = makeMockClient({
|
||
'card.update': { code: -1, msg: 'bad card' },
|
||
})
|
||
await expect(updateCardKitCard(client, 'ck', {}, 1)).rejects.toThrow(CardKitApiError)
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CardKitApiError
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('CardKitApiError', () => {
|
||
it('携带 code 和 msg,可被 parseCardApiError 识别', () => {
|
||
const err = new CardKitApiError({
|
||
api: 'card.update',
|
||
code: 230020,
|
||
msg: 'rate limited',
|
||
context: 'seq=5',
|
||
})
|
||
expect(err.code).toBe(230020)
|
||
expect(err.msg).toBe('rate limited')
|
||
expect(err.name).toBe('CardKitApiError')
|
||
expect(isCardRateLimitError(err)).toBe(true)
|
||
})
|
||
|
||
it('消息包含 api 名和 context', () => {
|
||
const err = new CardKitApiError({
|
||
api: 'cardElement.content',
|
||
code: 230099,
|
||
msg: 'oops',
|
||
context: 'seq=3 len=100',
|
||
})
|
||
expect(err.message).toContain('cardElement.content')
|
||
expect(err.message).toContain('230099')
|
||
expect(err.message).toContain('seq=3 len=100')
|
||
})
|
||
})
|