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) => ``),
+ 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)}`);