fix: prevent Feishu reply fallback duplicates (#671)

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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 16:55:18 +08:00
parent d075276a17
commit eb5ba2cbdf
3 changed files with 33 additions and 1 deletions

View File

@ -246,6 +246,31 @@ describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
expect(sc._getMessageId()).toBe('om_fb2')
})
it('reply 发送失败后降级 create 时复用同一个 uuid 避免重复消息', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.reply': () => {
throw Object.assign(new Error('reply failed after send'), { code: 231003 })
},
'im.message.create': { data: { message_id: 'om_fb_after_reply' } },
})
const sc = new StreamingCard({
larkClient: client,
chatId: 'c',
replyToMessageId: 'om_parent',
})
await sc.ensureCreated()
const replyCall = calls.find((c) => c.api === 'im.message.reply')
const fallbackCreate = calls.find((c) => c.api === 'im.message.create')
expect(replyCall).toBeDefined()
expect(fallbackCreate).toBeDefined()
expect(typeof replyCall!.args.data.uuid).toBe('string')
expect(replyCall!.args.data.uuid.length).toBeGreaterThan(0)
expect(fallbackCreate!.args.data.uuid).toBe(replyCall!.args.data.uuid)
})
it('降级发送也失败 → aborted + throw', async () => {
const { client } = makeMockClient({
'card.create': { code: 99991672 },

View File

@ -189,6 +189,7 @@ export async function sendCardAsMessage(
chatId: string,
cardId: string,
replyToMessageId?: string,
uuid?: string,
): Promise<string> {
const content = JSON.stringify({
type: 'card',
@ -199,7 +200,7 @@ export async function sendCardAsMessage(
const resp = await withImCardRequestTimeout('im.message.reply', () =>
client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content, msg_type: 'interactive' },
data: { content, msg_type: 'interactive', uuid },
}),
)
const messageId = resp.data?.message_id
@ -221,6 +222,7 @@ export async function sendCardAsMessage(
receive_id: chatId,
msg_type: 'interactive',
content,
uuid,
},
}),
)

View File

@ -13,6 +13,7 @@
*/
import type * as Lark from '@larksuiteoapi/node-sdk'
import { randomUUID } from 'node:crypto'
import { FlushController, THROTTLE } from './flush-controller.js'
import {
createCardEntity,
@ -152,6 +153,8 @@ export class StreamingCard {
private cardId: string | null = null
/** IM message_id。始终应该有值否则连 patch 也做不了)。 */
private messageId: string | null = null
/** Same idempotency key for the first IM send and any create fallback. */
private readonly outboundMessageUuid = randomUUID()
/** CardKit cardElement.content() 单调递增序列号。 */
private sequence = 0
/** CardKit 230099 N false
@ -198,6 +201,7 @@ export class StreamingCard {
this.deps.chatId,
cardId,
this.deps.replyToMessageId,
this.outboundMessageUuid,
)
this.cardId = cardId
this.messageId = messageId
@ -219,6 +223,7 @@ export class StreamingCard {
receive_id: this.deps.chatId,
msg_type: 'interactive',
content: JSON.stringify(buildRenderedCard(' ')),
uuid: this.outboundMessageUuid,
},
}),
)