mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
d075276a17
commit
eb5ba2cbdf
@ -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 },
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user