mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 23:40:45 +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,
|
||||
} 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<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>;
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<ResolvedZalouserAccount> = {
|
||||
},
|
||||
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<ResolvedZalouserAccount> = {
|
||||
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<ResolvedZalouserAccount> = {
|
||||
isGroup: target.isGroup,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
textMode: "markdown",
|
||||
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
|
||||
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
|
||||
});
|
||||
return buildChannelSendResult("zalouser", result);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<ZaloSendOptions["textChunkMode"]>;
|
||||
|
||||
export async function sendMessageZalouser(
|
||||
threadId: string,
|
||||
text: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): 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(
|
||||
@@ -24,8 +67,9 @@ export async function sendImageZalouser(
|
||||
imageUrl: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
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<void> {
|
||||
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 = {
|
||||
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 = {
|
||||
|
||||
@@ -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<typeof style> => 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) };
|
||||
|
||||
@@ -28,6 +28,39 @@ export const Reactions = ReactionsRuntime as Record<string, 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 = {
|
||||
imei: string;
|
||||
cookie: unknown;
|
||||
|
||||
Reference in New Issue
Block a user