From a2be2abc2820d8a8ab0b2f14024330eebede8906 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 19:31:32 +0100 Subject: [PATCH] refactor: dedupe qqbot chunk send loops --- extensions/qqbot/src/outbound-deliver.test.ts | 171 ++++++++++++++++++ extensions/qqbot/src/outbound-deliver.ts | 90 +++++---- 2 files changed, 223 insertions(+), 38 deletions(-) create mode 100644 extensions/qqbot/src/outbound-deliver.test.ts diff --git a/extensions/qqbot/src/outbound-deliver.test.ts b/extensions/qqbot/src/outbound-deliver.test.ts new file mode 100644 index 00000000000..78c489e297d --- /dev/null +++ b/extensions/qqbot/src/outbound-deliver.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiMocks = vi.hoisted(() => ({ + sendC2CMessage: vi.fn(), + sendDmMessage: vi.fn(), + sendGroupMessage: vi.fn(), + sendChannelMessage: vi.fn(), + sendC2CImageMessage: vi.fn(), + sendGroupImageMessage: vi.fn(), +})); + +const outboundMocks = vi.hoisted(() => ({ + sendPhoto: vi.fn(async () => ({})), + sendVoice: vi.fn(async () => ({})), + sendVideoMsg: vi.fn(async () => ({})), + sendDocument: vi.fn(async () => ({})), + sendMedia: vi.fn(async () => ({})), +})); + +const runtimeMocks = vi.hoisted(() => ({ + chunkMarkdownText: vi.fn((text: string) => [text]), +})); + +vi.mock("./api.js", () => ({ + sendC2CMessage: apiMocks.sendC2CMessage, + sendDmMessage: apiMocks.sendDmMessage, + sendGroupMessage: apiMocks.sendGroupMessage, + sendChannelMessage: apiMocks.sendChannelMessage, + sendC2CImageMessage: apiMocks.sendC2CImageMessage, + sendGroupImageMessage: apiMocks.sendGroupImageMessage, +})); + +vi.mock("./outbound.js", () => ({ + sendPhoto: outboundMocks.sendPhoto, + sendVoice: outboundMocks.sendVoice, + sendVideoMsg: outboundMocks.sendVideoMsg, + sendDocument: outboundMocks.sendDocument, + sendMedia: outboundMocks.sendMedia, +})); + +vi.mock("./runtime.js", () => ({ + getQQBotRuntime: () => ({ + channel: { + text: { + chunkMarkdownText: runtimeMocks.chunkMarkdownText, + }, + }, + }), +})); + +vi.mock("./utils/image-size.js", () => ({ + getImageSize: vi.fn(), + formatQQBotMarkdownImage: vi.fn((url: string) => `![img](${url})`), + hasQQBotImageSize: vi.fn(() => false), +})); + +import { + parseAndSendMediaTags, + sendPlainReply, + type ConsumeQuoteRefFn, + type DeliverAccountContext, + type DeliverEventContext, + type SendWithRetryFn, +} from "./outbound-deliver.js"; + +function buildEvent(): DeliverEventContext { + return { + type: "c2c", + senderId: "user-1", + messageId: "msg-1", + }; +} + +function buildAccountContext(markdownSupport: boolean): DeliverAccountContext { + return { + qualifiedTarget: "qqbot:c2c:user-1", + account: { + accountId: "default", + appId: "app-id", + clientSecret: "secret", + markdownSupport, + config: {}, + } as DeliverAccountContext["account"], + log: { + info: vi.fn(), + error: vi.fn(), + }, + }; +} + +const sendWithRetry: SendWithRetryFn = async (sendFn) => await sendFn("token"); +const consumeQuoteRef: ConsumeQuoteRefFn = () => undefined; + +describe("qqbot outbound deliver", () => { + beforeEach(() => { + vi.clearAllMocks(); + runtimeMocks.chunkMarkdownText.mockImplementation((text: string) => [text]); + }); + + it("sends plain replies through the shared text chunk sender", async () => { + await sendPlainReply( + {}, + "hello plain world", + buildEvent(), + buildAccountContext(false), + sendWithRetry, + consumeQuoteRef, + [], + ); + + expect(apiMocks.sendC2CMessage).toHaveBeenCalledWith( + "app-id", + "token", + "user-1", + "hello plain world", + "msg-1", + undefined, + ); + }); + + it("sends markdown replies through the shared text chunk sender", async () => { + await sendPlainReply( + {}, + "hello markdown world", + buildEvent(), + buildAccountContext(true), + sendWithRetry, + consumeQuoteRef, + [], + ); + + expect(apiMocks.sendC2CMessage).toHaveBeenCalledWith( + "app-id", + "token", + "user-1", + "hello markdown world", + "msg-1", + undefined, + ); + }); + + it("routes media-tag text segments through the shared chunk sender", async () => { + await parseAndSendMediaTags( + "beforehttps://example.com/a.pngafter", + buildEvent(), + buildAccountContext(false), + sendWithRetry, + consumeQuoteRef, + ); + + expect(apiMocks.sendC2CMessage).toHaveBeenNthCalledWith( + 1, + "app-id", + "token", + "user-1", + "before", + "msg-1", + undefined, + ); + expect(apiMocks.sendC2CMessage).toHaveBeenNthCalledWith( + 2, + "app-id", + "token", + "user-1", + "after", + "msg-1", + undefined, + ); + expect(outboundMocks.sendPhoto).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/qqbot/src/outbound-deliver.ts b/extensions/qqbot/src/outbound-deliver.ts index c57745ece8b..b1d38d88461 100644 --- a/extensions/qqbot/src/outbound-deliver.ts +++ b/extensions/qqbot/src/outbound-deliver.ts @@ -503,6 +503,32 @@ async function sendTextChunks( const { account, log } = actx; const prefix = `[qqbot:${account.accountId}]`; const chunks = getQQBotRuntime().channel.text.chunkMarkdownText(text, TEXT_CHUNK_LIMIT); + await sendQQBotTextChunksWithRetry({ + account, + event, + chunks, + sendWithRetry, + consumeQuoteRef, + allowDm: true, + log, + onSuccess: (chunk) => + `${prefix} Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`, + onError: (err) => `${prefix} Failed to send text chunk: ${String(err)}`, + }); +} + +async function sendQQBotTextChunksWithRetry(params: { + account: ResolvedQQBotAccount; + event: DeliverEventContext; + chunks: string[]; + sendWithRetry: SendWithRetryFn; + consumeQuoteRef: ConsumeQuoteRefFn; + allowDm: boolean; + log?: DeliverAccountContext["log"]; + onSuccess: (chunk: string) => string; + onError: (err: unknown) => string; +}): Promise { + const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, log } = params; for (const chunk of chunks) { try { await sendWithRetry((token) => @@ -512,14 +538,12 @@ async function sendTextChunks( token, text: chunk, consumeQuoteRef, - allowDm: true, + allowDm, }), ); - log?.info( - `${prefix} Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`, - ); + log?.info(params.onSuccess(chunk)); } catch (err) { - log?.error(`${prefix} Failed to send text chunk: ${String(err)}`); + log?.error(params.onError(err)); } } } @@ -685,25 +709,18 @@ async function sendMarkdownReply( // Send markdown text. if (result.trim()) { const mdChunks = chunkText(result, TEXT_CHUNK_LIMIT); - for (const chunk of mdChunks) { - try { - await sendWithRetry((token) => - sendQQBotTextChunk({ - account, - event, - token, - text: chunk, - consumeQuoteRef, - allowDm: true, - }), - ); - log?.info( - `${prefix} Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`, - ); - } catch (err) { - log?.error(`${prefix} Failed to send markdown message chunk: ${String(err)}`); - } - } + await sendQQBotTextChunksWithRetry({ + account, + event, + chunks: mdChunks, + sendWithRetry, + consumeQuoteRef, + allowDm: true, + log, + onSuccess: (chunk) => + `${prefix} Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`, + onError: (err) => `${prefix} Failed to send markdown message chunk: ${String(err)}`, + }); } } @@ -752,21 +769,18 @@ async function sendPlainTextReply( if (result.trim()) { const plainChunks = chunkText(result, TEXT_CHUNK_LIMIT); - for (const chunk of plainChunks) { - await sendWithRetry((token) => - sendQQBotTextChunk({ - account, - event, - token, - text: chunk, - consumeQuoteRef, - allowDm: false, - }), - ); - log?.info( + await sendQQBotTextChunksWithRetry({ + account, + event, + chunks: plainChunks, + sendWithRetry, + consumeQuoteRef, + allowDm: false, + log, + onSuccess: (chunk) => `${prefix} Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`, - ); - } + onError: (err) => `${prefix} Send failed: ${String(err)}`, + }); } } catch (err) { log?.error(`${prefix} Send failed: ${String(err)}`);