From eb5ba2cbdfe5f51742f50a181a52382dce58a84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 1 Jun 2026 16:55:18 +0800 Subject: [PATCH] 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 --- .../feishu/__tests__/streaming-card.test.ts | 25 +++++++++++++++++++ adapters/feishu/cardkit.ts | 4 ++- adapters/feishu/streaming-card.ts | 5 ++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/adapters/feishu/__tests__/streaming-card.test.ts b/adapters/feishu/__tests__/streaming-card.test.ts index fbb090ae..2cec35df 100644 --- a/adapters/feishu/__tests__/streaming-card.test.ts +++ b/adapters/feishu/__tests__/streaming-card.test.ts @@ -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 }, diff --git a/adapters/feishu/cardkit.ts b/adapters/feishu/cardkit.ts index 4a343331..bb9eda9d 100644 --- a/adapters/feishu/cardkit.ts +++ b/adapters/feishu/cardkit.ts @@ -189,6 +189,7 @@ export async function sendCardAsMessage( chatId: string, cardId: string, replyToMessageId?: string, + uuid?: string, ): Promise { 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, }, }), ) diff --git a/adapters/feishu/streaming-card.ts b/adapters/feishu/streaming-card.ts index 233a4435..16b1b90f 100644 --- a/adapters/feishu/streaming-card.ts +++ b/adapters/feishu/streaming-card.ts @@ -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, }, }), )