Files
openclaw/extensions/zalouser/src/zalo-js.ts
darkamenosa a6711afdc2 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
2026-03-12 16:24:15 +07:00

1695 lines
48 KiB
TypeScript

import { randomUUID } from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/zalouser";
import { normalizeZaloReactionIcon } from "./reaction.js";
import { getZalouserRuntime } from "./runtime.js";
import type {
ZaloAuthStatus,
ZaloEventMessage,
ZaloGroupContext,
ZaloGroup,
ZaloGroupMember,
ZaloInboundMessage,
ZaloSendOptions,
ZaloSendResult,
ZcaFriend,
ZcaUserInfo,
} from "./types.js";
import {
LoginQRCallbackEventType,
TextStyle,
ThreadType,
Zalo,
type API,
type Credentials,
type GroupInfo,
type LoginQRCallbackEvent,
type Message,
type User,
} from "./zca-client.js";
const API_LOGIN_TIMEOUT_MS = 20_000;
const QR_LOGIN_TTL_MS = 3 * 60_000;
const DEFAULT_QR_START_TIMEOUT_MS = 30_000;
const DEFAULT_QR_WAIT_TIMEOUT_MS = 120_000;
const GROUP_INFO_CHUNK_SIZE = 80;
const GROUP_CONTEXT_CACHE_TTL_MS = 5 * 60_000;
const GROUP_CONTEXT_CACHE_MAX_ENTRIES = 500;
const LISTENER_WATCHDOG_INTERVAL_MS = 30_000;
const LISTENER_WATCHDOG_MAX_GAP_MS = 35_000;
const apiByProfile = new Map<string, API>();
const apiInitByProfile = new Map<string, Promise<API>>();
type ActiveZaloQrLogin = {
id: string;
profile: string;
startedAt: number;
qrDataUrl?: string;
connected: boolean;
error?: string;
abort?: () => void;
waitPromise: Promise<void>;
};
const activeQrLogins = new Map<string, ActiveZaloQrLogin>();
type ActiveZaloListener = {
profile: string;
accountId: string;
stop: () => void;
};
const activeListeners = new Map<string, ActiveZaloListener>();
const groupContextCache = new Map<string, { value: ZaloGroupContext; expiresAt: number }>();
type AccountInfoResponse = Awaited<ReturnType<API["fetchAccountInfo"]>>;
type ApiTypingCapability = {
sendTypingEvent: (
threadId: string,
type?: (typeof ThreadType)[keyof typeof ThreadType],
) => Promise<unknown>;
};
type StoredZaloCredentials = {
imei: string;
cookie: Credentials["cookie"];
userAgent: string;
language?: string;
createdAt: string;
lastUsedAt?: string;
};
function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string {
return getZalouserRuntime().state.resolveStateDir(env, os.homedir);
}
function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), "credentials", "zalouser");
}
function credentialsFilename(profile: string): string {
const trimmed = profile.trim().toLowerCase();
if (!trimmed || trimmed === "default") {
return "credentials.json";
}
return `credentials-${encodeURIComponent(trimmed)}.json`;
}
function resolveCredentialsPath(profile: string, env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveCredentialsDir(env), credentialsFilename(profile));
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(label));
}, timeoutMs);
void promise
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeProfile(profile?: string | null): string {
const trimmed = profile?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "default";
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
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));
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed.replace(/_\d+$/, "");
}
}
return "";
}
function toStringValue(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(Math.trunc(value));
}
return "";
}
function normalizeAccountInfoUser(info: AccountInfoResponse): User | null {
if (!info || typeof info !== "object") {
return null;
}
if ("profile" in info) {
const profile = (info as { profile?: unknown }).profile;
if (profile && typeof profile === "object") {
return profile as User;
}
return null;
}
return info as User;
}
function toInteger(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.trunc(value);
}
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.trunc(parsed);
}
function normalizeMessageContent(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (!content || typeof content !== "object") {
return "";
}
const record = content as Record<string, unknown>;
const title = typeof record.title === "string" ? record.title.trim() : "";
const description = typeof record.description === "string" ? record.description.trim() : "";
const href = typeof record.href === "string" ? record.href.trim() : "";
const combined = [title, description, href].filter(Boolean).join("\n").trim();
if (combined) {
return combined;
}
try {
return JSON.stringify(content);
} catch {
return "";
}
}
function resolveInboundTimestamp(rawTs: unknown): number {
if (typeof rawTs === "number" && Number.isFinite(rawTs)) {
return rawTs > 1_000_000_000_000 ? rawTs : rawTs * 1000;
}
const parsed = Number.parseInt(String(rawTs ?? ""), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return Date.now();
}
return parsed > 1_000_000_000_000 ? parsed : parsed * 1000;
}
function extractMentionIds(rawMentions: unknown): string[] {
if (!Array.isArray(rawMentions)) {
return [];
}
const sink = new Set<string>();
for (const entry of rawMentions) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as { uid?: unknown };
const id = toNumberId(record.uid);
if (id) {
sink.add(id);
}
}
return Array.from(sink);
}
type MentionSpan = {
start: number;
end: number;
};
function toNonNegativeInteger(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
const normalized = Math.trunc(value);
return normalized >= 0 ? normalized : null;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed)) {
return parsed >= 0 ? parsed : null;
}
}
return null;
}
function extractOwnMentionSpans(
rawMentions: unknown,
ownUserId: string,
contentLength: number,
): MentionSpan[] {
if (!Array.isArray(rawMentions) || !ownUserId || contentLength <= 0) {
return [];
}
const spans: MentionSpan[] = [];
for (const entry of rawMentions) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as {
uid?: unknown;
pos?: unknown;
start?: unknown;
offset?: unknown;
len?: unknown;
length?: unknown;
};
const uid = toNumberId(record.uid);
if (!uid || uid !== ownUserId) {
continue;
}
const startRaw = toNonNegativeInteger(record.pos ?? record.start ?? record.offset);
const lengthRaw = toNonNegativeInteger(record.len ?? record.length);
if (startRaw === null || lengthRaw === null || lengthRaw <= 0) {
continue;
}
const start = Math.min(startRaw, contentLength);
const end = Math.min(start + lengthRaw, contentLength);
if (end <= start) {
continue;
}
spans.push({ start, end });
}
if (spans.length <= 1) {
return spans;
}
spans.sort((a, b) => a.start - b.start);
const merged: MentionSpan[] = [];
for (const span of spans) {
const last = merged[merged.length - 1];
if (!last || span.start > last.end) {
merged.push({ ...span });
continue;
}
last.end = Math.max(last.end, span.end);
}
return merged;
}
function stripOwnMentionsForCommandBody(
content: string,
rawMentions: unknown,
ownUserId: string,
): string {
if (!content || !ownUserId) {
return content;
}
const spans = extractOwnMentionSpans(rawMentions, ownUserId, content.length);
if (spans.length === 0) {
return stripLeadingAtMentionForCommand(content);
}
let cursor = 0;
let output = "";
for (const span of spans) {
if (span.start > cursor) {
output += content.slice(cursor, span.start);
}
cursor = Math.max(cursor, span.end);
}
if (cursor < content.length) {
output += content.slice(cursor);
}
return output.replace(/\s+/g, " ").trim();
}
function stripLeadingAtMentionForCommand(content: string): string {
const fallbackMatch = content.match(/^\s*@[^\s]+(?:\s+|[:,-]\s*)([/!][\s\S]*)$/);
if (!fallbackMatch) {
return content;
}
return fallbackMatch[1].trim();
}
function resolveGroupNameFromMessageData(data: Record<string, unknown>): string | undefined {
const candidates = [data.groupName, data.gName, data.idToName, data.threadName, data.roomName];
for (const candidate of candidates) {
const value = toStringValue(candidate);
if (value) {
return value;
}
}
return undefined;
}
function buildEventMessage(data: Record<string, unknown>): ZaloEventMessage | undefined {
const msgId = toStringValue(data.msgId);
const cliMsgId = toStringValue(data.cliMsgId);
const uidFrom = toStringValue(data.uidFrom);
const idTo = toStringValue(data.idTo);
if (!msgId || !cliMsgId || !uidFrom || !idTo) {
return undefined;
}
return {
msgId,
cliMsgId,
uidFrom,
idTo,
msgType: toStringValue(data.msgType) || "webchat",
st: toInteger(data.st, 0),
at: toInteger(data.at, 0),
cmd: toInteger(data.cmd, 0),
ts: toStringValue(data.ts) || Date.now(),
};
}
function extractSendMessageId(result: unknown): string | undefined {
if (!result || typeof result !== "object") {
return undefined;
}
const payload = result as {
msgId?: string | number;
message?: { msgId?: string | number } | null;
attachment?: Array<{ msgId?: string | number }>;
};
const direct = payload.msgId;
if (direct !== undefined && direct !== null) {
return String(direct);
}
const primary = payload.message?.msgId;
if (primary !== undefined && primary !== null) {
return String(primary);
}
const attachmentId = payload.attachment?.[0]?.msgId;
if (attachmentId !== undefined && attachmentId !== null) {
return String(attachmentId);
}
return undefined;
}
function resolveMediaFileName(params: {
mediaUrl: string;
fileName?: string;
contentType?: string;
kind?: string;
}): string {
const explicit = params.fileName?.trim();
if (explicit) {
return explicit;
}
try {
const parsed = new URL(params.mediaUrl);
const fromPath = path.basename(parsed.pathname).trim();
if (fromPath) {
return fromPath;
}
} catch {
// ignore URL parse failures
}
const ext =
params.contentType === "image/png"
? "png"
: params.contentType === "image/webp"
? "webp"
: params.contentType === "image/jpeg"
? "jpg"
: params.contentType === "video/mp4"
? "mp4"
: params.contentType === "audio/mpeg"
? "mp3"
: params.contentType === "audio/ogg"
? "ogg"
: params.contentType === "audio/wav"
? "wav"
: params.kind === "video"
? "mp4"
: params.kind === "audio"
? "mp3"
: params.kind === "image"
? "jpg"
: "bin";
return `upload.${ext}`;
}
function resolveUploadedVoiceAsset(
uploaded: Array<{
fileType?: string;
fileUrl?: string;
fileName?: string;
}>,
): { fileUrl: string; fileName?: string } | undefined {
for (const item of uploaded) {
if (!item || typeof item !== "object") {
continue;
}
const fileType = item.fileType?.toLowerCase();
const fileUrl = item.fileUrl?.trim();
if (!fileUrl) {
continue;
}
if (fileType === "others" || fileType === "video") {
return { fileUrl, fileName: item.fileName?.trim() || undefined };
}
}
return undefined;
}
function buildZaloVoicePlaybackUrl(asset: { fileUrl: string; fileName?: string }): string {
// zca-js uses uploadAttachment(...).fileUrl directly for sendVoice.
// Appending filename can produce URLs that play only in the local session.
return asset.fileUrl.trim();
}
function mapFriend(friend: User): ZcaFriend {
return {
userId: String(friend.userId),
displayName: friend.displayName || friend.zaloName || friend.username || String(friend.userId),
avatar: friend.avatar || undefined,
};
}
function mapGroup(groupId: string, group: GroupInfo & Record<string, unknown>): ZaloGroup {
const totalMember =
typeof group.totalMember === "number" && Number.isFinite(group.totalMember)
? group.totalMember
: undefined;
return {
groupId: String(groupId),
name: group.name?.trim() || String(groupId),
memberCount: totalMember,
};
}
function readCredentials(profile: string): StoredZaloCredentials | null {
const filePath = resolveCredentialsPath(profile);
try {
if (!fs.existsSync(filePath)) {
return null;
}
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<StoredZaloCredentials>;
if (
typeof parsed.imei !== "string" ||
!parsed.imei ||
!parsed.cookie ||
typeof parsed.userAgent !== "string" ||
!parsed.userAgent
) {
return null;
}
return {
imei: parsed.imei,
cookie: parsed.cookie as Credentials["cookie"],
userAgent: parsed.userAgent,
language: typeof parsed.language === "string" ? parsed.language : undefined,
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
lastUsedAt: typeof parsed.lastUsedAt === "string" ? parsed.lastUsedAt : undefined,
};
} catch {
return null;
}
}
function touchCredentials(profile: string): void {
const existing = readCredentials(profile);
if (!existing) {
return;
}
const next: StoredZaloCredentials = {
...existing,
lastUsedAt: new Date().toISOString(),
};
const dir = resolveCredentialsDir();
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(resolveCredentialsPath(profile), JSON.stringify(next, null, 2), "utf-8");
}
function writeCredentials(
profile: string,
credentials: Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt">,
): void {
const dir = resolveCredentialsDir();
fs.mkdirSync(dir, { recursive: true });
const existing = readCredentials(profile);
const now = new Date().toISOString();
const next: StoredZaloCredentials = {
...credentials,
createdAt: existing?.createdAt ?? now,
lastUsedAt: now,
};
fs.writeFileSync(resolveCredentialsPath(profile), JSON.stringify(next, null, 2), "utf-8");
}
function clearCredentials(profile: string): boolean {
const filePath = resolveCredentialsPath(profile);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
return true;
}
} catch {
// ignore
}
return false;
}
async function ensureApi(
profileInput?: string | null,
timeoutMs = API_LOGIN_TIMEOUT_MS,
): Promise<API> {
const profile = normalizeProfile(profileInput);
const cached = apiByProfile.get(profile);
if (cached) {
return cached;
}
const pending = apiInitByProfile.get(profile);
if (pending) {
return await pending;
}
const initPromise = (async () => {
const stored = readCredentials(profile);
if (!stored) {
throw new Error(`No saved Zalo session for profile \"${profile}\"`);
}
const zalo = new Zalo({
logging: false,
selfListen: false,
});
const api = await withTimeout(
zalo.login({
imei: stored.imei,
cookie: stored.cookie,
userAgent: stored.userAgent,
language: stored.language,
}),
timeoutMs,
`Timed out restoring Zalo session for profile \"${profile}\"`,
);
apiByProfile.set(profile, api);
touchCredentials(profile);
return api;
})();
apiInitByProfile.set(profile, initPromise);
try {
return await initPromise;
} catch (error) {
apiByProfile.delete(profile);
throw error;
} finally {
apiInitByProfile.delete(profile);
}
}
function invalidateApi(profileInput?: string | null): void {
const profile = normalizeProfile(profileInput);
const api = apiByProfile.get(profile);
if (api) {
try {
api.listener.stop();
} catch {
// ignore
}
}
apiByProfile.delete(profile);
apiInitByProfile.delete(profile);
}
function isQrLoginFresh(login: ActiveZaloQrLogin): boolean {
return Date.now() - login.startedAt < QR_LOGIN_TTL_MS;
}
function resetQrLogin(profileInput?: string | null): void {
const profile = normalizeProfile(profileInput);
const active = activeQrLogins.get(profile);
if (!active) {
return;
}
try {
active.abort?.();
} catch {
// ignore
}
activeQrLogins.delete(profile);
}
async function fetchGroupsByIds(api: API, ids: string[]): Promise<Map<string, GroupInfo>> {
const result = new Map<string, GroupInfo>();
for (let index = 0; index < ids.length; index += GROUP_INFO_CHUNK_SIZE) {
const chunk = ids.slice(index, index + GROUP_INFO_CHUNK_SIZE);
if (chunk.length === 0) {
continue;
}
const response = await api.getGroupInfo(chunk);
const map = response.gridInfoMap ?? {};
for (const [groupId, info] of Object.entries(map)) {
result.set(groupId, info);
}
}
return result;
}
function makeGroupContextCacheKey(profile: string, groupId: string): string {
return `${profile}:${groupId}`;
}
function readCachedGroupContext(profile: string, groupId: string): ZaloGroupContext | null {
const key = makeGroupContextCacheKey(profile, groupId);
const cached = groupContextCache.get(key);
if (!cached) {
return null;
}
if (cached.expiresAt <= Date.now()) {
groupContextCache.delete(key);
return null;
}
// Bump recency so hot groups stay in cache when enforcing max entries.
groupContextCache.delete(key);
groupContextCache.set(key, cached);
return cached.value;
}
function trimGroupContextCache(now: number): void {
for (const [key, value] of groupContextCache) {
if (value.expiresAt > now) {
continue;
}
groupContextCache.delete(key);
}
while (groupContextCache.size > GROUP_CONTEXT_CACHE_MAX_ENTRIES) {
const oldestKey = groupContextCache.keys().next().value;
if (!oldestKey) {
break;
}
groupContextCache.delete(oldestKey);
}
}
function writeCachedGroupContext(profile: string, context: ZaloGroupContext): void {
const now = Date.now();
const key = makeGroupContextCacheKey(profile, context.groupId);
if (groupContextCache.has(key)) {
groupContextCache.delete(key);
}
groupContextCache.set(key, {
value: context,
expiresAt: now + GROUP_CONTEXT_CACHE_TTL_MS,
});
trimGroupContextCache(now);
}
function clearCachedGroupContext(profile: string): void {
for (const key of groupContextCache.keys()) {
if (key.startsWith(`${profile}:`)) {
groupContextCache.delete(key);
}
}
}
function extractGroupMembersFromInfo(
groupInfo: (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] }) | undefined,
): string[] | undefined {
if (!groupInfo || !Array.isArray(groupInfo.currentMems)) {
return undefined;
}
const members = groupInfo.currentMems
.map((member) => {
if (!member || typeof member !== "object") {
return "";
}
const record = member as { dName?: unknown; zaloName?: unknown };
return toStringValue(record.dName) || toStringValue(record.zaloName);
})
.filter(Boolean);
if (members.length === 0) {
return undefined;
}
return members;
}
function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMessage | null {
const data = message.data as Record<string, unknown>;
const isGroup = message.type === ThreadType.Group;
const senderId = toNumberId(data.uidFrom);
const threadId = isGroup
? toNumberId(data.idTo)
: toNumberId(data.uidFrom) || toNumberId(data.idTo);
if (!threadId || !senderId) {
return null;
}
const content = normalizeMessageContent(data.content);
const normalizedOwnUserId = toNumberId(ownUserId);
const mentionIds = extractMentionIds(data.mentions);
const quoteOwnerId =
data.quote && typeof data.quote === "object"
? toNumberId((data.quote as { ownerId?: unknown }).ownerId)
: "";
const hasAnyMention = mentionIds.length > 0;
const canResolveExplicitMention = Boolean(normalizedOwnUserId);
const wasExplicitlyMentioned = Boolean(
normalizedOwnUserId && mentionIds.some((id) => id === normalizedOwnUserId),
);
const commandContent = wasExplicitlyMentioned
? stripOwnMentionsForCommandBody(content, data.mentions, normalizedOwnUserId)
: hasAnyMention && !canResolveExplicitMention
? stripLeadingAtMentionForCommand(content)
: content;
const implicitMention = Boolean(
normalizedOwnUserId && quoteOwnerId && quoteOwnerId === normalizedOwnUserId,
);
const eventMessage = buildEventMessage(data);
return {
threadId,
isGroup,
senderId,
senderName: typeof data.dName === "string" ? data.dName.trim() || undefined : undefined,
groupName: isGroup ? resolveGroupNameFromMessageData(data) : undefined,
content,
commandContent,
timestampMs: resolveInboundTimestamp(data.ts),
msgId: typeof data.msgId === "string" ? data.msgId : undefined,
cliMsgId: typeof data.cliMsgId === "string" ? data.cliMsgId : undefined,
hasAnyMention,
canResolveExplicitMention,
wasExplicitlyMentioned,
implicitMention,
eventMessage,
raw: message,
};
}
export function zalouserSessionExists(profileInput?: string | null): boolean {
const profile = normalizeProfile(profileInput);
return readCredentials(profile) !== null;
}
export async function checkZaloAuthenticated(profileInput?: string | null): Promise<boolean> {
const profile = normalizeProfile(profileInput);
if (!zalouserSessionExists(profile)) {
return false;
}
try {
const api = await ensureApi(profile, 12_000);
await withTimeout(api.fetchAccountInfo(), 12_000, "Timed out checking Zalo session");
return true;
} catch {
invalidateApi(profile);
return false;
}
}
export async function getZaloUserInfo(profileInput?: string | null): Promise<ZcaUserInfo | null> {
const profile = normalizeProfile(profileInput);
const api = await ensureApi(profile);
const info = await api.fetchAccountInfo();
const user = normalizeAccountInfoUser(info);
if (!user?.userId) {
return null;
}
return {
userId: String(user.userId),
displayName: user.displayName || user.zaloName || String(user.userId),
avatar: user.avatar || undefined,
};
}
export async function listZaloFriends(profileInput?: string | null): Promise<ZcaFriend[]> {
const profile = normalizeProfile(profileInput);
const api = await ensureApi(profile);
const friends = await api.getAllFriends();
return friends.map(mapFriend);
}
export async function listZaloFriendsMatching(
profileInput: string | null | undefined,
query?: string | null,
): Promise<ZcaFriend[]> {
const friends = await listZaloFriends(profileInput);
const q = query?.trim().toLowerCase();
if (!q) {
return friends;
}
const scored = friends
.map((friend) => {
const id = friend.userId.toLowerCase();
const name = friend.displayName.toLowerCase();
const exact = id === q || name === q;
const includes = id.includes(q) || name.includes(q);
return { friend, exact, includes };
})
.filter((entry) => entry.includes)
.sort((a, b) => Number(b.exact) - Number(a.exact));
return scored.map((entry) => entry.friend);
}
export async function listZaloGroups(profileInput?: string | null): Promise<ZaloGroup[]> {
const profile = normalizeProfile(profileInput);
const api = await ensureApi(profile);
const allGroups = await api.getAllGroups();
const ids = Object.keys(allGroups.gridVerMap ?? {});
if (ids.length === 0) {
return [];
}
const details = await fetchGroupsByIds(api, ids);
const rows: ZaloGroup[] = [];
for (const id of ids) {
const info = details.get(id);
if (!info) {
rows.push({ groupId: id, name: id });
continue;
}
rows.push(mapGroup(id, info as GroupInfo & Record<string, unknown>));
}
return rows;
}
export async function listZaloGroupsMatching(
profileInput: string | null | undefined,
query?: string | null,
): Promise<ZaloGroup[]> {
const groups = await listZaloGroups(profileInput);
const q = query?.trim().toLowerCase();
if (!q) {
return groups;
}
return groups.filter((group) => {
const id = group.groupId.toLowerCase();
const name = group.name.toLowerCase();
return id.includes(q) || name.includes(q);
});
}
export async function listZaloGroupMembers(
profileInput: string | null | undefined,
groupId: string,
): Promise<ZaloGroupMember[]> {
const profile = normalizeProfile(profileInput);
const api = await ensureApi(profile);
const infoResponse = await api.getGroupInfo(groupId);
const groupInfo = infoResponse.gridInfoMap?.[groupId] as
| (GroupInfo & { memVerList?: unknown })
| undefined;
if (!groupInfo) {
return [];
}
const memberIds = Array.isArray(groupInfo.memberIds)
? groupInfo.memberIds.map((id: unknown) => toNumberId(id)).filter(Boolean)
: [];
const memVerIds = Array.isArray(groupInfo.memVerList)
? groupInfo.memVerList.map((id: unknown) => toNumberId(id)).filter(Boolean)
: [];
const currentMembers = Array.isArray(groupInfo.currentMems) ? groupInfo.currentMems : [];
const currentById = new Map<string, { displayName?: string; avatar?: string }>();
for (const member of currentMembers) {
const id = toNumberId(member?.id);
if (!id) {
continue;
}
currentById.set(id, {
displayName: member.dName?.trim() || member.zaloName?.trim() || undefined,
avatar: member.avatar || undefined,
});
}
const uniqueIds = Array.from(
new Set<string>([...memberIds, ...memVerIds, ...currentById.keys()]),
);
const profileMap = new Map<string, { displayName?: string; avatar?: string }>();
if (uniqueIds.length > 0) {
const profiles = await api.getGroupMembersInfo(uniqueIds);
const profileEntries = profiles.profiles as Record<
string,
{
id?: string;
displayName?: string;
zaloName?: string;
avatar?: string;
}
>;
for (const [rawId, profileValue] of Object.entries(profileEntries)) {
const id = toNumberId(rawId) || toNumberId((profileValue as { id?: unknown })?.id);
if (!id || !profileValue) {
continue;
}
profileMap.set(id, {
displayName: profileValue.displayName?.trim() || profileValue.zaloName?.trim() || undefined,
avatar: profileValue.avatar || undefined,
});
}
}
return uniqueIds.map((id) => ({
userId: id,
displayName: profileMap.get(id)?.displayName || currentById.get(id)?.displayName || id,
avatar: profileMap.get(id)?.avatar || currentById.get(id)?.avatar,
}));
}
export async function resolveZaloGroupContext(
profileInput: string | null | undefined,
groupId: string,
): Promise<ZaloGroupContext> {
const profile = normalizeProfile(profileInput);
const normalizedGroupId = toNumberId(groupId) || groupId.trim();
if (!normalizedGroupId) {
throw new Error("groupId is required");
}
const cached = readCachedGroupContext(profile, normalizedGroupId);
if (cached) {
return cached;
}
const api = await ensureApi(profile);
const response = await api.getGroupInfo(normalizedGroupId);
const groupInfo = response.gridInfoMap?.[normalizedGroupId] as
| (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] })
| undefined;
const context: ZaloGroupContext = {
groupId: normalizedGroupId,
name: groupInfo?.name?.trim() || undefined,
members: extractGroupMembersFromInfo(groupInfo),
};
writeCachedGroupContext(profile, context);
return context;
}
export async function sendZaloTextMessage(
threadId: string,
text: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const profile = normalizeProfile(options.profile);
const trimmedThreadId = threadId.trim();
if (!trimmedThreadId) {
return { ok: false, error: "No threadId provided" };
}
const api = await ensureApi(profile);
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
try {
if (options.mediaUrl?.trim()) {
const media = await loadOutboundMediaFromUrl(options.mediaUrl.trim(), {
mediaLocalRoots: options.mediaLocalRoots,
});
const fileName = resolveMediaFileName({
mediaUrl: options.mediaUrl,
fileName: media.fileName,
contentType: media.contentType,
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(
textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
trimmedThreadId,
type,
);
textMessageId = extractSendMessageId(textResponse);
}
const attachmentFileName = fileName.includes(".") ? fileName : `${fileName}.bin`;
const uploaded = await api.uploadAttachment(
[
{
data: media.buffer,
filename: attachmentFileName as `${string}.${string}`,
metadata: {
totalSize: media.buffer.length,
},
},
],
trimmedThreadId,
type,
);
const voiceAsset = resolveUploadedVoiceAsset(uploaded);
if (!voiceAsset) {
throw new Error("Failed to resolve uploaded audio URL for voice message");
}
const voiceUrl = buildZaloVoicePlaybackUrl(voiceAsset);
const response = await api.sendVoice({ voiceUrl }, trimmedThreadId, type);
return {
ok: true,
messageId: extractSendMessageId(response) ?? textMessageId,
};
}
const response = await api.sendMessage(
{
msg: payloadText,
...(textStyles ? { styles: textStyles } : {}),
attachments: [
{
data: media.buffer,
filename: fileName.includes(".") ? fileName : `${fileName}.bin`,
metadata: {
totalSize: media.buffer.length,
},
},
],
},
trimmedThreadId,
type,
);
return { ok: true, messageId: extractSendMessageId(response) };
}
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) };
}
}
export async function sendZaloTypingEvent(
threadId: string,
options: Pick<ZaloSendOptions, "profile" | "isGroup"> = {},
): Promise<void> {
const profile = normalizeProfile(options.profile);
const trimmedThreadId = threadId.trim();
if (!trimmedThreadId) {
throw new Error("No threadId provided");
}
const api = await ensureApi(profile);
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
if ("sendTypingEvent" in api && typeof api.sendTypingEvent === "function") {
await (api as API & ApiTypingCapability).sendTypingEvent(trimmedThreadId, type);
return;
}
throw new Error("Zalo typing indicator is not supported by current API session");
}
async function resolveOwnUserId(api: API): Promise<string> {
try {
const info = await api.fetchAccountInfo();
const resolved = toNumberId(normalizeAccountInfoUser(info)?.userId);
if (resolved) {
return resolved;
}
} catch {
// Fall back to getOwnId when account info shape changes.
}
try {
const ownId = toNumberId(api.getOwnId());
if (ownId) {
return ownId;
}
} catch {
// Ignore fallback probe failures and keep mention detection conservative.
}
return "";
}
export async function sendZaloReaction(params: {
profile?: string | null;
threadId: string;
isGroup?: boolean;
msgId: string;
cliMsgId: string;
emoji: string;
remove?: boolean;
}): Promise<{ ok: boolean; error?: string }> {
const profile = normalizeProfile(params.profile);
const threadId = params.threadId.trim();
const msgId = toStringValue(params.msgId);
const cliMsgId = toStringValue(params.cliMsgId);
if (!threadId || !msgId || !cliMsgId) {
return { ok: false, error: "threadId, msgId, and cliMsgId are required" };
}
try {
const api = await ensureApi(profile);
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
const icon = params.remove
? { rType: -1, source: 6, icon: "" }
: normalizeZaloReactionIcon(params.emoji);
await api.addReaction(icon, {
data: { msgId, cliMsgId },
threadId,
type,
});
return { ok: true };
} catch (error) {
return { ok: false, error: toErrorMessage(error) };
}
}
export async function sendZaloDeliveredEvent(params: {
profile?: string | null;
isGroup?: boolean;
message: ZaloEventMessage;
isSeen?: boolean;
}): Promise<void> {
const profile = normalizeProfile(params.profile);
const api = await ensureApi(profile);
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
await api.sendDeliveredEvent(params.isSeen === true, params.message, type);
}
export async function sendZaloSeenEvent(params: {
profile?: string | null;
isGroup?: boolean;
message: ZaloEventMessage;
}): Promise<void> {
const profile = normalizeProfile(params.profile);
const api = await ensureApi(profile);
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
await api.sendSeenEvent(params.message, type);
}
export async function sendZaloLink(
threadId: string,
url: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const profile = normalizeProfile(options.profile);
const trimmedThreadId = threadId.trim();
const trimmedUrl = url.trim();
if (!trimmedThreadId) {
return { ok: false, error: "No threadId provided" };
}
if (!trimmedUrl) {
return { ok: false, error: "No URL provided" };
}
try {
const api = await ensureApi(profile);
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
const response = await api.sendLink(
{ link: trimmedUrl, msg: options.caption },
trimmedThreadId,
type,
);
return { ok: true, messageId: String(response.msgId) };
} catch (error) {
return { ok: false, error: toErrorMessage(error) };
}
}
export async function startZaloQrLogin(params: {
profile?: string | null;
force?: boolean;
timeoutMs?: number;
}): Promise<{ qrDataUrl?: string; message: string }> {
const profile = normalizeProfile(params.profile);
if (!params.force && (await checkZaloAuthenticated(profile))) {
const info = await getZaloUserInfo(profile).catch(() => null);
const name = info?.displayName ? ` (${info.displayName})` : "";
return {
message: `Zalo is already linked${name}.`,
};
}
if (params.force) {
await logoutZaloProfile(profile);
}
const existing = activeQrLogins.get(profile);
if (existing && isQrLoginFresh(existing)) {
if (existing.qrDataUrl) {
return {
qrDataUrl: existing.qrDataUrl,
message: "QR already active. Scan it with the Zalo app.",
};
}
} else if (existing) {
resetQrLogin(profile);
}
if (!activeQrLogins.has(profile)) {
const login: ActiveZaloQrLogin = {
id: randomUUID(),
profile,
startedAt: Date.now(),
connected: false,
waitPromise: Promise.resolve(),
};
login.waitPromise = (async () => {
let capturedCredentials: Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt"> | null =
null;
try {
const zalo = new Zalo({ logging: false, selfListen: false });
const api = await zalo.loginQR(undefined, (event: LoginQRCallbackEvent) => {
const current = activeQrLogins.get(profile);
if (!current || current.id !== login.id) {
return;
}
if (event.actions?.abort) {
current.abort = () => {
try {
event.actions?.abort?.();
} catch {
// ignore
}
};
}
switch (event.type) {
case LoginQRCallbackEventType.QRCodeGenerated: {
const image = event.data.image.replace(/^data:image\/png;base64,/, "");
current.qrDataUrl = image.startsWith("data:image")
? image
: `data:image/png;base64,${image}`;
break;
}
case LoginQRCallbackEventType.QRCodeExpired: {
try {
event.actions.retry();
} catch {
current.error = "QR expired before confirmation. Start login again.";
}
break;
}
case LoginQRCallbackEventType.QRCodeDeclined: {
current.error = "QR login was declined on the phone.";
break;
}
case LoginQRCallbackEventType.GotLoginInfo: {
capturedCredentials = {
imei: event.data.imei,
cookie: event.data.cookie,
userAgent: event.data.userAgent,
};
break;
}
default:
break;
}
});
const current = activeQrLogins.get(profile);
if (!current || current.id !== login.id) {
return;
}
if (!capturedCredentials) {
const ctx = api.getContext();
const cookieJar = api.getCookie();
const cookieJson = cookieJar.toJSON();
capturedCredentials = {
imei: ctx.imei,
cookie: cookieJson?.cookies ?? [],
userAgent: ctx.userAgent,
language: ctx.language,
};
}
writeCredentials(profile, capturedCredentials);
invalidateApi(profile);
apiByProfile.set(profile, api);
current.connected = true;
} catch (error) {
const current = activeQrLogins.get(profile);
if (current && current.id === login.id) {
current.error = toErrorMessage(error);
}
}
})();
activeQrLogins.set(profile, login);
}
const active = activeQrLogins.get(profile);
if (!active) {
return { message: "Failed to initialize Zalo QR login." };
}
const timeoutMs = Math.max(params.timeoutMs ?? DEFAULT_QR_START_TIMEOUT_MS, 3000);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (active.error) {
resetQrLogin(profile);
return {
message: `Failed to start QR login: ${active.error}`,
};
}
if (active.connected) {
resetQrLogin(profile);
return {
message: "Zalo already connected.",
};
}
if (active.qrDataUrl) {
return {
qrDataUrl: active.qrDataUrl,
message: "Scan this QR with the Zalo app.",
};
}
await delay(150);
}
return {
message: "Still preparing QR. Call wait to continue checking login status.",
};
}
export async function waitForZaloQrLogin(params: {
profile?: string | null;
timeoutMs?: number;
}): Promise<ZaloAuthStatus> {
const profile = normalizeProfile(params.profile);
const active = activeQrLogins.get(profile);
if (!active) {
const connected = await checkZaloAuthenticated(profile);
return {
connected,
message: connected ? "Zalo session is ready." : "No active Zalo QR login in progress.",
};
}
if (!isQrLoginFresh(active)) {
resetQrLogin(profile);
return {
connected: false,
message: "QR login expired. Start again to generate a fresh QR code.",
};
}
const timeoutMs = Math.max(params.timeoutMs ?? DEFAULT_QR_WAIT_TIMEOUT_MS, 1000);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (active.error) {
const message = `Zalo login failed: ${active.error}`;
resetQrLogin(profile);
return {
connected: false,
message,
};
}
if (active.connected) {
resetQrLogin(profile);
return {
connected: true,
message: "Login successful.",
};
}
await Promise.race([active.waitPromise, delay(400)]);
}
return {
connected: false,
message: "Still waiting for QR scan confirmation.",
};
}
export async function logoutZaloProfile(profileInput?: string | null): Promise<{
cleared: boolean;
loggedOut: boolean;
message: string;
}> {
const profile = normalizeProfile(profileInput);
resetQrLogin(profile);
clearCachedGroupContext(profile);
const listener = activeListeners.get(profile);
if (listener) {
try {
listener.stop();
} catch {
// ignore
}
activeListeners.delete(profile);
}
invalidateApi(profile);
const cleared = clearCredentials(profile);
return {
cleared,
loggedOut: true,
message: cleared ? "Logged out and cleared local session." : "No local session to clear.",
};
}
export async function startZaloListener(params: {
accountId: string;
profile?: string | null;
abortSignal: AbortSignal;
onMessage: (message: ZaloInboundMessage) => void;
onError: (error: Error) => void;
}): Promise<{ stop: () => void }> {
const profile = normalizeProfile(params.profile);
const existing = activeListeners.get(profile);
if (existing) {
throw new Error(
`Zalo listener already running for profile \"${profile}\" (account \"${existing.accountId}\")`,
);
}
const api = await ensureApi(profile);
const ownUserId = await resolveOwnUserId(api);
let stopped = false;
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
let lastWatchdogTickAt = Date.now();
const cleanup = () => {
if (stopped) {
return;
}
stopped = true;
if (watchdogTimer) {
clearInterval(watchdogTimer);
watchdogTimer = null;
}
try {
api.listener.off("message", onMessage);
api.listener.off("error", onError);
api.listener.off("closed", onClosed);
} catch {
// ignore listener detachment errors
}
try {
api.listener.stop();
} catch {
// ignore
}
activeListeners.delete(profile);
};
const onMessage = (incoming: Message) => {
if (incoming.isSelf) {
return;
}
const normalized = toInboundMessage(incoming, ownUserId);
if (!normalized) {
return;
}
params.onMessage(normalized);
};
const failListener = (error: Error) => {
if (stopped || params.abortSignal.aborted) {
return;
}
cleanup();
invalidateApi(profile);
params.onError(error);
};
const onError = (error: unknown) => {
const wrapped = error instanceof Error ? error : new Error(String(error));
failListener(wrapped);
};
const onClosed = (code: number, reason: string) => {
failListener(new Error(`Zalo listener closed (${code}): ${reason || "no reason"}`));
};
api.listener.on("message", onMessage);
api.listener.on("error", onError);
api.listener.on("closed", onClosed);
try {
api.listener.start({ retryOnClose: false });
} catch (error) {
cleanup();
throw error;
}
watchdogTimer = setInterval(() => {
if (stopped || params.abortSignal.aborted) {
return;
}
const now = Date.now();
const gapMs = now - lastWatchdogTickAt;
lastWatchdogTickAt = now;
if (gapMs <= LISTENER_WATCHDOG_MAX_GAP_MS) {
return;
}
failListener(
new Error(
`Zalo listener watchdog gap detected (${Math.round(gapMs / 1000)}s): forcing reconnect`,
),
);
}, LISTENER_WATCHDOG_INTERVAL_MS);
watchdogTimer.unref?.();
params.abortSignal.addEventListener(
"abort",
() => {
cleanup();
},
{ once: true },
);
activeListeners.set(profile, {
profile,
accountId: params.accountId,
stop: cleanup,
});
return { stop: cleanup };
}
export async function resolveZaloGroupsByEntries(params: {
profile?: string | null;
entries: string[];
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
const groups = await listZaloGroups(params.profile);
const byName = new Map<string, ZaloGroup[]>();
for (const group of groups) {
const key = group.name.trim().toLowerCase();
if (!key) {
continue;
}
const list = byName.get(key) ?? [];
list.push(group);
byName.set(key, list);
}
return params.entries.map((input) => {
const trimmed = input.trim();
if (!trimmed) {
return { input, resolved: false };
}
if (/^\d+$/.test(trimmed)) {
return { input, resolved: true, id: trimmed };
}
const candidates = byName.get(trimmed.toLowerCase()) ?? [];
const match = candidates[0];
return match ? { input, resolved: true, id: match.groupId } : { input, resolved: false };
});
}
export async function resolveZaloAllowFromEntries(params: {
profile?: string | null;
entries: string[];
}): Promise<Array<{ input: string; resolved: boolean; id?: string; note?: string }>> {
const friends = await listZaloFriends(params.profile);
const byName = new Map<string, ZcaFriend[]>();
for (const friend of friends) {
const key = friend.displayName.trim().toLowerCase();
if (!key) {
continue;
}
const list = byName.get(key) ?? [];
list.push(friend);
byName.set(key, list);
}
return params.entries.map((input) => {
const trimmed = input.trim();
if (!trimmed) {
return { input, resolved: false };
}
if (/^\d+$/.test(trimmed)) {
return { input, resolved: true, id: trimmed };
}
const matches = byName.get(trimmed.toLowerCase()) ?? [];
const match = matches[0];
if (!match) {
return { input, resolved: false };
}
return {
input,
resolved: true,
id: match.userId,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
};
});
}
export async function clearProfileRuntimeArtifacts(profileInput?: string | null): Promise<void> {
const profile = normalizeProfile(profileInput);
resetQrLogin(profile);
clearCachedGroupContext(profile);
const listener = activeListeners.get(profile);
if (listener) {
listener.stop();
activeListeners.delete(profile);
}
invalidateApi(profile);
await fsp.mkdir(resolveCredentialsDir(), { recursive: true }).catch(() => undefined);
}