Skip to content

Commit bcc6d7d

Browse files
committed
fix(qqbot): keep markdown table chunks valid
1 parent 7a7165a commit bcc6d7d

6 files changed

Lines changed: 993 additions & 3 deletions

File tree

extensions/qqbot/src/channel.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { qqbotChannelConfigSchema } from "./config-schema.js";
2626
import { qqbotDoctor } from "./doctor.js";
2727
import { loadCredentialBackup, saveCredentialBackup } from "./engine/config/credential-backup.js";
2828
import { clearAccountCredentials } from "./engine/config/credentials.js";
29+
import { chunkQQBotMarkdownText } from "./engine/messaging/markdown-table-chunking.js";
2930
import {
3031
normalizeTarget as coreNormalizeTarget,
3132
looksLikeQQBotTarget,
@@ -264,7 +265,8 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
264265
},
265266
outbound: {
266267
deliveryMode: "direct",
267-
chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
268+
chunker: (text, limit) =>
269+
chunkQQBotMarkdownText(text, limit, getQQBotRuntime().channel.text.chunkMarkdownText),
268270
chunkerMode: "markdown",
269271
textChunkLimit: 5000,
270272
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),

extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,4 +658,110 @@ describe("dispatchOutbound", () => {
658658
expect(finalized?.Surface).toBe("qqbot");
659659
expect(finalized?.ChatType).toBe("direct");
660660
});
661+
662+
it("keeps markdown table chunks self-contained across block deliveries", async () => {
663+
const runtime = makeRuntime({
664+
onDispatch: async ({ deliver }) => {
665+
await deliver(
666+
{
667+
text: ["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n"),
668+
},
669+
{ kind: "block" },
670+
);
671+
await deliver({ text: ["| 2 | beta |", "| 3 | gamma |"].join("\n") }, { kind: "block" });
672+
},
673+
});
674+
675+
await dispatchOutbound(makeInbound(), {
676+
runtime,
677+
cfg: {},
678+
account,
679+
});
680+
681+
expect(sendTextMock).toHaveBeenCalledTimes(2);
682+
expect(sendTextMock.mock.calls[0]?.[1]).toBe(
683+
["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n"),
684+
);
685+
expect(sendTextMock.mock.calls[1]?.[1]).toBe(
686+
["| Id | Value |", "|---:|---|", "| 2 | beta |", "| 3 | gamma |"].join("\n"),
687+
);
688+
});
689+
690+
it("waits for a table separator when a block ends after the header", async () => {
691+
const runtime = makeRuntime({
692+
onDispatch: async ({ deliver }) => {
693+
await deliver({ text: "| Id | Value |" }, { kind: "block" });
694+
await deliver({ text: ["|---:|---|", "| 1 | alpha |"].join("\n") }, { kind: "block" });
695+
},
696+
});
697+
698+
await dispatchOutbound(makeInbound(), {
699+
runtime,
700+
cfg: {},
701+
account,
702+
});
703+
704+
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
705+
["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n"),
706+
]);
707+
});
708+
709+
it("flushes unfinished markdown table row fragments as plain text fields", async () => {
710+
const runtime = makeRuntime({
711+
onDispatch: async ({ deliver }) => {
712+
await deliver(
713+
{
714+
text: ["| Id | Function | Status |", "|---:|---|---|", "| 1 | auth | ok |"].join("\n"),
715+
},
716+
{ kind: "block" },
717+
);
718+
await deliver({ text: "| 10 | analyzeerror_patterns | 无需重试" }, { kind: "block" });
719+
},
720+
});
721+
722+
await dispatchOutbound(makeInbound(), {
723+
runtime,
724+
cfg: {},
725+
account,
726+
});
727+
728+
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
729+
["| Id | Function | Status |", "|---:|---|---|", "| 1 | auth | ok |"].join("\n"),
730+
["Id: 10", "Function: analyzeerror_patterns", "Status: 无需重试"].join("\n"),
731+
]);
732+
});
733+
734+
it("holds short table rows until a following block completes the columns", async () => {
735+
const runtime = makeRuntime({
736+
onDispatch: async ({ deliver }) => {
737+
await deliver(
738+
{
739+
text: [
740+
"| Id | Time | Owner | Note |",
741+
"|---:|---|---|---|",
742+
"| 16 | 40ms | He | ok |",
743+
"| 17 | 100ms |",
744+
].join("\n"),
745+
},
746+
{ kind: "block" },
747+
);
748+
await deliver({ text: "Lin | daily cap |" }, { kind: "block" });
749+
},
750+
});
751+
752+
await dispatchOutbound(makeInbound(), {
753+
runtime,
754+
cfg: {},
755+
account,
756+
});
757+
758+
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
759+
["| Id | Time | Owner | Note |", "|---:|---|---|---|", "| 16 | 40ms | He | ok |"].join("\n"),
760+
[
761+
"| Id | Time | Owner | Note |",
762+
"|---:|---|---|---|",
763+
"| 17 | 100ms | Lin | daily cap |",
764+
].join("\n"),
765+
]);
766+
});
661767
});

extensions/qqbot/src/engine/gateway/outbound-dispatch.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
1414
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-chunking";
1515
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
16+
import { createQQBotMarkdownChunker } from "../messaging/markdown-table-chunking.js";
1617
import {
1718
parseAndSendMediaTags,
1819
sendPlainReply,
1920
sendTextOnlyReply,
21+
TEXT_CHUNK_LIMIT,
2022
type DeliverDeps,
2123
} from "../messaging/outbound-deliver.js";
2224
import {
@@ -277,6 +279,9 @@ export async function dispatchOutbound(
277279
});
278280

279281
// ---- Deliver deps ----
282+
const markdownChunker = createQQBotMarkdownChunker((text, limit) =>
283+
runtime.channel.text.chunkMarkdownText(text, limit),
284+
);
280285
const deliverDeps: DeliverDeps = {
281286
mediaSender: {
282287
sendPhoto: (target, imageUrl) => sendPhoto(target, imageUrl),
@@ -286,7 +291,35 @@ export async function dispatchOutbound(
286291
sendDocument: (target, filePath) => sendDocument(target, filePath),
287292
sendMedia: (opts) => sendMedia(opts),
288293
},
289-
chunkText: (text, limit) => runtime.channel.text.chunkMarkdownText(text, limit),
294+
chunkText: (text, limit) => markdownChunker.chunkText(text, limit),
295+
};
296+
const flushPendingMarkdownText = async (): Promise<void> => {
297+
const pendingChunks = markdownChunker.flushPendingText(TEXT_CHUNK_LIMIT);
298+
if (pendingChunks.length === 0) {
299+
return;
300+
}
301+
const passthroughDeps: DeliverDeps = {
302+
...deliverDeps,
303+
chunkText: (text) => [text],
304+
};
305+
for (const chunk of pendingChunks) {
306+
await sendTextOnlyReply(
307+
chunk,
308+
{
309+
type: event.type,
310+
senderId: event.senderId,
311+
messageId: event.messageId,
312+
channelId: event.channelId,
313+
groupOpenid: event.groupOpenid,
314+
msgIdx: event.msgIdx,
315+
},
316+
{ account, qualifiedTarget, log },
317+
sendWithRetry,
318+
() => undefined,
319+
passthroughDeps,
320+
);
321+
recordOutbound();
322+
}
290323
};
291324

292325
const replyDeps: ReplyDispatcherDeps = {
@@ -690,6 +723,7 @@ export async function dispatchOutbound(
690723
toolFallbackSent = true;
691724
await sendToolFallback();
692725
}
726+
await flushPendingMarkdownText();
693727
if (streamingController && !streamingController.isTerminalPhase) {
694728
try {
695729
streamingController.markFullyComplete();

0 commit comments

Comments
 (0)