fix(zalouser): rechunk formatted messages

This commit is contained in:
Tuyen
2026-03-12 11:33:56 +07:00
parent 9273b6a38b
commit 5c8061b229
2 changed files with 190 additions and 7 deletions

View File

@@ -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" });

View File

@@ -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;
}