mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 03:37:27 +00:00
feat(zalouser): add markdown-to-Zalo text style parsing (#43324)
* feat(zalouser): add markdown-to-Zalo text style parsing Parse markdown formatting (bold, italic, strikethrough, headings, lists, code blocks, blockquotes, custom color/style tags) into Zalo native TextStyle ranges so outbound messages render with rich formatting. - Add text-styles.ts with parseZalouserTextStyles() converter - Wire markdown mode into send pipeline (sendMessageZalouser) - Export TextStyle enum and Style type from zca-client - Add textMode/textStyles to ZaloSendOptions - Pass textStyles through sendZaloTextMessage to zca-js API - Enable textMode:"markdown" in outbound sendText/sendMedia and monitor - Add comprehensive tests for parsing, send, and channel integration * fix(zalouser): harden markdown text parsing * fix(zalouser): mirror zca-js text style types * fix(zalouser): support tilde fenced code blocks * fix(zalouser): handle quoted fenced code blocks * fix(zalouser): preserve literal quote lines in code fences * fix(zalouser): support indented quoted fences * fix(zalouser): preserve quoted markdown blocks * fix(zalouser): rechunk formatted messages * fix(zalouser): preserve markdown structure across chunks * fix(zalouser): honor chunk limits and CRLF fences
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
primeSendMock,
|
primeSendMock,
|
||||||
} from "../../../src/test-utils/send-payload-contract.js";
|
} from "../../../src/test-utils/send-payload-contract.js";
|
||||||
import { zalouserPlugin } from "./channel.js";
|
import { zalouserPlugin } from "./channel.js";
|
||||||
|
import { setZalouserRuntime } from "./runtime.js";
|
||||||
|
|
||||||
vi.mock("./send.js", () => ({
|
vi.mock("./send.js", () => ({
|
||||||
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
|
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
|
||||||
@@ -38,6 +39,14 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||||||
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>;
|
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
setZalouserRuntime({
|
||||||
|
channel: {
|
||||||
|
text: {
|
||||||
|
resolveChunkMode: vi.fn(() => "length"),
|
||||||
|
resolveTextChunkLimit: vi.fn(() => 1200),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
const mod = await import("./send.js");
|
const mod = await import("./send.js");
|
||||||
mockedSend = vi.mocked(mod.sendMessageZalouser);
|
mockedSend = vi.mocked(mod.sendMessageZalouser);
|
||||||
mockedSend.mockClear();
|
mockedSend.mockClear();
|
||||||
@@ -55,7 +64,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||||||
expect(mockedSend).toHaveBeenCalledWith(
|
expect(mockedSend).toHaveBeenCalledWith(
|
||||||
"1471383327500481391",
|
"1471383327500481391",
|
||||||
"hello group",
|
"hello group",
|
||||||
expect.objectContaining({ isGroup: true }),
|
expect.objectContaining({ isGroup: true, textMode: "markdown" }),
|
||||||
);
|
);
|
||||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" });
|
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" });
|
||||||
});
|
});
|
||||||
@@ -71,7 +80,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||||||
expect(mockedSend).toHaveBeenCalledWith(
|
expect(mockedSend).toHaveBeenCalledWith(
|
||||||
"987654321",
|
"987654321",
|
||||||
"hello",
|
"hello",
|
||||||
expect.objectContaining({ isGroup: false }),
|
expect.objectContaining({ isGroup: false, textMode: "markdown" }),
|
||||||
);
|
);
|
||||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-d1" });
|
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-d1" });
|
||||||
});
|
});
|
||||||
@@ -87,14 +96,37 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||||||
expect(mockedSend).toHaveBeenCalledWith(
|
expect(mockedSend).toHaveBeenCalledWith(
|
||||||
"g-1471383327500481391",
|
"g-1471383327500481391",
|
||||||
"hello native group",
|
"hello native group",
|
||||||
expect.objectContaining({ isGroup: true }),
|
expect.objectContaining({ isGroup: true, textMode: "markdown" }),
|
||||||
);
|
);
|
||||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
|
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes long markdown through once so formatting happens before chunking", async () => {
|
||||||
|
const text = `**${"a".repeat(2501)}**`;
|
||||||
|
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" });
|
||||||
|
|
||||||
|
const result = await zalouserPlugin.outbound!.sendPayload!({
|
||||||
|
...baseCtx({ text }),
|
||||||
|
to: "987654321",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedSend).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedSend).toHaveBeenCalledWith(
|
||||||
|
"987654321",
|
||||||
|
text,
|
||||||
|
expect.objectContaining({
|
||||||
|
isGroup: false,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkMode: "length",
|
||||||
|
textChunkLimit: 1200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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 { zalouserPlugin } from "./channel.js";
|
import { zalouserPlugin } from "./channel.js";
|
||||||
|
import { setZalouserRuntime } from "./runtime.js";
|
||||||
import { sendReactionZalouser } from "./send.js";
|
import { sendReactionZalouser } from "./send.js";
|
||||||
|
|
||||||
vi.mock("./send.js", async (importOriginal) => {
|
vi.mock("./send.js", async (importOriginal) => {
|
||||||
@@ -13,6 +15,16 @@ vi.mock("./send.js", async (importOriginal) => {
|
|||||||
const mockSendReaction = vi.mocked(sendReactionZalouser);
|
const mockSendReaction = vi.mocked(sendReactionZalouser);
|
||||||
|
|
||||||
describe("zalouser outbound chunker", () => {
|
describe("zalouser outbound chunker", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setZalouserRuntime({
|
||||||
|
channel: {
|
||||||
|
text: {
|
||||||
|
chunkMarkdownText,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
it("chunks without empty strings and respects limit", () => {
|
it("chunks without empty strings and respects limit", () => {
|
||||||
const chunker = zalouserPlugin.outbound?.chunker;
|
const chunker = zalouserPlugin.outbound?.chunker;
|
||||||
expect(chunker).toBeTypeOf("function");
|
expect(chunker).toBeTypeOf("function");
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
buildBaseAccountStatusSnapshot,
|
buildBaseAccountStatusSnapshot,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
chunkTextForOutbound,
|
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
formatAllowFromLowercase,
|
formatAllowFromLowercase,
|
||||||
isNumericTargetId,
|
isNumericTargetId,
|
||||||
@@ -43,6 +42,7 @@ import { resolveZalouserReactionMessageIds } from "./message-sid.js";
|
|||||||
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
||||||
import { probeZalouser } from "./probe.js";
|
import { probeZalouser } from "./probe.js";
|
||||||
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
||||||
|
import { getZalouserRuntime } from "./runtime.js";
|
||||||
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||||
import {
|
import {
|
||||||
@@ -166,6 +166,16 @@ 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 resolveZalouserOutboundTextChunkLimit(cfg: OpenClawConfig, accountId?: string) {
|
||||||
|
return getZalouserRuntime().channel.text.resolveTextChunkLimit(cfg, "zalouser", accountId, {
|
||||||
|
fallbackLimit: zalouserDock.outbound?.textChunkLimit ?? 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function mapUser(params: {
|
function mapUser(params: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -595,14 +605,9 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
},
|
},
|
||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: chunkTextForOutbound,
|
|
||||||
chunkerMode: "text",
|
|
||||||
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: "" },
|
||||||
@@ -613,6 +618,9 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
const result = await sendMessageZalouser(target.threadId, text, {
|
const result = await sendMessageZalouser(target.threadId, text, {
|
||||||
profile: account.profile,
|
profile: account.profile,
|
||||||
isGroup: target.isGroup,
|
isGroup: target.isGroup,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
|
||||||
|
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
|
||||||
});
|
});
|
||||||
return buildChannelSendResult("zalouser", result);
|
return buildChannelSendResult("zalouser", result);
|
||||||
},
|
},
|
||||||
@@ -624,6 +632,9 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
isGroup: target.isGroup,
|
isGroup: target.isGroup,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
|
||||||
|
textChunkLimit: resolveZalouserOutboundTextChunkLimit(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,8 @@ 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"),
|
||||||
|
resolveTextChunkLimit: vi.fn(() => 1200),
|
||||||
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
|
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -304,6 +309,42 @@ 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",
|
||||||
|
textChunkLimit: 1200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -703,6 +703,10 @@ 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 textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
|
||||||
|
fallbackLimit: ZALOUSER_TEXT_LIMIT,
|
||||||
|
});
|
||||||
|
|
||||||
const sentMedia = await sendMediaWithLeadingCaption({
|
const sentMedia = await sendMediaWithLeadingCaption({
|
||||||
mediaUrls: resolveOutboundMediaUrls(payload),
|
mediaUrls: resolveOutboundMediaUrls(payload),
|
||||||
@@ -713,6 +717,9 @@ async function deliverZalouserReply(params: {
|
|||||||
profile,
|
profile,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
isGroup,
|
isGroup,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkMode: chunkMode,
|
||||||
|
textChunkLimit,
|
||||||
});
|
});
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
},
|
},
|
||||||
@@ -725,20 +732,17 @@ 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}`);
|
textChunkLimit,
|
||||||
for (const chunk of chunks) {
|
});
|
||||||
try {
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
|
} catch (err) {
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
runtime.error(`Zalouser message send failed: ${String(err)}`);
|
||||||
} catch (err) {
|
|
||||||
runtime.error(`Zalouser message send failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
sendSeenZalouser,
|
sendSeenZalouser,
|
||||||
sendTypingZalouser,
|
sendTypingZalouser,
|
||||||
} from "./send.js";
|
} from "./send.js";
|
||||||
|
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||||
import {
|
import {
|
||||||
sendZaloDeliveredEvent,
|
sendZaloDeliveredEvent,
|
||||||
sendZaloLink,
|
sendZaloLink,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
sendZaloTextMessage,
|
sendZaloTextMessage,
|
||||||
sendZaloTypingEvent,
|
sendZaloTypingEvent,
|
||||||
} from "./zalo-js.js";
|
} from "./zalo-js.js";
|
||||||
|
import { TextStyle } from "./zca-client.js";
|
||||||
|
|
||||||
vi.mock("./zalo-js.js", () => ({
|
vi.mock("./zalo-js.js", () => ({
|
||||||
sendZaloTextMessage: vi.fn(),
|
sendZaloTextMessage: vi.fn(),
|
||||||
@@ -43,36 +45,272 @@ describe("zalouser send helpers", () => {
|
|||||||
mockSendSeen.mockReset();
|
mockSendSeen.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delegates text send to JS transport", async () => {
|
it("keeps plain text literal by default", async () => {
|
||||||
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
|
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
|
||||||
|
|
||||||
const result = await sendMessageZalouser("thread-1", "hello", {
|
const result = await sendMessageZalouser("thread-1", "**hello**", {
|
||||||
profile: "default",
|
profile: "default",
|
||||||
isGroup: true,
|
isGroup: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSendText).toHaveBeenCalledWith("thread-1", "hello", {
|
expect(mockSendText).toHaveBeenCalledWith(
|
||||||
profile: "default",
|
"thread-1",
|
||||||
isGroup: true,
|
"**hello**",
|
||||||
});
|
expect.objectContaining({
|
||||||
|
profile: "default",
|
||||||
|
isGroup: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(result).toEqual({ ok: true, messageId: "mid-1" });
|
expect(result).toEqual({ ok: true, messageId: "mid-1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps image helper to media send", async () => {
|
it("formats markdown text when markdown mode is enabled", async () => {
|
||||||
|
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1b" });
|
||||||
|
|
||||||
|
await sendMessageZalouser("thread-1", "**hello**", {
|
||||||
|
profile: "default",
|
||||||
|
isGroup: true,
|
||||||
|
textMode: "markdown",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSendText).toHaveBeenCalledWith(
|
||||||
|
"thread-1",
|
||||||
|
"hello",
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: "default",
|
||||||
|
isGroup: true,
|
||||||
|
textMode: "markdown",
|
||||||
|
textStyles: [{ start: 0, len: 5, st: TextStyle.Bold }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats image captions in markdown mode", async () => {
|
||||||
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" });
|
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" });
|
||||||
|
|
||||||
await sendImageZalouser("thread-2", "https://example.com/a.png", {
|
await sendImageZalouser("thread-2", "https://example.com/a.png", {
|
||||||
profile: "p2",
|
profile: "p2",
|
||||||
caption: "cap",
|
caption: "_cap_",
|
||||||
isGroup: false,
|
isGroup: false,
|
||||||
|
textMode: "markdown",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSendText).toHaveBeenCalledWith("thread-2", "cap", {
|
expect(mockSendText).toHaveBeenCalledWith(
|
||||||
|
"thread-2",
|
||||||
|
"cap",
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: "p2",
|
||||||
|
caption: undefined,
|
||||||
|
isGroup: false,
|
||||||
|
mediaUrl: "https://example.com/a.png",
|
||||||
|
textMode: "markdown",
|
||||||
|
textStyles: [{ start: 0, len: 3, st: TextStyle.Italic }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not keep the raw markdown caption as a media fallback after formatting", async () => {
|
||||||
|
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2b" });
|
||||||
|
|
||||||
|
await sendImageZalouser("thread-2", "https://example.com/a.png", {
|
||||||
profile: "p2",
|
profile: "p2",
|
||||||
caption: "cap",
|
caption: "```\n```",
|
||||||
isGroup: false,
|
isGroup: false,
|
||||||
mediaUrl: "https://example.com/a.png",
|
textMode: "markdown",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(mockSendText).toHaveBeenCalledWith(
|
||||||
|
"thread-2",
|
||||||
|
"",
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: "p2",
|
||||||
|
caption: undefined,
|
||||||
|
isGroup: false,
|
||||||
|
mediaUrl: "https://example.com/a.png",
|
||||||
|
textMode: "markdown",
|
||||||
|
textStyles: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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("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("respects an explicit text chunk limit when splitting formatted markdown", async () => {
|
||||||
|
const text = `**${"a".repeat(1501)}**`;
|
||||||
|
mockSendText
|
||||||
|
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-5" })
|
||||||
|
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-6" });
|
||||||
|
|
||||||
|
const result = await sendMessageZalouser("thread-2d-3", text, {
|
||||||
|
profile: "p2d-3",
|
||||||
|
isGroup: false,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkLimit: 1200,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"thread-2d-3",
|
||||||
|
"a".repeat(1200),
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: "p2d-3",
|
||||||
|
isGroup: false,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkLimit: 1200,
|
||||||
|
textStyles: [{ start: 0, len: 1200, st: TextStyle.Bold }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"thread-2d-3",
|
||||||
|
"a".repeat(301),
|
||||||
|
expect.objectContaining({
|
||||||
|
profile: "p2d-3",
|
||||||
|
isGroup: false,
|
||||||
|
textMode: "markdown",
|
||||||
|
textChunkLimit: 1200,
|
||||||
|
textStyles: [{ start: 0, len: 301, st: TextStyle.Bold }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: true, messageId: "mid-2d-6" });
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("delegates link helper to JS transport", async () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||||
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
|
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
|
||||||
import {
|
import {
|
||||||
sendZaloDeliveredEvent,
|
sendZaloDeliveredEvent,
|
||||||
@@ -7,16 +8,58 @@ import {
|
|||||||
sendZaloTextMessage,
|
sendZaloTextMessage,
|
||||||
sendZaloTypingEvent,
|
sendZaloTypingEvent,
|
||||||
} from "./zalo-js.js";
|
} from "./zalo-js.js";
|
||||||
|
import { TextStyle } from "./zca-client.js";
|
||||||
|
|
||||||
export type ZalouserSendOptions = ZaloSendOptions;
|
export type ZalouserSendOptions = ZaloSendOptions;
|
||||||
export type ZalouserSendResult = ZaloSendResult;
|
export type ZalouserSendResult = ZaloSendResult;
|
||||||
|
|
||||||
|
const ZALO_TEXT_LIMIT = 2000;
|
||||||
|
const DEFAULT_TEXT_CHUNK_MODE = "length";
|
||||||
|
|
||||||
|
type StyledTextChunk = {
|
||||||
|
text: string;
|
||||||
|
styles?: ZaloSendOptions["textStyles"];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextChunkMode = NonNullable<ZaloSendOptions["textChunkMode"]>;
|
||||||
|
|
||||||
export async function sendMessageZalouser(
|
export async function sendMessageZalouser(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
text: string,
|
text: string,
|
||||||
options: ZalouserSendOptions = {},
|
options: ZalouserSendOptions = {},
|
||||||
): Promise<ZalouserSendResult> {
|
): Promise<ZalouserSendResult> {
|
||||||
return await sendZaloTextMessage(threadId, text, options);
|
const prepared =
|
||||||
|
options.textMode === "markdown"
|
||||||
|
? parseZalouserTextStyles(text)
|
||||||
|
: { text, styles: options.textStyles };
|
||||||
|
const textChunkLimit = options.textChunkLimit ?? ZALO_TEXT_LIMIT;
|
||||||
|
const chunks = splitStyledText(
|
||||||
|
prepared.text,
|
||||||
|
(prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
|
||||||
|
textChunkLimit,
|
||||||
|
options.textChunkMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 lastResult ?? { ok: false, error: "No message content provided" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendImageZalouser(
|
export async function sendImageZalouser(
|
||||||
@@ -24,8 +67,9 @@ export async function sendImageZalouser(
|
|||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
options: ZalouserSendOptions = {},
|
options: ZalouserSendOptions = {},
|
||||||
): Promise<ZalouserSendResult> {
|
): Promise<ZalouserSendResult> {
|
||||||
return await sendZaloTextMessage(threadId, options.caption ?? "", {
|
return await sendMessageZalouser(threadId, options.caption ?? "", {
|
||||||
...options,
|
...options,
|
||||||
|
caption: undefined,
|
||||||
mediaUrl: imageUrl,
|
mediaUrl: imageUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -85,3 +129,144 @@ export async function sendSeenZalouser(params: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await sendZaloSeenEvent(params);
|
await sendZaloSeenEvent(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitStyledText(
|
||||||
|
text: string,
|
||||||
|
styles: ZaloSendOptions["textStyles"],
|
||||||
|
limit: number,
|
||||||
|
mode: ZaloSendOptions["textChunkMode"],
|
||||||
|
): StyledTextChunk[] {
|
||||||
|
if (text.length === 0) {
|
||||||
|
return [{ text, styles: undefined }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: StyledTextChunk[] = [];
|
||||||
|
for (const range of splitTextRanges(text, limit, mode ?? DEFAULT_TEXT_CHUNK_MODE)) {
|
||||||
|
const { start, end } = range;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
203
extensions/zalouser/src/text-styles.test.ts
Normal file
203
extensions/zalouser/src/text-styles.test.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||||
|
import { TextStyle } from "./zca-client.js";
|
||||||
|
|
||||||
|
describe("parseZalouserTextStyles", () => {
|
||||||
|
it("renders inline markdown emphasis as Zalo style ranges", () => {
|
||||||
|
expect(parseZalouserTextStyles("**bold** *italic* ~~strike~~")).toEqual({
|
||||||
|
text: "bold italic strike",
|
||||||
|
styles: [
|
||||||
|
{ start: 0, len: 4, st: TextStyle.Bold },
|
||||||
|
{ start: 5, len: 6, st: TextStyle.Italic },
|
||||||
|
{ start: 12, len: 6, st: TextStyle.StrikeThrough },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps inline code and plain math markers literal", () => {
|
||||||
|
expect(parseZalouserTextStyles("before `inline *code*` after\n2 * 3 * 4")).toEqual({
|
||||||
|
text: "before `inline *code*` after\n2 * 3 * 4",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves backslash escapes inside code spans and fenced code blocks", () => {
|
||||||
|
expect(parseZalouserTextStyles("before `\\*` after\n```ts\n\\*\\_\\\\\n```")).toEqual({
|
||||||
|
text: "before `\\*` after\n\\*\\_\\\\",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes fenced code blocks when the input uses CRLF newlines", () => {
|
||||||
|
expect(parseZalouserTextStyles("```\r\n*code*\r\n```\r\n**after**")).toEqual({
|
||||||
|
text: "*code*\nafter",
|
||||||
|
styles: [{ start: 7, len: 5, st: TextStyle.Bold }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps headings, block quotes, and lists into line styles", () => {
|
||||||
|
expect(parseZalouserTextStyles(["# Title", "> quoted", " - nested"].join("\n"))).toEqual({
|
||||||
|
text: "Title\nquoted\nnested",
|
||||||
|
styles: [
|
||||||
|
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||||
|
{ start: 0, len: 5, st: TextStyle.Big },
|
||||||
|
{ start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
|
||||||
|
{ 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", () => {
|
||||||
|
expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
|
||||||
|
text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats tilde fences as literal code blocks", () => {
|
||||||
|
expect(parseZalouserTextStyles("~~~bash\n*cmd*\n~~~")).toEqual({
|
||||||
|
text: "*cmd*",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats fences indented under list items as literal code blocks", () => {
|
||||||
|
expect(parseZalouserTextStyles(" ```\n*cmd*\n ```")).toEqual({
|
||||||
|
text: "*cmd*",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats quoted backtick fences as literal code blocks", () => {
|
||||||
|
expect(parseZalouserTextStyles("> ```js\n> *cmd*\n> ```")).toEqual({
|
||||||
|
text: "*cmd*",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats quoted tilde fences as literal code blocks", () => {
|
||||||
|
expect(parseZalouserTextStyles("> ~~~\n> *cmd*\n> ~~~")).toEqual({
|
||||||
|
text: "*cmd*",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves quote-prefixed lines inside normal fenced code blocks", () => {
|
||||||
|
expect(parseZalouserTextStyles("```\n> prompt\n```")).toEqual({
|
||||||
|
text: "> prompt",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat quote-prefixed fence text inside code as a closing fence", () => {
|
||||||
|
expect(parseZalouserTextStyles("```\n> ```\n*still code*\n```")).toEqual({
|
||||||
|
text: "> ```\n*still code*",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats indented blockquotes as quoted lines", () => {
|
||||||
|
expect(parseZalouserTextStyles(" > quoted")).toEqual({
|
||||||
|
text: "quoted",
|
||||||
|
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats spaced nested blockquotes as deeper quoted lines", () => {
|
||||||
|
expect(parseZalouserTextStyles("> > quoted")).toEqual({
|
||||||
|
text: "quoted",
|
||||||
|
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 2 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats indented quoted fences as literal code blocks", () => {
|
||||||
|
expect(parseZalouserTextStyles(" > ```\n > *cmd*\n > ```")).toEqual({
|
||||||
|
text: "*cmd*",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats spaced nested quoted fences as literal code blocks", () => {
|
||||||
|
expect(parseZalouserTextStyles("> > ```\n> > code\n> > ```")).toEqual({
|
||||||
|
text: "code",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves inner quote markers inside quoted fenced code blocks", () => {
|
||||||
|
expect(parseZalouserTextStyles("> ```\n>> prompt\n> ```")).toEqual({
|
||||||
|
text: "> prompt",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps quote indentation on heading lines", () => {
|
||||||
|
expect(parseZalouserTextStyles("> # Title")).toEqual({
|
||||||
|
text: "Title",
|
||||||
|
styles: [
|
||||||
|
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||||
|
{ start: 0, len: 5, st: TextStyle.Big },
|
||||||
|
{ start: 0, len: 5, st: TextStyle.Indent, indentSize: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unmatched fences literal", () => {
|
||||||
|
expect(parseZalouserTextStyles("```python")).toEqual({
|
||||||
|
text: "```python",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unclosed fenced blocks literal until eof", () => {
|
||||||
|
expect(parseZalouserTextStyles("```python\n\\*not italic*\n_next_")).toEqual({
|
||||||
|
text: "```python\n\\*not italic*\n_next_",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports nested markdown and tag styles regardless of order", () => {
|
||||||
|
expect(parseZalouserTextStyles("**{red}x{/red}** {red}**y**{/red}")).toEqual({
|
||||||
|
text: "x y",
|
||||||
|
styles: [
|
||||||
|
{ start: 0, len: 1, st: TextStyle.Bold },
|
||||||
|
{ start: 0, len: 1, st: TextStyle.Red },
|
||||||
|
{ start: 2, len: 1, st: TextStyle.Red },
|
||||||
|
{ start: 2, len: 1, st: TextStyle.Bold },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats small text tags as normal text", () => {
|
||||||
|
expect(parseZalouserTextStyles("{small}tiny{/small}")).toEqual({
|
||||||
|
text: "tiny",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps escaped markers literal", () => {
|
||||||
|
expect(parseZalouserTextStyles("\\*literal\\* \\{underline}tag{/underline}")).toEqual({
|
||||||
|
text: "*literal* {underline}tag{/underline}",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps indented code blocks literal", () => {
|
||||||
|
expect(parseZalouserTextStyles(" *cmd*")).toEqual({
|
||||||
|
text: "\u00A0\u00A0\u00A0\u00A0*cmd*",
|
||||||
|
styles: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
537
extensions/zalouser/src/text-styles.ts
Normal file
537
extensions/zalouser/src/text-styles.ts
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
import { TextStyle, type Style } from "./zca-client.js";
|
||||||
|
|
||||||
|
type InlineStyle = (typeof TextStyle)[keyof typeof TextStyle];
|
||||||
|
|
||||||
|
type LineStyle = {
|
||||||
|
lineIndex: number;
|
||||||
|
style: InlineStyle;
|
||||||
|
indentSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Segment = {
|
||||||
|
text: string;
|
||||||
|
styles: InlineStyle[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type InlineMarker = {
|
||||||
|
pattern: RegExp;
|
||||||
|
extractText: (match: RegExpExecArray) => string;
|
||||||
|
resolveStyles?: (match: RegExpExecArray) => InlineStyle[];
|
||||||
|
literal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedInlineMatch = {
|
||||||
|
match: RegExpExecArray;
|
||||||
|
marker: InlineMarker;
|
||||||
|
styles: InlineStyle[];
|
||||||
|
text: string;
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FenceMarker = {
|
||||||
|
char: "`" | "~";
|
||||||
|
length: number;
|
||||||
|
indent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActiveFence = FenceMarker & {
|
||||||
|
quoteIndent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAG_STYLE_MAP: Record<string, InlineStyle | null> = {
|
||||||
|
red: TextStyle.Red,
|
||||||
|
orange: TextStyle.Orange,
|
||||||
|
yellow: TextStyle.Yellow,
|
||||||
|
green: TextStyle.Green,
|
||||||
|
small: null,
|
||||||
|
big: TextStyle.Big,
|
||||||
|
underline: TextStyle.Underline,
|
||||||
|
};
|
||||||
|
|
||||||
|
const INLINE_MARKERS: InlineMarker[] = [
|
||||||
|
{
|
||||||
|
pattern: /`([^`\n]+)`/g,
|
||||||
|
extractText: (match) => match[0],
|
||||||
|
literal: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\\([*_~#\\{}>+\-`])/g,
|
||||||
|
extractText: (match) => match[1],
|
||||||
|
literal: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: new RegExp(`\\{(${Object.keys(TAG_STYLE_MAP).join("|")})\\}(.+?)\\{/\\1\\}`, "g"),
|
||||||
|
extractText: (match) => match[2],
|
||||||
|
resolveStyles: (match) => {
|
||||||
|
const style = TAG_STYLE_MAP[match[1]];
|
||||||
|
return style ? [style] : [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /(?<!\*)\*\*\*(?=\S)([^\n]*?\S)(?<!\*)\*\*\*(?!\*)/g,
|
||||||
|
extractText: (match) => match[1],
|
||||||
|
resolveStyles: () => [TextStyle.Bold, TextStyle.Italic],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /(?<!\*)\*\*(?![\s*])([^\n]*?\S)(?<!\*)\*\*(?!\*)/g,
|
||||||
|
extractText: (match) => match[1],
|
||||||
|
resolveStyles: () => [TextStyle.Bold],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /(?<![\w_])__(?![\s_])([^\n]*?\S)(?<!_)__(?![\w_])/g,
|
||||||
|
extractText: (match) => match[1],
|
||||||
|
resolveStyles: () => [TextStyle.Bold],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /(?<!~)~~(?=\S)([^\n]*?\S)(?<!~)~~(?!~)/g,
|
||||||
|
extractText: (match) => match[1],
|
||||||
|
resolveStyles: () => [TextStyle.StrikeThrough],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /(?<!\*)\*(?![\s*])([^\n]*?\S)(?<!\*)\*(?!\*)/g,
|
||||||
|
extractText: (match) => match[1],
|
||||||
|
resolveStyles: () => [TextStyle.Italic],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /(?<![\w_])_(?![\s_])([^\n]*?\S)(?<!_)_(?![\w_])/g,
|
||||||
|
extractText: (match) => match[1],
|
||||||
|
resolveStyles: () => [TextStyle.Italic],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function parseZalouserTextStyles(input: string): { text: string; styles: Style[] } {
|
||||||
|
const allStyles: Style[] = [];
|
||||||
|
|
||||||
|
const escapeMap: string[] = [];
|
||||||
|
const lines = input.replace(/\r\n?/g, "\n").split("\n");
|
||||||
|
const lineStyles: LineStyle[] = [];
|
||||||
|
const processedLines: string[] = [];
|
||||||
|
let activeFence: ActiveFence | null = null;
|
||||||
|
|
||||||
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||||
|
const rawLine = lines[lineIndex];
|
||||||
|
const { text: unquotedLine, indent: baseIndent } = stripQuotePrefix(rawLine);
|
||||||
|
|
||||||
|
if (activeFence) {
|
||||||
|
const codeLine =
|
||||||
|
activeFence.quoteIndent > 0
|
||||||
|
? stripQuotePrefix(rawLine, activeFence.quoteIndent).text
|
||||||
|
: rawLine;
|
||||||
|
if (isClosingFence(codeLine, activeFence)) {
|
||||||
|
activeFence = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
processedLines.push(
|
||||||
|
escapeLiteralText(
|
||||||
|
normalizeCodeBlockLeadingWhitespace(stripCodeFenceIndent(codeLine, activeFence.indent)),
|
||||||
|
escapeMap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = unquotedLine;
|
||||||
|
const openingFence = resolveOpeningFence(rawLine);
|
||||||
|
if (openingFence) {
|
||||||
|
const fenceLine = openingFence.quoteIndent > 0 ? unquotedLine : rawLine;
|
||||||
|
if (!hasClosingFence(lines, lineIndex + 1, openingFence)) {
|
||||||
|
processedLines.push(escapeLiteralText(fenceLine, escapeMap));
|
||||||
|
activeFence = openingFence;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
activeFence = openingFence;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputLineIndex = processedLines.length;
|
||||||
|
if (isIndentedCodeBlockLine(line)) {
|
||||||
|
if (baseIndent > 0) {
|
||||||
|
lineStyles.push({
|
||||||
|
lineIndex: outputLineIndex,
|
||||||
|
style: TextStyle.Indent,
|
||||||
|
indentSize: baseIndent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
processedLines.push(escapeLiteralText(normalizeCodeBlockLeadingWhitespace(line), escapeMap));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text: markdownLine, size: markdownPadding } = stripOptionalMarkdownPadding(line);
|
||||||
|
|
||||||
|
const headingMatch = markdownLine.match(/^(#{1,4})\s(.*)$/);
|
||||||
|
if (headingMatch) {
|
||||||
|
const depth = headingMatch[1].length;
|
||||||
|
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Bold });
|
||||||
|
if (depth === 1) {
|
||||||
|
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Big });
|
||||||
|
}
|
||||||
|
if (baseIndent > 0) {
|
||||||
|
lineStyles.push({
|
||||||
|
lineIndex: outputLineIndex,
|
||||||
|
style: TextStyle.Indent,
|
||||||
|
indentSize: baseIndent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
processedLines.push(headingMatch[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indentMatch = markdownLine.match(/^(\s+)(.*)$/);
|
||||||
|
let indentLevel = 0;
|
||||||
|
let content = markdownLine;
|
||||||
|
if (indentMatch) {
|
||||||
|
indentLevel = clampIndent(indentMatch[1].length);
|
||||||
|
content = indentMatch[2];
|
||||||
|
}
|
||||||
|
const totalIndent = Math.min(5, baseIndent + indentLevel);
|
||||||
|
|
||||||
|
if (/^[-*+]\s\[[ xX]\]\s/.test(content)) {
|
||||||
|
if (totalIndent > 0) {
|
||||||
|
lineStyles.push({
|
||||||
|
lineIndex: outputLineIndex,
|
||||||
|
style: TextStyle.Indent,
|
||||||
|
indentSize: totalIndent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
processedLines.push(content);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedListMatch = content.match(/^(\d+)\.\s(.*)$/);
|
||||||
|
if (orderedListMatch) {
|
||||||
|
if (totalIndent > 0) {
|
||||||
|
lineStyles.push({
|
||||||
|
lineIndex: outputLineIndex,
|
||||||
|
style: TextStyle.Indent,
|
||||||
|
indentSize: totalIndent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.OrderedList });
|
||||||
|
processedLines.push(orderedListMatch[2]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unorderedListMatch = content.match(/^[-*+]\s(.*)$/);
|
||||||
|
if (unorderedListMatch) {
|
||||||
|
if (totalIndent > 0) {
|
||||||
|
lineStyles.push({
|
||||||
|
lineIndex: outputLineIndex,
|
||||||
|
style: TextStyle.Indent,
|
||||||
|
indentSize: totalIndent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.UnorderedList });
|
||||||
|
processedLines.push(unorderedListMatch[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markdownPadding > 0) {
|
||||||
|
if (baseIndent > 0) {
|
||||||
|
lineStyles.push({
|
||||||
|
lineIndex: outputLineIndex,
|
||||||
|
style: TextStyle.Indent,
|
||||||
|
indentSize: baseIndent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
processedLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalIndent > 0) {
|
||||||
|
lineStyles.push({
|
||||||
|
lineIndex: outputLineIndex,
|
||||||
|
style: TextStyle.Indent,
|
||||||
|
indentSize: totalIndent,
|
||||||
|
});
|
||||||
|
processedLines.push(content);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedLines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = parseInlineSegments(processedLines.join("\n"));
|
||||||
|
|
||||||
|
let plainText = "";
|
||||||
|
for (const segment of segments) {
|
||||||
|
const start = plainText.length;
|
||||||
|
plainText += segment.text;
|
||||||
|
for (const style of segment.styles) {
|
||||||
|
allStyles.push({ start, len: segment.text.length, st: style } as Style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escapeMap.length > 0) {
|
||||||
|
const escapeRegex = /\x01(\d+)\x02/g;
|
||||||
|
const shifts: Array<{ pos: number; delta: number }> = [];
|
||||||
|
let cumulativeDelta = 0;
|
||||||
|
|
||||||
|
for (const match of plainText.matchAll(escapeRegex)) {
|
||||||
|
const escapeIndex = Number.parseInt(match[1], 10);
|
||||||
|
cumulativeDelta += match[0].length - escapeMap[escapeIndex].length;
|
||||||
|
shifts.push({ pos: (match.index ?? 0) + match[0].length, delta: cumulativeDelta });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const style of allStyles) {
|
||||||
|
let startDelta = 0;
|
||||||
|
let endDelta = 0;
|
||||||
|
const end = style.start + style.len;
|
||||||
|
for (const shift of shifts) {
|
||||||
|
if (shift.pos <= style.start) {
|
||||||
|
startDelta = shift.delta;
|
||||||
|
}
|
||||||
|
if (shift.pos <= end) {
|
||||||
|
endDelta = shift.delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
style.start -= startDelta;
|
||||||
|
style.len -= endDelta - startDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
plainText = plainText.replace(
|
||||||
|
escapeRegex,
|
||||||
|
(_match, index) => escapeMap[Number.parseInt(index, 10)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalLines = plainText.split("\n");
|
||||||
|
let offset = 0;
|
||||||
|
for (let lineIndex = 0; lineIndex < finalLines.length; lineIndex += 1) {
|
||||||
|
const lineLength = finalLines[lineIndex].length;
|
||||||
|
if (lineLength > 0) {
|
||||||
|
for (const lineStyle of lineStyles) {
|
||||||
|
if (lineStyle.lineIndex !== lineIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineStyle.style === TextStyle.Indent) {
|
||||||
|
allStyles.push({
|
||||||
|
start: offset,
|
||||||
|
len: lineLength,
|
||||||
|
st: TextStyle.Indent,
|
||||||
|
indentSize: lineStyle.indentSize,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
allStyles.push({ start: offset, len: lineLength, st: lineStyle.style } as Style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += lineLength + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: plainText, styles: allStyles };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampIndent(spaceCount: number): number {
|
||||||
|
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 {
|
||||||
|
for (let index = startIndex; index < lines.length; index += 1) {
|
||||||
|
const candidate =
|
||||||
|
fence.quoteIndent > 0 ? stripQuotePrefix(lines[index], fence.quoteIndent).text : lines[index];
|
||||||
|
if (isClosingFence(candidate, fence)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpeningFence(line: string): ActiveFence | null {
|
||||||
|
const directFence = parseFenceMarker(line);
|
||||||
|
if (directFence) {
|
||||||
|
return { ...directFence, quoteIndent: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoted = stripQuotePrefix(line);
|
||||||
|
if (quoted.indent === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotedFence = parseFenceMarker(quoted.text);
|
||||||
|
if (!quotedFence) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...quotedFence,
|
||||||
|
quoteIndent: quoted.indent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQuotePrefix(
|
||||||
|
line: string,
|
||||||
|
maxDepth = Number.POSITIVE_INFINITY,
|
||||||
|
): { text: string; indent: number } {
|
||||||
|
let cursor = 0;
|
||||||
|
while (cursor < line.length && cursor < 3 && line[cursor] === " ") {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedDepth = 0;
|
||||||
|
let consumedCursor = cursor;
|
||||||
|
while (removedDepth < maxDepth && consumedCursor < line.length && line[consumedCursor] === ">") {
|
||||||
|
removedDepth += 1;
|
||||||
|
consumedCursor += 1;
|
||||||
|
if (line[consumedCursor] === " ") {
|
||||||
|
consumedCursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedDepth === 0) {
|
||||||
|
return { text: line, indent: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: line.slice(consumedCursor),
|
||||||
|
indent: Math.min(5, removedDepth),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFenceMarker(line: string): FenceMarker | null {
|
||||||
|
const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})(.*)$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = match[2];
|
||||||
|
const char = marker[0];
|
||||||
|
if (char !== "`" && char !== "~") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
char,
|
||||||
|
length: marker.length,
|
||||||
|
indent: match[1].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClosingFence(line: string, fence: FenceMarker): boolean {
|
||||||
|
const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})[ \t]*$/);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return match[2][0] === fence.char && match[2].length >= fence.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeLiteralText(input: string, escapeMap: string[]): string {
|
||||||
|
return input.replace(/[\\*_~{}`]/g, (ch) => {
|
||||||
|
const index = escapeMap.length;
|
||||||
|
escapeMap.push(ch);
|
||||||
|
return `\x01${index}\x02`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineSegments(text: string, inheritedStyles: InlineStyle[] = []): Segment[] {
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < text.length) {
|
||||||
|
const nextMatch = findNextInlineMatch(text, cursor);
|
||||||
|
if (!nextMatch) {
|
||||||
|
pushSegment(segments, text.slice(cursor), inheritedStyles);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextMatch.match.index > cursor) {
|
||||||
|
pushSegment(segments, text.slice(cursor, nextMatch.match.index), inheritedStyles);
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedStyles = [...inheritedStyles, ...nextMatch.styles];
|
||||||
|
if (nextMatch.marker.literal) {
|
||||||
|
pushSegment(segments, nextMatch.text, combinedStyles);
|
||||||
|
} else {
|
||||||
|
segments.push(...parseInlineSegments(nextMatch.text, combinedStyles));
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = nextMatch.match.index + nextMatch.match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextInlineMatch(text: string, startIndex: number): ResolvedInlineMatch | null {
|
||||||
|
let bestMatch: ResolvedInlineMatch | null = null;
|
||||||
|
|
||||||
|
for (const [priority, marker] of INLINE_MARKERS.entries()) {
|
||||||
|
const regex = new RegExp(marker.pattern.source, marker.pattern.flags);
|
||||||
|
regex.lastIndex = startIndex;
|
||||||
|
const match = regex.exec(text);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
bestMatch &&
|
||||||
|
(match.index > bestMatch.match.index ||
|
||||||
|
(match.index === bestMatch.match.index && priority > bestMatch.priority))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bestMatch = {
|
||||||
|
match,
|
||||||
|
marker,
|
||||||
|
text: marker.extractText(match),
|
||||||
|
styles: marker.resolveStyles?.(match) ?? [],
|
||||||
|
priority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSegment(segments: Segment[], text: string, styles: InlineStyle[]): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSegment = segments.at(-1);
|
||||||
|
if (lastSegment && sameStyles(lastSegment.styles, styles)) {
|
||||||
|
lastSegment.text += text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
text,
|
||||||
|
styles: [...styles],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameStyles(left: InlineStyle[], right: InlineStyle[]): boolean {
|
||||||
|
return left.length === right.length && left.every((style, index) => style === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCodeBlockLeadingWhitespace(line: string): string {
|
||||||
|
return line.replace(/^[ \t]+/, (leadingWhitespace) =>
|
||||||
|
leadingWhitespace.replace(/\t/g, "\u00A0\u00A0\u00A0\u00A0").replace(/ /g, "\u00A0"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIndentedCodeBlockLine(line: string): boolean {
|
||||||
|
return /^(?: {4,}|\t)/.test(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCodeFenceIndent(line: string, indent: number): string {
|
||||||
|
let consumed = 0;
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < line.length && consumed < indent && line[cursor] === " ") {
|
||||||
|
cursor += 1;
|
||||||
|
consumed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return line.slice(cursor);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Style } from "./zca-client.js";
|
||||||
|
|
||||||
export type ZcaFriend = {
|
export type ZcaFriend = {
|
||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -59,6 +61,10 @@ export type ZaloSendOptions = {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
isGroup?: boolean;
|
isGroup?: boolean;
|
||||||
mediaLocalRoots?: readonly string[];
|
mediaLocalRoots?: readonly string[];
|
||||||
|
textMode?: "markdown" | "plain";
|
||||||
|
textChunkMode?: "length" | "newline";
|
||||||
|
textChunkLimit?: number;
|
||||||
|
textStyles?: Style[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ZaloSendResult = {
|
export type ZaloSendResult = {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import {
|
import {
|
||||||
LoginQRCallbackEventType,
|
LoginQRCallbackEventType,
|
||||||
|
TextStyle,
|
||||||
ThreadType,
|
ThreadType,
|
||||||
Zalo,
|
Zalo,
|
||||||
type API,
|
type API,
|
||||||
@@ -136,6 +137,39 @@ function toErrorMessage(error: unknown): string {
|
|||||||
return String(error);
|
return String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampTextStyles(
|
||||||
|
text: string,
|
||||||
|
styles?: ZaloSendOptions["textStyles"],
|
||||||
|
): ZaloSendOptions["textStyles"] {
|
||||||
|
if (!styles || styles.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const maxLength = text.length;
|
||||||
|
const clamped = styles
|
||||||
|
.map((style) => {
|
||||||
|
const start = Math.max(0, Math.min(style.start, maxLength));
|
||||||
|
const end = Math.min(style.start + style.len, maxLength);
|
||||||
|
if (end <= start) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (style.st === TextStyle.Indent) {
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
len: end - start,
|
||||||
|
st: style.st,
|
||||||
|
indentSize: style.indentSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
len: end - start,
|
||||||
|
st: style.st,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((style): style is NonNullable<typeof style> => style !== null);
|
||||||
|
return clamped.length > 0 ? clamped : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function toNumberId(value: unknown): string {
|
function toNumberId(value: unknown): string {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
return String(Math.trunc(value));
|
return String(Math.trunc(value));
|
||||||
@@ -1018,11 +1052,16 @@ export async function sendZaloTextMessage(
|
|||||||
kind: media.kind,
|
kind: media.kind,
|
||||||
});
|
});
|
||||||
const payloadText = (text || options.caption || "").slice(0, 2000);
|
const payloadText = (text || options.caption || "").slice(0, 2000);
|
||||||
|
const textStyles = clampTextStyles(payloadText, options.textStyles);
|
||||||
|
|
||||||
if (media.kind === "audio") {
|
if (media.kind === "audio") {
|
||||||
let textMessageId: string | undefined;
|
let textMessageId: string | undefined;
|
||||||
if (payloadText) {
|
if (payloadText) {
|
||||||
const textResponse = await api.sendMessage(payloadText, trimmedThreadId, type);
|
const textResponse = await api.sendMessage(
|
||||||
|
textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
|
||||||
|
trimmedThreadId,
|
||||||
|
type,
|
||||||
|
);
|
||||||
textMessageId = extractSendMessageId(textResponse);
|
textMessageId = extractSendMessageId(textResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,6 +1094,7 @@ export async function sendZaloTextMessage(
|
|||||||
const response = await api.sendMessage(
|
const response = await api.sendMessage(
|
||||||
{
|
{
|
||||||
msg: payloadText,
|
msg: payloadText,
|
||||||
|
...(textStyles ? { styles: textStyles } : {}),
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
data: media.buffer,
|
data: media.buffer,
|
||||||
@@ -1071,7 +1111,13 @@ export async function sendZaloTextMessage(
|
|||||||
return { ok: true, messageId: extractSendMessageId(response) };
|
return { ok: true, messageId: extractSendMessageId(response) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.sendMessage(text.slice(0, 2000), trimmedThreadId, type);
|
const payloadText = text.slice(0, 2000);
|
||||||
|
const textStyles = clampTextStyles(payloadText, options.textStyles);
|
||||||
|
const response = await api.sendMessage(
|
||||||
|
textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
|
||||||
|
trimmedThreadId,
|
||||||
|
type,
|
||||||
|
);
|
||||||
return { ok: true, messageId: extractSendMessageId(response) };
|
return { ok: true, messageId: extractSendMessageId(response) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, error: toErrorMessage(error) };
|
return { ok: false, error: toErrorMessage(error) };
|
||||||
|
|||||||
@@ -28,6 +28,39 @@ export const Reactions = ReactionsRuntime as Record<string, string> & {
|
|||||||
NONE: string;
|
NONE: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mirror zca-js sendMessage style constants locally because the package root
|
||||||
|
// typing surface does not consistently expose TextStyle/Style to tsgo.
|
||||||
|
export const TextStyle = {
|
||||||
|
Bold: "b",
|
||||||
|
Italic: "i",
|
||||||
|
Underline: "u",
|
||||||
|
StrikeThrough: "s",
|
||||||
|
Red: "c_db342e",
|
||||||
|
Orange: "c_f27806",
|
||||||
|
Yellow: "c_f7b503",
|
||||||
|
Green: "c_15a85f",
|
||||||
|
Small: "f_13",
|
||||||
|
Big: "f_18",
|
||||||
|
UnorderedList: "lst_1",
|
||||||
|
OrderedList: "lst_2",
|
||||||
|
Indent: "ind_$",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle];
|
||||||
|
|
||||||
|
export type Style =
|
||||||
|
| {
|
||||||
|
start: number;
|
||||||
|
len: number;
|
||||||
|
st: Exclude<TextStyleValue, typeof TextStyle.Indent>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
start: number;
|
||||||
|
len: number;
|
||||||
|
st: typeof TextStyle.Indent;
|
||||||
|
indentSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type Credentials = {
|
export type Credentials = {
|
||||||
imei: string;
|
imei: string;
|
||||||
cookie: unknown;
|
cookie: unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user