refactor: dedupe qqbot chunk send loops

This commit is contained in:
Peter Steinberger
2026-04-06 19:31:32 +01:00
parent 2edc3c8a3e
commit a2be2abc28
2 changed files with 223 additions and 38 deletions

View File

@@ -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(
"before<qqimg>https://example.com/a.png</qqimg>after",
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);
});
});

View File

@@ -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<void> {
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)}`);