mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(zalouser): preserve markdown structure across chunks
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
|
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
|
|
||||||
import {
|
import {
|
||||||
installSendPayloadContractSuite,
|
installSendPayloadContractSuite,
|
||||||
primeSendMock,
|
primeSendMock,
|
||||||
@@ -43,7 +42,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||||||
setZalouserRuntime({
|
setZalouserRuntime({
|
||||||
channel: {
|
channel: {
|
||||||
text: {
|
text: {
|
||||||
chunkMarkdownText,
|
resolveChunkMode: vi.fn(() => "length"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
@@ -101,10 +100,8 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
|
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses markdown-aware chunking for long fenced code payloads", async () => {
|
it("passes long markdown through once so formatting happens before chunking", async () => {
|
||||||
const codeLine = "const value = 1234567890;";
|
const text = `**${"a".repeat(2501)}**`;
|
||||||
const text = `\`\`\`ts\n${Array.from({ length: 140 }, () => codeLine).join("\n")}\n\`\`\``;
|
|
||||||
const expectedChunks = chunkMarkdownText(text, 2000);
|
|
||||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" });
|
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" });
|
||||||
|
|
||||||
const result = await zalouserPlugin.outbound!.sendPayload!({
|
const result = await zalouserPlugin.outbound!.sendPayload!({
|
||||||
@@ -112,13 +109,18 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||||||
to: "987654321",
|
to: "987654321",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockedSend.mock.calls.map((call) => call[1])).toEqual(expectedChunks);
|
expect(mockedSend).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedSend).toHaveBeenCalledWith(
|
||||||
|
"987654321",
|
||||||
|
text,
|
||||||
|
expect.objectContaining({ isGroup: false, textMode: "markdown", textChunkMode: "length" }),
|
||||||
|
);
|
||||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
|
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
|
||||||
});
|
});
|
||||||
|
|
||||||
installSendPayloadContractSuite({
|
installSendPayloadContractSuite({
|
||||||
channel: "zalouser",
|
channel: "zalouser",
|
||||||
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
|
chunking: { mode: "passthrough", longTextLength: 3000 },
|
||||||
createHarness: ({ payload, sendResults }) => {
|
createHarness: ({ payload, sendResults }) => {
|
||||||
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
|
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ function resolveZalouserQrProfile(accountId?: string | null): string {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: string) {
|
||||||
|
return getZalouserRuntime().channel.text.resolveChunkMode(cfg, "zalouser", accountId);
|
||||||
|
}
|
||||||
|
|
||||||
function mapUser(params: {
|
function mapUser(params: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -595,14 +599,9 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
},
|
},
|
||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getZalouserRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
||||||
chunkerMode: "markdown",
|
|
||||||
textChunkLimit: 2000,
|
|
||||||
sendPayload: async (ctx) =>
|
sendPayload: async (ctx) =>
|
||||||
await sendPayloadWithChunkedTextAndMedia({
|
await sendPayloadWithChunkedTextAndMedia({
|
||||||
ctx,
|
ctx,
|
||||||
textChunkLimit: zalouserPlugin.outbound!.textChunkLimit,
|
|
||||||
chunker: zalouserPlugin.outbound!.chunker,
|
|
||||||
sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
|
sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
|
||||||
sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
|
sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
|
||||||
emptyResult: { channel: "zalouser", messageId: "" },
|
emptyResult: { channel: "zalouser", messageId: "" },
|
||||||
@@ -614,6 +613,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
profile: account.profile,
|
profile: account.profile,
|
||||||
isGroup: target.isGroup,
|
isGroup: target.isGroup,
|
||||||
textMode: "markdown",
|
textMode: "markdown",
|
||||||
|
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
|
||||||
});
|
});
|
||||||
return buildChannelSendResult("zalouser", result);
|
return buildChannelSendResult("zalouser", result);
|
||||||
},
|
},
|
||||||
@@ -626,6 +626,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
textMode: "markdown",
|
textMode: "markdown",
|
||||||
|
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
|
||||||
});
|
});
|
||||||
return buildChannelSendResult("zalouser", result);
|
return buildChannelSendResult("zalouser", result);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function createRuntimeEnv(): RuntimeEnv {
|
|||||||
|
|
||||||
function installRuntime(params: {
|
function installRuntime(params: {
|
||||||
commandAuthorized?: boolean;
|
commandAuthorized?: boolean;
|
||||||
|
replyPayload?: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
||||||
resolveCommandAuthorizedFromAuthorizers?: (params: {
|
resolveCommandAuthorizedFromAuthorizers?: (params: {
|
||||||
useAccessGroups: boolean;
|
useAccessGroups: boolean;
|
||||||
authorizers: Array<{ configured: boolean; allowed: boolean }>;
|
authorizers: Array<{ configured: boolean; allowed: boolean }>;
|
||||||
@@ -58,6 +59,9 @@ function installRuntime(params: {
|
|||||||
}) {
|
}) {
|
||||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
|
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
|
||||||
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
||||||
|
if (params.replyPayload) {
|
||||||
|
await dispatcherOptions.deliver(params.replyPayload);
|
||||||
|
}
|
||||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
|
||||||
});
|
});
|
||||||
const resolveCommandAuthorizedFromAuthorizers = vi.fn(
|
const resolveCommandAuthorizedFromAuthorizers = vi.fn(
|
||||||
@@ -166,7 +170,7 @@ function installRuntime(params: {
|
|||||||
text: {
|
text: {
|
||||||
resolveMarkdownTableMode: vi.fn(() => "code"),
|
resolveMarkdownTableMode: vi.fn(() => "code"),
|
||||||
convertMarkdownTables: vi.fn((text: string) => text),
|
convertMarkdownTables: vi.fn((text: string) => text),
|
||||||
resolveChunkMode: vi.fn(() => "line"),
|
resolveChunkMode: vi.fn(() => "length"),
|
||||||
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
|
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -304,6 +308,41 @@ describe("zalouser monitor group mention gating", () => {
|
|||||||
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes long markdown replies through once so formatting happens before chunking", async () => {
|
||||||
|
const replyText = `**${"a".repeat(2501)}**`;
|
||||||
|
installRuntime({
|
||||||
|
commandAuthorized: false,
|
||||||
|
replyPayload: { text: replyText },
|
||||||
|
});
|
||||||
|
|
||||||
|
await __testing.processMessage({
|
||||||
|
message: createDmMessage({
|
||||||
|
content: "hello",
|
||||||
|
}),
|
||||||
|
account: {
|
||||||
|
...createAccount(),
|
||||||
|
config: {
|
||||||
|
...createAccount().config,
|
||||||
|
dmPolicy: "open",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: createConfig(),
|
||||||
|
runtime: createRuntimeEnv(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMessageZalouserMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMessageZalouserMock).toHaveBeenCalledWith(
|
||||||
|
"u-1",
|
||||||
|
replyText,
|
||||||
|
expect.objectContaining({
|
||||||
|
isGroup: false,
|
||||||
|
profile: "default",
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkMode: "length",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses commandContent for mention-prefixed control commands", async () => {
|
it("uses commandContent for mention-prefixed control commands", async () => {
|
||||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||||
commandAuthorized: true,
|
commandAuthorized: true,
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ export type ZalouserMonitorResult = {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZALOUSER_TEXT_LIMIT = 2000;
|
|
||||||
|
|
||||||
function normalizeZalouserEntry(entry: string): string {
|
function normalizeZalouserEntry(entry: string): string {
|
||||||
return entry.replace(/^(zalouser|zlu):/i, "").trim();
|
return entry.replace(/^(zalouser|zlu):/i, "").trim();
|
||||||
}
|
}
|
||||||
@@ -703,6 +701,7 @@ async function deliverZalouserReply(params: {
|
|||||||
params;
|
params;
|
||||||
const tableMode = params.tableMode ?? "code";
|
const tableMode = params.tableMode ?? "code";
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
|
||||||
|
|
||||||
const sentMedia = await sendMediaWithLeadingCaption({
|
const sentMedia = await sendMediaWithLeadingCaption({
|
||||||
mediaUrls: resolveOutboundMediaUrls(payload),
|
mediaUrls: resolveOutboundMediaUrls(payload),
|
||||||
@@ -714,6 +713,7 @@ async function deliverZalouserReply(params: {
|
|||||||
mediaUrl,
|
mediaUrl,
|
||||||
isGroup,
|
isGroup,
|
||||||
textMode: "markdown",
|
textMode: "markdown",
|
||||||
|
textChunkMode: chunkMode,
|
||||||
});
|
});
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
},
|
},
|
||||||
@@ -726,24 +726,16 @@ async function deliverZalouserReply(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
|
try {
|
||||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
await sendMessageZalouser(chatId, text, {
|
||||||
text,
|
profile,
|
||||||
ZALOUSER_TEXT_LIMIT,
|
isGroup,
|
||||||
chunkMode,
|
textMode: "markdown",
|
||||||
);
|
textChunkMode: chunkMode,
|
||||||
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
});
|
||||||
for (const chunk of chunks) {
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
try {
|
} catch (err) {
|
||||||
await sendMessageZalouser(chatId, chunk, {
|
runtime.error(`Zalouser message send failed: ${String(err)}`);
|
||||||
profile,
|
|
||||||
isGroup,
|
|
||||||
textMode: "markdown",
|
|
||||||
});
|
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
|
||||||
} catch (err) {
|
|
||||||
runtime.error(`Zalouser message send failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,49 @@ describe("zalouser send helpers", () => {
|
|||||||
expect(result).toEqual({ ok: true, messageId: "mid-2d-2" });
|
expect(result).toEqual({ ok: true, messageId: "mid-2d-2" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves formatted text and styles when newline chunk mode splits after parsing", async () => {
|
||||||
|
const text = `**${"a".repeat(1995)}**\n\nsecond paragraph`;
|
||||||
|
const formatted = parseZalouserTextStyles(text);
|
||||||
|
mockSendText
|
||||||
|
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-3" })
|
||||||
|
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-4" });
|
||||||
|
|
||||||
|
const result = await sendMessageZalouser("thread-2d-2", text, {
|
||||||
|
profile: "p2d-2",
|
||||||
|
isGroup: false,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkMode: "newline",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
|
||||||
|
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"thread-2d-2",
|
||||||
|
`${"a".repeat(1995)}\n\n`,
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: "p2d-2",
|
||||||
|
isGroup: false,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkMode: "newline",
|
||||||
|
textStyles: [{ start: 0, len: 1995, st: TextStyle.Bold }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"thread-2d-2",
|
||||||
|
"second paragraph",
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: "p2d-2",
|
||||||
|
isGroup: false,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkMode: "newline",
|
||||||
|
textStyles: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: true, messageId: "mid-2d-4" });
|
||||||
|
});
|
||||||
|
|
||||||
it("sends overflow markdown captions as follow-up text after the media message", async () => {
|
it("sends overflow markdown captions as follow-up text after the media message", async () => {
|
||||||
const caption = "\t".repeat(500) + "a".repeat(1500);
|
const caption = "\t".repeat(500) + "a".repeat(1500);
|
||||||
const formatted = parseZalouserTextStyles(caption);
|
const formatted = parseZalouserTextStyles(caption);
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ export type ZalouserSendOptions = ZaloSendOptions;
|
|||||||
export type ZalouserSendResult = ZaloSendResult;
|
export type ZalouserSendResult = ZaloSendResult;
|
||||||
|
|
||||||
const ZALO_TEXT_LIMIT = 2000;
|
const ZALO_TEXT_LIMIT = 2000;
|
||||||
|
const DEFAULT_TEXT_CHUNK_MODE = "length";
|
||||||
|
|
||||||
type StyledTextChunk = {
|
type StyledTextChunk = {
|
||||||
text: string;
|
text: string;
|
||||||
styles?: ZaloSendOptions["textStyles"];
|
styles?: ZaloSendOptions["textStyles"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TextChunkMode = NonNullable<ZaloSendOptions["textChunkMode"]>;
|
||||||
|
|
||||||
export async function sendMessageZalouser(
|
export async function sendMessageZalouser(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -33,6 +36,7 @@ export async function sendMessageZalouser(
|
|||||||
prepared.text,
|
prepared.text,
|
||||||
(prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
|
(prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
|
||||||
ZALO_TEXT_LIMIT,
|
ZALO_TEXT_LIMIT,
|
||||||
|
options.textChunkMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
let lastResult: ZalouserSendResult | null = null;
|
let lastResult: ZalouserSendResult | null = null;
|
||||||
@@ -129,14 +133,15 @@ function splitStyledText(
|
|||||||
text: string,
|
text: string,
|
||||||
styles: ZaloSendOptions["textStyles"],
|
styles: ZaloSendOptions["textStyles"],
|
||||||
limit: number,
|
limit: number,
|
||||||
|
mode: ZaloSendOptions["textChunkMode"],
|
||||||
): StyledTextChunk[] {
|
): StyledTextChunk[] {
|
||||||
if (text.length === 0) {
|
if (text.length === 0) {
|
||||||
return [{ text, styles: undefined }];
|
return [{ text, styles: undefined }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks: StyledTextChunk[] = [];
|
const chunks: StyledTextChunk[] = [];
|
||||||
for (let start = 0; start < text.length; start += limit) {
|
for (const range of splitTextRanges(text, limit, mode ?? DEFAULT_TEXT_CHUNK_MODE)) {
|
||||||
const end = Math.min(text.length, start + limit);
|
const { start, end } = range;
|
||||||
chunks.push({
|
chunks.push({
|
||||||
text: text.slice(start, end),
|
text: text.slice(start, end),
|
||||||
styles: sliceTextStyles(styles, start, end),
|
styles: sliceTextStyles(styles, start, end),
|
||||||
@@ -181,3 +186,86 @@ function sliceTextStyles(
|
|||||||
|
|
||||||
return chunkStyles.length > 0 ? chunkStyles : undefined;
|
return chunkStyles.length > 0 ? chunkStyles : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitTextRanges(
|
||||||
|
text: string,
|
||||||
|
limit: number,
|
||||||
|
mode: TextChunkMode,
|
||||||
|
): Array<{ start: number; end: number }> {
|
||||||
|
if (mode === "newline") {
|
||||||
|
return splitTextRangesByPreferredBreaks(text, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges: Array<{ start: number; end: number }> = [];
|
||||||
|
for (let start = 0; start < text.length; start += limit) {
|
||||||
|
ranges.push({
|
||||||
|
start,
|
||||||
|
end: Math.min(text.length, start + limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTextRangesByPreferredBreaks(
|
||||||
|
text: string,
|
||||||
|
limit: number,
|
||||||
|
): Array<{ start: number; end: number }> {
|
||||||
|
const ranges: Array<{ start: number; end: number }> = [];
|
||||||
|
let start = 0;
|
||||||
|
|
||||||
|
while (start < text.length) {
|
||||||
|
const maxEnd = Math.min(text.length, start + limit);
|
||||||
|
let end = maxEnd;
|
||||||
|
if (maxEnd < text.length) {
|
||||||
|
end =
|
||||||
|
findParagraphBreak(text, start, maxEnd) ??
|
||||||
|
findLastBreak(text, "\n", start, maxEnd) ??
|
||||||
|
findLastWhitespaceBreak(text, start, maxEnd) ??
|
||||||
|
maxEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end <= start) {
|
||||||
|
end = maxEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges.push({ start, end });
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findParagraphBreak(text: string, start: number, end: number): number | undefined {
|
||||||
|
const slice = text.slice(start, end);
|
||||||
|
const matches = slice.matchAll(/\n[\t ]*\n+/g);
|
||||||
|
let lastMatch: RegExpMatchArray | undefined;
|
||||||
|
for (const match of matches) {
|
||||||
|
lastMatch = match;
|
||||||
|
}
|
||||||
|
if (!lastMatch || lastMatch.index === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return start + lastMatch.index + lastMatch[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastBreak(
|
||||||
|
text: string,
|
||||||
|
marker: string,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
): number | undefined {
|
||||||
|
const index = text.lastIndexOf(marker, end - 1);
|
||||||
|
if (index < start) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return index + marker.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastWhitespaceBreak(text: string, start: number, end: number): number | undefined {
|
||||||
|
for (let index = end - 1; index > start; index -= 1) {
|
||||||
|
if (/\s/.test(text[index])) {
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,12 +35,23 @@ describe("parseZalouserTextStyles", () => {
|
|||||||
{ start: 0, len: 5, st: TextStyle.Bold },
|
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||||
{ start: 0, len: 5, st: TextStyle.Big },
|
{ start: 0, len: 5, st: TextStyle.Big },
|
||||||
{ start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
|
{ start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
|
||||||
{ start: 13, len: 6, st: TextStyle.Indent, indentSize: 1 },
|
|
||||||
{ start: 13, len: 6, st: TextStyle.UnorderedList },
|
{ start: 13, len: 6, st: TextStyle.UnorderedList },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats 1-3 leading spaces as markdown padding for headings and lists", () => {
|
||||||
|
expect(parseZalouserTextStyles(" # Title\n 1. item\n - bullet")).toEqual({
|
||||||
|
text: "Title\nitem\nbullet",
|
||||||
|
styles: [
|
||||||
|
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||||
|
{ start: 0, len: 5, st: TextStyle.Big },
|
||||||
|
{ start: 6, len: 4, st: TextStyle.OrderedList },
|
||||||
|
{ start: 11, len: 6, st: TextStyle.UnorderedList },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("strips fenced code markers and preserves leading indentation with nbsp", () => {
|
it("strips fenced code markers and preserves leading indentation with nbsp", () => {
|
||||||
expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
|
expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
|
||||||
text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",
|
text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",
|
||||||
|
|||||||
@@ -156,7 +156,9 @@ export function parseZalouserTextStyles(input: string): { text: string; styles:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headingMatch = line.match(/^(#{1,4})\s(.*)$/);
|
const { text: markdownLine, size: markdownPadding } = stripOptionalMarkdownPadding(line);
|
||||||
|
|
||||||
|
const headingMatch = markdownLine.match(/^(#{1,4})\s(.*)$/);
|
||||||
if (headingMatch) {
|
if (headingMatch) {
|
||||||
const depth = headingMatch[1].length;
|
const depth = headingMatch[1].length;
|
||||||
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Bold });
|
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Bold });
|
||||||
@@ -174,9 +176,9 @@ export function parseZalouserTextStyles(input: string): { text: string; styles:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const indentMatch = line.match(/^(\s+)(.*)$/);
|
const indentMatch = markdownLine.match(/^(\s+)(.*)$/);
|
||||||
let indentLevel = 0;
|
let indentLevel = 0;
|
||||||
let content = line;
|
let content = markdownLine;
|
||||||
if (indentMatch) {
|
if (indentMatch) {
|
||||||
indentLevel = clampIndent(indentMatch[1].length);
|
indentLevel = clampIndent(indentMatch[1].length);
|
||||||
content = indentMatch[2];
|
content = indentMatch[2];
|
||||||
@@ -223,6 +225,18 @@ export function parseZalouserTextStyles(input: string): { text: string; styles:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (markdownPadding > 0) {
|
||||||
|
if (baseIndent > 0) {
|
||||||
|
lineStyles.push({
|
||||||
|
lineIndex: outputLineIndex,
|
||||||
|
style: TextStyle.Indent,
|
||||||
|
indentSize: baseIndent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
processedLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (totalIndent > 0) {
|
if (totalIndent > 0) {
|
||||||
lineStyles.push({
|
lineStyles.push({
|
||||||
lineIndex: outputLineIndex,
|
lineIndex: outputLineIndex,
|
||||||
@@ -312,6 +326,17 @@ function clampIndent(spaceCount: number): number {
|
|||||||
return Math.min(5, Math.max(1, Math.floor(spaceCount / 2)));
|
return Math.min(5, Math.max(1, Math.floor(spaceCount / 2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripOptionalMarkdownPadding(line: string): { text: string; size: number } {
|
||||||
|
const match = line.match(/^( {1,3})(?=\S)/);
|
||||||
|
if (!match) {
|
||||||
|
return { text: line, size: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: line.slice(match[1].length),
|
||||||
|
size: match[1].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function hasClosingFence(lines: string[], startIndex: number, fence: ActiveFence): boolean {
|
function hasClosingFence(lines: string[], startIndex: number, fence: ActiveFence): boolean {
|
||||||
for (let index = startIndex; index < lines.length; index += 1) {
|
for (let index = startIndex; index < lines.length; index += 1) {
|
||||||
const candidate =
|
const candidate =
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export type ZaloSendOptions = {
|
|||||||
isGroup?: boolean;
|
isGroup?: boolean;
|
||||||
mediaLocalRoots?: readonly string[];
|
mediaLocalRoots?: readonly string[];
|
||||||
textMode?: "markdown" | "plain";
|
textMode?: "markdown" | "plain";
|
||||||
|
textChunkMode?: "length" | "newline";
|
||||||
textStyles?: Style[];
|
textStyles?: Style[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user