mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03: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>
195 lines
5.9 KiB
TypeScript
195 lines
5.9 KiB
TypeScript
/**
|
||
* card-errors 单元测试
|
||
*/
|
||
|
||
import { describe, it, expect } from 'bun:test'
|
||
import {
|
||
CARD_ERROR,
|
||
CARD_CONTENT_SUB_ERROR,
|
||
extractLarkApiCode,
|
||
extractSubCode,
|
||
parseCardApiError,
|
||
isCardRateLimitError,
|
||
isCardTableLimitError,
|
||
} from '../card-errors.js'
|
||
|
||
describe('extractLarkApiCode', () => {
|
||
it('从 err.code 直接提取', () => {
|
||
expect(extractLarkApiCode({ code: 230020 })).toBe(230020)
|
||
})
|
||
|
||
it('从 err.data.code 提取', () => {
|
||
expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099)
|
||
})
|
||
|
||
it('从 err.response.data.code 提取(Axios 风格)', () => {
|
||
expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672)
|
||
})
|
||
|
||
it('数字字符串被强制转成 number', () => {
|
||
expect(extractLarkApiCode({ code: '230020' })).toBe(230020)
|
||
})
|
||
|
||
it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => {
|
||
const err = {
|
||
code: 1,
|
||
data: { code: 2 },
|
||
response: { data: { code: 3 } },
|
||
}
|
||
expect(extractLarkApiCode(err)).toBe(1)
|
||
})
|
||
|
||
it('none → undefined', () => {
|
||
expect(extractLarkApiCode({})).toBeUndefined()
|
||
expect(extractLarkApiCode(null)).toBeUndefined()
|
||
expect(extractLarkApiCode(undefined)).toBeUndefined()
|
||
expect(extractLarkApiCode('just a string')).toBeUndefined()
|
||
expect(extractLarkApiCode(new Error('plain'))).toBeUndefined()
|
||
})
|
||
|
||
it('非有限数字被忽略', () => {
|
||
expect(extractLarkApiCode({ code: NaN })).toBeUndefined()
|
||
expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('extractSubCode', () => {
|
||
it('识别标准的 ErrCode: 11310', () => {
|
||
const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit'
|
||
expect(extractSubCode(msg)).toBe(11310)
|
||
})
|
||
|
||
it('无 ErrCode 时返回 null', () => {
|
||
expect(extractSubCode('random error message')).toBeNull()
|
||
expect(extractSubCode('')).toBeNull()
|
||
})
|
||
|
||
it('ErrCode 大小写容错(冒号后多空格)', () => {
|
||
expect(extractSubCode('ErrCode: 42')).toBe(42)
|
||
})
|
||
})
|
||
|
||
describe('parseCardApiError', () => {
|
||
it('从 SDK 风格错误提取完整结构', () => {
|
||
const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' }
|
||
const parsed = parseCardApiError(err)
|
||
expect(parsed).toEqual({
|
||
code: 230099,
|
||
subCode: 11310,
|
||
errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit',
|
||
})
|
||
})
|
||
|
||
it('从 Axios 风格错误(response.data.msg)提取', () => {
|
||
const err = {
|
||
response: {
|
||
data: {
|
||
code: 230020,
|
||
msg: 'rate limited',
|
||
},
|
||
},
|
||
}
|
||
const parsed = parseCardApiError(err)
|
||
expect(parsed?.code).toBe(230020)
|
||
expect(parsed?.errMsg).toBe('rate limited')
|
||
expect(parsed?.subCode).toBeNull()
|
||
})
|
||
|
||
it('无 code 时返回 null', () => {
|
||
expect(parseCardApiError({})).toBeNull()
|
||
expect(parseCardApiError(null)).toBeNull()
|
||
expect(parseCardApiError('string')).toBeNull()
|
||
})
|
||
|
||
it('有 code 无 msg 时 errMsg 为空字符串', () => {
|
||
const parsed = parseCardApiError({ code: 230020 })
|
||
expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' })
|
||
})
|
||
|
||
it('fallback 到 err.message', () => {
|
||
const err = Object.assign(new Error('fallback text'), { code: 230099 })
|
||
const parsed = parseCardApiError(err)
|
||
expect(parsed?.errMsg).toBe('fallback text')
|
||
})
|
||
})
|
||
|
||
describe('isCardRateLimitError', () => {
|
||
it('识别 230020', () => {
|
||
expect(isCardRateLimitError({ code: 230020 })).toBe(true)
|
||
})
|
||
|
||
it('识别 Axios 风格 230020', () => {
|
||
expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true)
|
||
})
|
||
|
||
it('不匹配其他 code', () => {
|
||
expect(isCardRateLimitError({ code: 230099 })).toBe(false)
|
||
expect(isCardRateLimitError({ code: 99991672 })).toBe(false)
|
||
})
|
||
|
||
it('非错误对象返回 false', () => {
|
||
expect(isCardRateLimitError(null)).toBe(false)
|
||
expect(isCardRateLimitError({})).toBe(false)
|
||
expect(isCardRateLimitError(new Error('random'))).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('isCardTableLimitError', () => {
|
||
const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; '
|
||
|
||
it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => {
|
||
const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg }
|
||
expect(isCardTableLimitError(err)).toBe(true)
|
||
})
|
||
|
||
it('从 Axios 风格的 response.data 匹配', () => {
|
||
const err = {
|
||
response: {
|
||
data: {
|
||
code: 230099,
|
||
msg: validMsg,
|
||
},
|
||
},
|
||
}
|
||
expect(isCardTableLimitError(err)).toBe(true)
|
||
})
|
||
|
||
it('230099 + 11310 但没有 "table number over limit" 字样 → false(其它元素超限)', () => {
|
||
const err = {
|
||
code: CARD_ERROR.CARD_CONTENT_FAILED,
|
||
msg: 'ErrCode: 11310; ErrMsg: some other element limit; ',
|
||
}
|
||
expect(isCardTableLimitError(err)).toBe(false)
|
||
})
|
||
|
||
it('code 不是 230099 → false', () => {
|
||
const err = { code: 230020, msg: validMsg }
|
||
expect(isCardTableLimitError(err)).toBe(false)
|
||
})
|
||
|
||
it('没有 subCode → false', () => {
|
||
const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' }
|
||
expect(isCardTableLimitError(err)).toBe(false)
|
||
})
|
||
|
||
it('不区分 "table number" 的大小写', () => {
|
||
const err = {
|
||
code: 230099,
|
||
msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ',
|
||
}
|
||
expect(isCardTableLimitError(err)).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('常量值', () => {
|
||
it('CARD_ERROR.RATE_LIMITED === 230020', () => {
|
||
expect(CARD_ERROR.RATE_LIMITED).toBe(230020)
|
||
})
|
||
it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => {
|
||
expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099)
|
||
})
|
||
it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => {
|
||
expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310)
|
||
})
|
||
})
|