mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 13:00:48 +00:00
* 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
1695 lines
48 KiB
TypeScript
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);
|
|
}
|