diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 0cef65f8c05..d388773e2e6 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -5,6 +5,7 @@ import { primeSendMock, } from "../../../src/test-utils/send-payload-contract.js"; import { zalouserPlugin } from "./channel.js"; +import { setZalouserRuntime } from "./runtime.js"; vi.mock("./send.js", () => ({ sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }), @@ -38,6 +39,14 @@ describe("zalouserPlugin outbound sendPayload", () => { let mockedSend: ReturnType>; beforeEach(async () => { + setZalouserRuntime({ + channel: { + text: { + resolveChunkMode: vi.fn(() => "length"), + resolveTextChunkLimit: vi.fn(() => 1200), + }, + }, + } as never); const mod = await import("./send.js"); mockedSend = vi.mocked(mod.sendMessageZalouser); mockedSend.mockClear(); @@ -55,7 +64,7 @@ describe("zalouserPlugin outbound sendPayload", () => { expect(mockedSend).toHaveBeenCalledWith( "1471383327500481391", "hello group", - expect.objectContaining({ isGroup: true }), + expect.objectContaining({ isGroup: true, textMode: "markdown" }), ); expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" }); }); @@ -71,7 +80,7 @@ describe("zalouserPlugin outbound sendPayload", () => { expect(mockedSend).toHaveBeenCalledWith( "987654321", "hello", - expect.objectContaining({ isGroup: false }), + expect.objectContaining({ isGroup: false, textMode: "markdown" }), ); expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-d1" }); }); @@ -87,14 +96,37 @@ describe("zalouserPlugin outbound sendPayload", () => { expect(mockedSend).toHaveBeenCalledWith( "g-1471383327500481391", "hello native group", - expect.objectContaining({ isGroup: true }), + expect.objectContaining({ isGroup: true, textMode: "markdown" }), ); 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({ channel: "zalouser", - chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, + chunking: { mode: "passthrough", longTextLength: 3000 }, createHarness: ({ payload, sendResults }) => { primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults); return { diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index 231bcc8b2d3..5580ddfb2e1 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js"; import { zalouserPlugin } from "./channel.js"; +import { setZalouserRuntime } from "./runtime.js"; import { sendReactionZalouser } from "./send.js"; vi.mock("./send.js", async (importOriginal) => { @@ -13,6 +15,16 @@ vi.mock("./send.js", async (importOriginal) => { const mockSendReaction = vi.mocked(sendReactionZalouser); describe("zalouser outbound chunker", () => { + beforeEach(() => { + setZalouserRuntime({ + channel: { + text: { + chunkMarkdownText, + }, + }, + } as never); + }); + it("chunks without empty strings and respects limit", () => { const chunker = zalouserPlugin.outbound?.chunker; expect(chunker).toBeTypeOf("function"); diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 2091124be6e..a2e3435f75c 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -20,7 +20,6 @@ import { buildBaseAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - chunkTextForOutbound, deleteAccountFromConfigSection, formatAllowFromLowercase, isNumericTargetId, @@ -43,6 +42,7 @@ import { resolveZalouserReactionMessageIds } from "./message-sid.js"; import { zalouserOnboardingAdapter } from "./onboarding.js"; import { probeZalouser } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; +import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { @@ -166,6 +166,16 @@ function resolveZalouserQrProfile(accountId?: string | null): string { 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: { id: string; name?: string | null; @@ -595,14 +605,9 @@ export const zalouserPlugin: ChannelPlugin = { }, outbound: { deliveryMode: "direct", - chunker: chunkTextForOutbound, - chunkerMode: "text", - textChunkLimit: 2000, sendPayload: async (ctx) => await sendPayloadWithChunkedTextAndMedia({ ctx, - textChunkLimit: zalouserPlugin.outbound!.textChunkLimit, - chunker: zalouserPlugin.outbound!.chunker, sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx), sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx), emptyResult: { channel: "zalouser", messageId: "" }, @@ -613,6 +618,9 @@ export const zalouserPlugin: ChannelPlugin = { const result = await sendMessageZalouser(target.threadId, text, { profile: account.profile, isGroup: target.isGroup, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), }); return buildChannelSendResult("zalouser", result); }, @@ -624,6 +632,9 @@ export const zalouserPlugin: ChannelPlugin = { isGroup: target.isGroup, mediaUrl, mediaLocalRoots, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), }); return buildChannelSendResult("zalouser", result); }, diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index b3e38efecd6..49593f07072 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -51,6 +51,7 @@ function createRuntimeEnv(): RuntimeEnv { function installRuntime(params: { commandAuthorized?: boolean; + replyPayload?: { text?: string; mediaUrl?: string; mediaUrls?: string[] }; resolveCommandAuthorizedFromAuthorizers?: (params: { useAccessGroups: boolean; authorizers: Array<{ configured: boolean; allowed: boolean }>; @@ -58,6 +59,9 @@ function installRuntime(params: { }) { const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => { await dispatcherOptions.typingCallbacks?.onReplyStart?.(); + if (params.replyPayload) { + await dispatcherOptions.deliver(params.replyPayload); + } return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx }; }); const resolveCommandAuthorizedFromAuthorizers = vi.fn( @@ -166,7 +170,8 @@ function installRuntime(params: { text: { resolveMarkdownTableMode: vi.fn(() => "code"), convertMarkdownTables: vi.fn((text: string) => text), - resolveChunkMode: vi.fn(() => "line"), + resolveChunkMode: vi.fn(() => "length"), + resolveTextChunkLimit: vi.fn(() => 1200), chunkMarkdownTextWithMode: vi.fn((text: string) => [text]), }, }, @@ -304,6 +309,42 @@ describe("zalouser monitor group mention gating", () => { 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 () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: true, diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 6590082e830..5329b22fa68 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -703,6 +703,10 @@ async function deliverZalouserReply(params: { params; const tableMode = params.tableMode ?? "code"; 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({ mediaUrls: resolveOutboundMediaUrls(payload), @@ -713,6 +717,9 @@ async function deliverZalouserReply(params: { profile, mediaUrl, isGroup, + textMode: "markdown", + textChunkMode: chunkMode, + textChunkLimit, }); statusSink?.({ lastOutboundAt: Date.now() }); }, @@ -725,20 +732,17 @@ async function deliverZalouserReply(params: { } if (text) { - const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId); - const chunks = core.channel.text.chunkMarkdownTextWithMode( - text, - ZALOUSER_TEXT_LIMIT, - chunkMode, - ); - logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`); - for (const chunk of chunks) { - try { - await sendMessageZalouser(chatId, chunk, { profile, isGroup }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error(`Zalouser message send failed: ${String(err)}`); - } + try { + await sendMessageZalouser(chatId, text, { + profile, + isGroup, + textMode: "markdown", + textChunkMode: chunkMode, + textChunkLimit, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error(`Zalouser message send failed: ${String(err)}`); } } } diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts index 92b3cec25f2..cc920e6be7e 100644 --- a/extensions/zalouser/src/send.test.ts +++ b/extensions/zalouser/src/send.test.ts @@ -8,6 +8,7 @@ import { sendSeenZalouser, sendTypingZalouser, } from "./send.js"; +import { parseZalouserTextStyles } from "./text-styles.js"; import { sendZaloDeliveredEvent, sendZaloLink, @@ -16,6 +17,7 @@ import { sendZaloTextMessage, sendZaloTypingEvent, } from "./zalo-js.js"; +import { TextStyle } from "./zca-client.js"; vi.mock("./zalo-js.js", () => ({ sendZaloTextMessage: vi.fn(), @@ -43,36 +45,272 @@ describe("zalouser send helpers", () => { 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" }); - const result = await sendMessageZalouser("thread-1", "hello", { + const result = await sendMessageZalouser("thread-1", "**hello**", { profile: "default", isGroup: true, }); - expect(mockSendText).toHaveBeenCalledWith("thread-1", "hello", { - profile: "default", - isGroup: true, - }); + expect(mockSendText).toHaveBeenCalledWith( + "thread-1", + "**hello**", + expect.objectContaining({ + profile: "default", + isGroup: true, + }), + ); 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" }); await sendImageZalouser("thread-2", "https://example.com/a.png", { profile: "p2", - caption: "cap", + caption: "_cap_", 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", - caption: "cap", + caption: "```\n```", 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 () => { diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index 07ae1408bff..55ff17df636 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -1,3 +1,4 @@ +import { parseZalouserTextStyles } from "./text-styles.js"; import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js"; import { sendZaloDeliveredEvent, @@ -7,16 +8,58 @@ import { sendZaloTextMessage, sendZaloTypingEvent, } from "./zalo-js.js"; +import { TextStyle } from "./zca-client.js"; export type ZalouserSendOptions = ZaloSendOptions; export type ZalouserSendResult = ZaloSendResult; +const ZALO_TEXT_LIMIT = 2000; +const DEFAULT_TEXT_CHUNK_MODE = "length"; + +type StyledTextChunk = { + text: string; + styles?: ZaloSendOptions["textStyles"]; +}; + +type TextChunkMode = NonNullable; + export async function sendMessageZalouser( threadId: string, text: string, options: ZalouserSendOptions = {}, ): Promise { - 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( @@ -24,8 +67,9 @@ export async function sendImageZalouser( imageUrl: string, options: ZalouserSendOptions = {}, ): Promise { - return await sendZaloTextMessage(threadId, options.caption ?? "", { + return await sendMessageZalouser(threadId, options.caption ?? "", { ...options, + caption: undefined, mediaUrl: imageUrl, }); } @@ -85,3 +129,144 @@ export async function sendSeenZalouser(params: { }): Promise { 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 => 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; +} diff --git a/extensions/zalouser/src/text-styles.test.ts b/extensions/zalouser/src/text-styles.test.ts new file mode 100644 index 00000000000..01e6c2da86b --- /dev/null +++ b/extensions/zalouser/src/text-styles.test.ts @@ -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: [], + }); + }); +}); diff --git a/extensions/zalouser/src/text-styles.ts b/extensions/zalouser/src/text-styles.ts new file mode 100644 index 00000000000..cdfe8b492b5 --- /dev/null +++ b/extensions/zalouser/src/text-styles.ts @@ -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 = { + 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: /(? match[1], + resolveStyles: () => [TextStyle.Bold, TextStyle.Italic], + }, + { + pattern: /(? match[1], + resolveStyles: () => [TextStyle.Bold], + }, + { + pattern: /(? match[1], + resolveStyles: () => [TextStyle.Bold], + }, + { + pattern: /(? match[1], + resolveStyles: () => [TextStyle.StrikeThrough], + }, + { + pattern: /(? match[1], + resolveStyles: () => [TextStyle.Italic], + }, + { + pattern: /(? 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); +} diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index d704a1b3f78..e6343b1f6bd 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -1,3 +1,5 @@ +import type { Style } from "./zca-client.js"; + export type ZcaFriend = { userId: string; displayName: string; @@ -59,6 +61,10 @@ export type ZaloSendOptions = { caption?: string; isGroup?: boolean; mediaLocalRoots?: readonly string[]; + textMode?: "markdown" | "plain"; + textChunkMode?: "length" | "newline"; + textChunkLimit?: number; + textStyles?: Style[]; }; export type ZaloSendResult = { diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index 25d263b7d6a..0e2d744232f 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -20,6 +20,7 @@ import type { } from "./types.js"; import { LoginQRCallbackEventType, + TextStyle, ThreadType, Zalo, type API, @@ -136,6 +137,39 @@ function toErrorMessage(error: unknown): string { 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 => style !== null); + return clamped.length > 0 ? clamped : undefined; +} + function toNumberId(value: unknown): string { if (typeof value === "number" && Number.isFinite(value)) { return String(Math.trunc(value)); @@ -1018,11 +1052,16 @@ export async function sendZaloTextMessage( kind: media.kind, }); const payloadText = (text || options.caption || "").slice(0, 2000); + const textStyles = clampTextStyles(payloadText, options.textStyles); if (media.kind === "audio") { let textMessageId: string | undefined; 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); } @@ -1055,6 +1094,7 @@ export async function sendZaloTextMessage( const response = await api.sendMessage( { msg: payloadText, + ...(textStyles ? { styles: textStyles } : {}), attachments: [ { data: media.buffer, @@ -1071,7 +1111,13 @@ export async function sendZaloTextMessage( 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) }; } catch (error) { return { ok: false, error: toErrorMessage(error) }; diff --git a/extensions/zalouser/src/zca-client.ts b/extensions/zalouser/src/zca-client.ts index 57172eef64d..00a1c8c1be0 100644 --- a/extensions/zalouser/src/zca-client.ts +++ b/extensions/zalouser/src/zca-client.ts @@ -28,6 +28,39 @@ export const Reactions = ReactionsRuntime as Record & { 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; + } + | { + start: number; + len: number; + st: typeof TextStyle.Indent; + indentSize?: number; + }; + export type Credentials = { imei: string; cookie: unknown;