mirror of https://github.com/openclaw/openclaw.git
273 lines
6.8 KiB
TypeScript
273 lines
6.8 KiB
TypeScript
import { parseZalouserTextStyles } from "./text-styles.js";
|
|
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
|
|
import {
|
|
sendZaloDeliveredEvent,
|
|
sendZaloLink,
|
|
sendZaloReaction,
|
|
sendZaloSeenEvent,
|
|
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> {
|
|
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(
|
|
threadId: string,
|
|
imageUrl: string,
|
|
options: ZalouserSendOptions = {},
|
|
): Promise<ZalouserSendResult> {
|
|
return await sendMessageZalouser(threadId, options.caption ?? "", {
|
|
...options,
|
|
caption: undefined,
|
|
mediaUrl: imageUrl,
|
|
});
|
|
}
|
|
|
|
export async function sendLinkZalouser(
|
|
threadId: string,
|
|
url: string,
|
|
options: ZalouserSendOptions = {},
|
|
): Promise<ZalouserSendResult> {
|
|
return await sendZaloLink(threadId, url, options);
|
|
}
|
|
|
|
export async function sendTypingZalouser(
|
|
threadId: string,
|
|
options: Pick<ZalouserSendOptions, "profile" | "isGroup"> = {},
|
|
): Promise<void> {
|
|
await sendZaloTypingEvent(threadId, options);
|
|
}
|
|
|
|
export async function sendReactionZalouser(params: {
|
|
threadId: string;
|
|
msgId: string;
|
|
cliMsgId: string;
|
|
emoji: string;
|
|
remove?: boolean;
|
|
profile?: string;
|
|
isGroup?: boolean;
|
|
}): Promise<ZalouserSendResult> {
|
|
const result = await sendZaloReaction({
|
|
profile: params.profile,
|
|
threadId: params.threadId,
|
|
isGroup: params.isGroup,
|
|
msgId: params.msgId,
|
|
cliMsgId: params.cliMsgId,
|
|
emoji: params.emoji,
|
|
remove: params.remove,
|
|
});
|
|
return {
|
|
ok: result.ok,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
export async function sendDeliveredZalouser(params: {
|
|
profile?: string;
|
|
isGroup?: boolean;
|
|
message: ZaloEventMessage;
|
|
isSeen?: boolean;
|
|
}): Promise<void> {
|
|
await sendZaloDeliveredEvent(params);
|
|
}
|
|
|
|
export async function sendSeenZalouser(params: {
|
|
profile?: string;
|
|
isGroup?: boolean;
|
|
message: ZaloEventMessage;
|
|
}): 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;
|
|
}
|