mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(zalouser): rechunk formatted messages
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
sendSeenZalouser,
|
||||
sendTypingZalouser,
|
||||
} from "./send.js";
|
||||
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
@@ -132,6 +133,102 @@ describe("zalouser send helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rechunks normalized markdown text before sending to avoid transport truncation", async () => {
|
||||
const text = "\t".repeat(500) + "a".repeat(1500);
|
||||
const formatted = parseZalouserTextStyles(text);
|
||||
mockSendText
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2c-1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2c-2" });
|
||||
|
||||
const result = await sendMessageZalouser("thread-2c", text, {
|
||||
profile: "p2c",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(formatted.text.length).toBeGreaterThan(2000);
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
|
||||
expect(mockSendText.mock.calls.every((call) => (call[1] as string).length <= 2000)).toBe(true);
|
||||
expect(result).toEqual({ ok: true, messageId: "mid-2c-2" });
|
||||
});
|
||||
|
||||
it("preserves text styles when splitting long formatted markdown", async () => {
|
||||
const text = `**${"a".repeat(2501)}**`;
|
||||
mockSendText
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-2" });
|
||||
|
||||
const result = await sendMessageZalouser("thread-2d", text, {
|
||||
profile: "p2d",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"thread-2d",
|
||||
"a".repeat(2000),
|
||||
expect.objectContaining({
|
||||
profile: "p2d",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 2000, st: TextStyle.Bold }],
|
||||
}),
|
||||
);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"thread-2d",
|
||||
"a".repeat(501),
|
||||
expect.objectContaining({
|
||||
profile: "p2d",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 501, st: TextStyle.Bold }],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, messageId: "mid-2d-2" });
|
||||
});
|
||||
|
||||
it("sends overflow markdown captions as follow-up text after the media message", async () => {
|
||||
const caption = "\t".repeat(500) + "a".repeat(1500);
|
||||
const formatted = parseZalouserTextStyles(caption);
|
||||
mockSendText
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2e-1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2e-2" });
|
||||
|
||||
const result = await sendImageZalouser("thread-2e", "https://example.com/long.png", {
|
||||
profile: "p2e",
|
||||
caption,
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"thread-2e",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
profile: "p2e",
|
||||
caption: undefined,
|
||||
isGroup: false,
|
||||
mediaUrl: "https://example.com/long.png",
|
||||
textMode: "markdown",
|
||||
}),
|
||||
);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"thread-2e",
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
mediaUrl: "https://example.com/long.png",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, messageId: "mid-2e-2" });
|
||||
});
|
||||
|
||||
it("delegates link helper to JS transport", async () => {
|
||||
mockSendLink.mockResolvedValueOnce({ ok: false, error: "boom" });
|
||||
|
||||
|
||||
@@ -8,24 +8,53 @@ import {
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
import { TextStyle } from "./zca-client.js";
|
||||
|
||||
export type ZalouserSendOptions = ZaloSendOptions;
|
||||
export type ZalouserSendResult = ZaloSendResult;
|
||||
|
||||
const ZALO_TEXT_LIMIT = 2000;
|
||||
|
||||
type StyledTextChunk = {
|
||||
text: string;
|
||||
styles?: ZaloSendOptions["textStyles"];
|
||||
};
|
||||
|
||||
export async function sendMessageZalouser(
|
||||
threadId: string,
|
||||
text: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
if (options.textMode === "markdown") {
|
||||
const formatted = parseZalouserTextStyles(text);
|
||||
return await sendZaloTextMessage(threadId, formatted.text, {
|
||||
...options,
|
||||
textStyles: formatted.styles.length > 0 ? formatted.styles : undefined,
|
||||
});
|
||||
const prepared =
|
||||
options.textMode === "markdown"
|
||||
? parseZalouserTextStyles(text)
|
||||
: { text, styles: options.textStyles };
|
||||
const chunks = splitStyledText(
|
||||
prepared.text,
|
||||
(prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
|
||||
ZALO_TEXT_LIMIT,
|
||||
);
|
||||
|
||||
let lastResult: ZalouserSendResult | null = null;
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
const chunkOptions =
|
||||
index === 0
|
||||
? { ...options, textStyles: chunk.styles }
|
||||
: {
|
||||
...options,
|
||||
caption: undefined,
|
||||
mediaLocalRoots: undefined,
|
||||
mediaUrl: undefined,
|
||||
textStyles: chunk.styles,
|
||||
};
|
||||
const result = await sendZaloTextMessage(threadId, chunk.text, chunkOptions);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
lastResult = result;
|
||||
}
|
||||
|
||||
return await sendZaloTextMessage(threadId, text, options);
|
||||
return lastResult ?? { ok: false, error: "No message content provided" };
|
||||
}
|
||||
|
||||
export async function sendImageZalouser(
|
||||
@@ -95,3 +124,60 @@ export async function sendSeenZalouser(params: {
|
||||
}): Promise<void> {
|
||||
await sendZaloSeenEvent(params);
|
||||
}
|
||||
|
||||
function splitStyledText(
|
||||
text: string,
|
||||
styles: ZaloSendOptions["textStyles"],
|
||||
limit: number,
|
||||
): StyledTextChunk[] {
|
||||
if (text.length === 0) {
|
||||
return [{ text, styles: undefined }];
|
||||
}
|
||||
|
||||
const chunks: StyledTextChunk[] = [];
|
||||
for (let start = 0; start < text.length; start += limit) {
|
||||
const end = Math.min(text.length, start + limit);
|
||||
chunks.push({
|
||||
text: text.slice(start, end),
|
||||
styles: sliceTextStyles(styles, start, end),
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function sliceTextStyles(
|
||||
styles: ZaloSendOptions["textStyles"],
|
||||
start: number,
|
||||
end: number,
|
||||
): ZaloSendOptions["textStyles"] {
|
||||
if (!styles || styles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chunkStyles = styles
|
||||
.map((style) => {
|
||||
const overlapStart = Math.max(style.start, start);
|
||||
const overlapEnd = Math.min(style.start + style.len, end);
|
||||
if (overlapEnd <= overlapStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (style.st === TextStyle.Indent) {
|
||||
return {
|
||||
start: overlapStart - start,
|
||||
len: overlapEnd - overlapStart,
|
||||
st: style.st,
|
||||
indentSize: style.indentSize,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start: overlapStart - start,
|
||||
len: overlapEnd - overlapStart,
|
||||
st: style.st,
|
||||
};
|
||||
})
|
||||
.filter((style): style is NonNullable<typeof style> => style !== null);
|
||||
|
||||
return chunkStyles.length > 0 ? chunkStyles : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user