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, }, }), )