mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
refactor: dedupe qqbot chunk send loops
This commit is contained in:
171
extensions/qqbot/src/outbound-deliver.test.ts
Normal file
171
extensions/qqbot/src/outbound-deliver.test.ts
Normal 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) => ``),
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user