From cb8b3274884e26ef3f631a17003543f8ba850bbe Mon Sep 17 00:00:00 2001 From: darkamenosa Date: Tue, 28 Apr 2026 13:26:37 +0700 Subject: [PATCH] fix(zalouser): persist refreshed session cookies Persist refreshed `zca-js` session cookies after QR login, session restore, and successful API calls so gateway restarts restore the freshest local Zalo Personal session. - Adds stable credential cookie signatures so equivalent cookie-jar reorderings do not rewrite credentials. - Adds regression coverage for reordered live cookie jars preserving credential file content and mtime. - Updates CHANGELOG.md: (#73277) Thanks @darkamenosa. Co-authored-by: Tuyen Co-authored-by: Frank Yang --- CHANGELOG.md | 1 + .../zalouser/src/zalo-js.credentials.test.ts | 465 ++++++++++++ extensions/zalouser/src/zalo-js.ts | 671 ++++++++++++------ 3 files changed, 910 insertions(+), 227 deletions(-) create mode 100644 extensions/zalouser/src/zalo-js.credentials.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 62936e8f499..066a926591b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Memory/Dreaming: retry Dream Diary once with the session default when a configured dreaming model is unavailable, while leaving subagent trust and allowlist errors visible instead of silently masking configuration problems. Refs #67409 and #69209. Thanks @Ghiggins18 and @everySympathy. - Feishu/inbound files: recover CJK filenames from plain `Content-Disposition: filename=` download headers when Feishu exposes UTF-8 bytes through Latin-1 header decoding, while leaving valid Latin-1 and JSON-derived names unchanged. (#48578, #50435, #59431) Thanks @alex-xuweilong, @lishuaigit, and @DoChaoing. - Channels/Telegram: normalize accidental full `/bot` Telegram `apiRoot` values at runtime and teach `openclaw doctor --fix` to remove the suffix, so startup control calls no longer 404 when direct Bot API curl commands work. Fixes #55387. Thanks @brendanmatthewjones-cmyk, @techfindubai-ux, and @Sivlerback-Chris. +- Zalo Personal: persist refreshed `zca-js` session cookies after QR login, session restore, and successful API calls so gateway restarts restore the freshest local session. (#73277) Thanks @darkamenosa. ## 2026.4.27 diff --git a/extensions/zalouser/src/zalo-js.credentials.test.ts b/extensions/zalouser/src/zalo-js.credentials.test.ts new file mode 100644 index 00000000000..62a9f78608e --- /dev/null +++ b/extensions/zalouser/src/zalo-js.credentials.test.ts @@ -0,0 +1,465 @@ +import { lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { API, Credentials, LoginQRCallbackEvent } from "./zca-client.js"; +import { LoginQRCallbackEventType } from "./zca-constants.js"; + +const createZaloMock = vi.hoisted(() => vi.fn()); +const TEST_MTIME_TICK_MS = 20; + +vi.mock("./zca-client.js", () => ({ + createZalo: createZaloMock, + TextStyle: { Indent: 9 }, +})); + +import { + checkZaloAuthenticated, + listZaloFriends, + sendZaloLink, + sendZaloReaction, + startZaloQrLogin, + waitForZaloQrLogin, +} from "./zalo-js.js"; + +type StoredCredentialFile = { + imei: string; + cookie: Credentials["cookie"]; + userAgent: string; + language?: string; + createdAt?: string; + lastUsedAt?: string; +}; + +function credentialPath(stateDir: string, profile: string): string { + const trimmed = profile.trim().toLowerCase(); + const filename = + !trimmed || trimmed === "default" + ? "credentials.json" + : `credentials-${encodeURIComponent(trimmed)}.json`; + return path.join(stateDir, "credentials", "zalouser", filename); +} + +async function readStoredCredentials( + stateDir: string, + profile: string, +): Promise { + return JSON.parse( + await readFile(credentialPath(stateDir, profile), "utf8"), + ) as StoredCredentialFile; +} + +async function waitForMtimeTick(): Promise { + await new Promise((resolve) => setTimeout(resolve, TEST_MTIME_TICK_MS)); +} + +function createMockApi(params: { + imei: string; + userAgent: string; + language?: string; + cookies: unknown[] | (() => unknown[]); + getAllFriends?: API["getAllFriends"]; +}): API { + return { + getContext: () => ({ + imei: params.imei, + userAgent: params.userAgent, + language: params.language, + }), + getCookie: () => ({ + toJSON: () => ({ + cookies: typeof params.cookies === "function" ? params.cookies() : params.cookies, + }), + }), + fetchAccountInfo: async () => ({ + userId: "user-1", + username: "user-1", + displayName: "Zalo User", + zaloName: "Zalo User", + avatar: "", + }), + getAllFriends: params.getAllFriends ?? vi.fn(async () => []), + listener: { + on: vi.fn(), + off: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }, + } as unknown as API; +} + +describe("zalouser credential persistence", () => { + beforeEach(() => { + createZaloMock.mockReset(); + }); + + it("persists the final API cookie jar after QR login", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + const profile = "qr-refresh"; + const callbackCookie = [{ key: "zpsid", value: "callback", domain: "chat.zalo.me" }]; + const refreshedCookie = [{ key: "zpsid", value: "refreshed", domain: "chat.zalo.me" }]; + const api = createMockApi({ + imei: "api-imei", + userAgent: "api-user-agent", + language: "vi", + cookies: refreshedCookie, + }); + + createZaloMock.mockResolvedValueOnce({ + loginQR: async (_options: unknown, callback?: (event: LoginQRCallbackEvent) => unknown) => { + callback?.({ + type: LoginQRCallbackEventType.QRCodeGenerated, + data: { + code: "qr-code", + image: "data:image/png;base64,abc123", + }, + actions: { + saveToFile: vi.fn(async () => undefined), + retry: vi.fn(), + abort: vi.fn(), + }, + }); + callback?.({ + type: LoginQRCallbackEventType.GotLoginInfo, + data: { + cookie: callbackCookie, + imei: "callback-imei", + userAgent: "callback-user-agent", + }, + actions: null, + }); + return api; + }, + }); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + await startZaloQrLogin({ profile, timeoutMs: 1000 }); + + await expect(waitForZaloQrLogin({ profile, timeoutMs: 1000 })).resolves.toMatchObject({ + connected: true, + }); + + const stored = await readStoredCredentials(stateDir, profile); + expect(stored).toMatchObject({ + imei: "api-imei", + userAgent: "api-user-agent", + language: "vi", + }); + expect(stored.cookie).toEqual(refreshedCookie); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("rewrites restored sessions with cookies refreshed by zca-js login", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + const profile = "restore-refresh"; + const storedCookie = [{ key: "zpsid", value: "stored", domain: "chat.zalo.me" }]; + const refreshedCookie = [{ key: "zpsid", value: "refreshed", domain: "chat.zalo.me" }]; + const filePath = credentialPath(stateDir, profile); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile( + filePath, + JSON.stringify( + { + imei: "stored-imei", + cookie: storedCookie, + userAgent: "stored-user-agent", + createdAt: "2026-04-01T00:00:00.000Z", + }, + null, + 2, + ), + ); + + const api = createMockApi({ + imei: "stored-imei", + userAgent: "stored-user-agent", + language: "vi", + cookies: refreshedCookie, + }); + const login = vi.fn(async () => api); + createZaloMock.mockResolvedValueOnce({ login }); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + await expect(checkZaloAuthenticated(profile)).resolves.toBe(true); + + expect(login).toHaveBeenCalledWith({ + imei: "stored-imei", + cookie: storedCookie, + userAgent: "stored-user-agent", + language: undefined, + }); + const stored = await readStoredCredentials(stateDir, profile); + expect(stored.cookie).toEqual(refreshedCookie); + expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z"); + expect(stored.lastUsedAt).toEqual(expect.any(String)); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("persists cookie changes after a successful API call", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + const profile = "api-refresh"; + const storedCookie: unknown[] = [{ key: "zpsid", value: "stored", domain: "chat.zalo.me" }]; + const loginCookie: unknown[] = [{ key: "zpsid", value: "login", domain: "chat.zalo.me" }]; + const refreshedCookie: unknown[] = [ + { key: "zpsid", value: "api-refreshed", domain: "chat.zalo.me" }, + ]; + const filePath = credentialPath(stateDir, profile); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile( + filePath, + JSON.stringify( + { + imei: "stored-imei", + cookie: storedCookie, + userAgent: "stored-user-agent", + createdAt: "2026-04-01T00:00:00.000Z", + }, + null, + 2, + ), + ); + + let currentCookie = loginCookie; + const api = createMockApi({ + imei: "stored-imei", + userAgent: "stored-user-agent", + language: "vi", + cookies: () => currentCookie, + getAllFriends: vi.fn(async () => { + currentCookie = refreshedCookie; + return [ + { + userId: "friend-1", + username: "friend-1", + displayName: "Friend One", + zaloName: "Friend One", + avatar: "", + }, + ]; + }), + }); + createZaloMock.mockResolvedValueOnce({ login: vi.fn(async () => api) }); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + await expect(listZaloFriends(profile)).resolves.toEqual([ + { + userId: "friend-1", + displayName: "Friend One", + avatar: undefined, + }, + ]); + + const stored = await readStoredCredentials(stateDir, profile); + expect(stored.cookie).toEqual(refreshedCookie); + expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z"); + expect(stored.lastUsedAt).toEqual(expect.any(String)); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("does not rewrite credentials when the live cookie jar only reorders cookies", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + const profile = "api-stable"; + const cookieA: unknown[] = [ + { key: "zpsid", value: "same", domain: "chat.zalo.me" }, + { key: "zpw", value: "same-secondary", domain: "chat.zalo.me" }, + ]; + const cookieB = [...cookieA].toReversed(); + const filePath = credentialPath(stateDir, profile); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile( + filePath, + JSON.stringify( + { + imei: "stored-imei", + cookie: cookieA, + userAgent: "stored-user-agent", + createdAt: "2026-04-01T00:00:00.000Z", + }, + null, + 2, + ), + ); + + let currentCookie = cookieA; + const api = createMockApi({ + imei: "stored-imei", + userAgent: "stored-user-agent", + language: "vi", + cookies: () => currentCookie, + getAllFriends: vi.fn(async () => []), + }); + createZaloMock.mockResolvedValueOnce({ login: vi.fn(async () => api) }); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + await expect(listZaloFriends(profile)).resolves.toEqual([]); + const firstRaw = await readFile(filePath, "utf8"); + const firstMtimeMs = (await stat(filePath)).mtimeMs; + + currentCookie = cookieB; + await waitForMtimeTick(); + + await expect(listZaloFriends(profile)).resolves.toEqual([]); + expect(await readFile(filePath, "utf8")).toBe(firstRaw); + expect((await stat(filePath)).mtimeMs).toBe(firstMtimeMs); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("keeps reaction sends non-throwing when session restore fails", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + await expect( + sendZaloReaction({ + profile: "missing-session", + threadId: "thread-1", + msgId: "msg-1", + cliMsgId: "cli-1", + emoji: "like", + }), + ).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining("No saved Zalo session"), + }); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it("keeps link sends non-throwing when session restore fails", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + await expect( + sendZaloLink("thread-1", "https://example.com", { + profile: "missing-session", + }), + ).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining("No saved Zalo session"), + }); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")( + "writes credentials with private permissions", + async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + const profile = "private-mode"; + const api = createMockApi({ + imei: "api-imei", + userAgent: "api-user-agent", + cookies: [{ key: "zpsid", value: "private", domain: "chat.zalo.me" }], + }); + + createZaloMock.mockResolvedValueOnce({ + loginQR: async (_options: unknown, callback?: (event: LoginQRCallbackEvent) => unknown) => { + callback?.({ + type: LoginQRCallbackEventType.QRCodeGenerated, + data: { + code: "qr-code", + image: "data:image/png;base64,abc123", + }, + actions: { + saveToFile: vi.fn(async () => undefined), + retry: vi.fn(), + abort: vi.fn(), + }, + }); + return api; + }, + }); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + await startZaloQrLogin({ profile, timeoutMs: 1000 }); + await expect(waitForZaloQrLogin({ profile, timeoutMs: 1000 })).resolves.toMatchObject({ + connected: true, + }); + + const filePath = credentialPath(stateDir, profile); + const dirMode = (await stat(path.dirname(filePath))).mode & 0o777; + const fileMode = (await stat(filePath)).mode & 0o777; + expect(dirMode).toBe(0o700); + expect(fileMode).toBe(0o600); + }); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === "win32")( + "refuses to write credentials through a symlinked file", + async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-")); + const profile = "symlink-target"; + const filePath = credentialPath(stateDir, profile); + const targetPath = path.join(stateDir, "outside.json"); + const api = createMockApi({ + imei: "api-imei", + userAgent: "api-user-agent", + cookies: [{ key: "zpsid", value: "symlink", domain: "chat.zalo.me" }], + }); + + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(targetPath, "sentinel", "utf8"); + await symlink(targetPath, filePath); + + createZaloMock.mockResolvedValueOnce({ + loginQR: async (_options: unknown, callback?: (event: LoginQRCallbackEvent) => unknown) => { + callback?.({ + type: LoginQRCallbackEventType.QRCodeGenerated, + data: { + code: "qr-code", + image: "data:image/png;base64,abc123", + }, + actions: { + saveToFile: vi.fn(async () => undefined), + retry: vi.fn(), + abort: vi.fn(), + }, + }); + return api; + }, + }); + + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + const started = await startZaloQrLogin({ profile, timeoutMs: 1000 }); + const waited = await waitForZaloQrLogin({ profile, timeoutMs: 1000 }); + expect(`${started.message} ${waited.message}`).toContain( + "Refusing to write Zalo credentials to symlinked path", + ); + }); + + expect(await readFile(targetPath, "utf8")).toBe("sentinel"); + expect((await lstat(filePath)).isSymbolicLink()).toBe(true); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index de8f58dabeb..6f2845998b7 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -47,6 +47,7 @@ const LISTENER_WATCHDOG_MAX_GAP_MS = 35_000; const apiByProfile = new Map(); const apiInitByProfile = new Map>(); +const credentialSignaturesByProfile = new Map(); type ActiveZaloQrLogin = { id: string; @@ -108,6 +109,82 @@ function resolveCredentialsPath(profile: string, env: NodeJS.ProcessEnv = proces return path.join(resolveCredentialsDir(env), credentialsFilename(profile)); } +function isNodeErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + +function ensureCredentialsDir(): string { + const dir = resolveCredentialsDir(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error("Refusing to use non-directory Zalo credentials path"); + } + try { + fs.chmodSync(dir, 0o700); + } catch { + // Best-effort on platforms that support POSIX permissions. + } + return dir; +} + +function isReadableCredentialFile(filePath: string): boolean { + try { + const stat = fs.lstatSync(filePath); + return stat.isFile() && !stat.isSymbolicLink(); + } catch (error) { + if (isNodeErrorCode(error, "ENOENT")) { + return false; + } + throw error; + } +} + +function assertWritableCredentialTarget(filePath: string): void { + try { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error("Refusing to write Zalo credentials to symlinked path"); + } + } catch (error) { + if (isNodeErrorCode(error, "ENOENT")) { + return; + } + throw error; + } +} + +function writeCredentialFileAtomic(filePath: string, payload: string): void { + const dir = ensureCredentialsDir(); + assertWritableCredentialTarget(filePath); + const tempPath = path.join(dir, `.${path.basename(filePath)}.tmp-${process.pid}-${randomUUID()}`); + try { + fs.writeFileSync(tempPath, payload, { encoding: "utf-8", mode: 0o600, flag: "wx" }); + try { + fs.chmodSync(tempPath, 0o600); + } catch { + // Best-effort on platforms that support POSIX permissions. + } + fs.renameSync(tempPath, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // Best-effort on platforms that support POSIX permissions. + } + } finally { + try { + fs.unlinkSync(tempPath); + } catch { + // The temp file is normally moved by renameSync. + } + } +} + function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -540,7 +617,7 @@ function mapGroup(groupId: string, group: GroupInfo & Record): function readCredentials(profile: string): StoredZaloCredentials | null { const filePath = resolveCredentialsPath(profile); try { - if (!fs.existsSync(filePath)) { + if (!isReadableCredentialFile(filePath)) { return null; } const raw = fs.readFileSync(filePath, "utf-8"); @@ -554,7 +631,7 @@ function readCredentials(profile: string): StoredZaloCredentials | null { ) { return null; } - return { + const credentials = { imei: parsed.imei, cookie: parsed.cookie as Credentials["cookie"], userAgent: parsed.userAgent, @@ -562,31 +639,73 @@ function readCredentials(profile: string): StoredZaloCredentials | null { createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(), lastUsedAt: typeof parsed.lastUsedAt === "string" ? parsed.lastUsedAt : undefined, }; + credentialSignaturesByProfile.set(profile, credentialSignature(credentials)); + return credentials; } catch { return null; } } -function touchCredentials(profile: string): void { - const existing = readCredentials(profile); - if (!existing) { - return; +function credentialSignature( + credentials: Omit, +): string { + return JSON.stringify({ + imei: credentials.imei, + cookie: canonicalCredentialCookie(credentials.cookie), + userAgent: credentials.userAgent, + language: credentials.language, + }); +} + +function stableCanonicalValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stableCanonicalValue); } - 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"); + if (!value || typeof value !== "object") { + return value; + } + return Object.fromEntries( + Object.entries(value as Record) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, stableCanonicalValue(entry)]), + ); +} + +function stableSignatureValue(value: unknown): string { + return JSON.stringify(stableCanonicalValue(value)) ?? "undefined"; +} + +function canonicalCookieArray(value: unknown[]): unknown[] { + return value + .map(stableCanonicalValue) + .toSorted((left, right) => + stableSignatureValue(left).localeCompare(stableSignatureValue(right)), + ); +} + +function canonicalCredentialCookie(cookie: Credentials["cookie"]): unknown { + if (Array.isArray(cookie)) { + return canonicalCookieArray(cookie); + } + if (!cookie || typeof cookie !== "object") { + return cookie; + } + return Object.fromEntries( + Object.entries(cookie as Record) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [ + key, + key === "cookies" && Array.isArray(entry) + ? canonicalCookieArray(entry) + : stableCanonicalValue(entry), + ]), + ); } function writeCredentials( profile: string, credentials: Omit, ): void { - const dir = resolveCredentialsDir(); - fs.mkdirSync(dir, { recursive: true }); const existing = readCredentials(profile); const now = new Date().toISOString(); const next: StoredZaloCredentials = { @@ -594,7 +713,59 @@ function writeCredentials( createdAt: existing?.createdAt ?? now, lastUsedAt: now, }; - fs.writeFileSync(resolveCredentialsPath(profile), JSON.stringify(next, null, 2), "utf-8"); + writeCredentialFileAtomic(resolveCredentialsPath(profile), JSON.stringify(next, null, 2)); + credentialSignaturesByProfile.set(profile, credentialSignature(next)); +} + +function snapshotApiCredentials( + api: API, + fallback?: Partial>, +): Omit { + const ctx = api.getContext(); + const cookieJson = api.getCookie().toJSON(); + const refreshedCookies = + Array.isArray(cookieJson?.cookies) && cookieJson.cookies.length > 0 + ? cookieJson.cookies + : fallback?.cookie; + const imei = normalizeOptionalString(ctx.imei) ?? normalizeOptionalString(fallback?.imei); + const userAgent = + normalizeOptionalString(ctx.userAgent) ?? normalizeOptionalString(fallback?.userAgent); + if (!imei || !refreshedCookies || !userAgent) { + throw new Error("Zalo API session did not expose refreshed credentials"); + } + return { + imei, + cookie: refreshedCookies as Credentials["cookie"], + userAgent, + language: normalizeOptionalString(ctx.language) ?? normalizeOptionalString(fallback?.language), + }; +} + +function writeApiCredentials( + profile: string, + api: API, + fallback?: Partial>, +): void { + writeCredentials(profile, snapshotApiCredentials(api, fallback)); +} + +function writeApiCredentialsIfChanged(profile: string, api: API): boolean { + const credentials = snapshotApiCredentials(api); + const signature = credentialSignature(credentials); + if (credentialSignaturesByProfile.get(profile) === signature) { + return false; + } + writeCredentials(profile, credentials); + return true; +} + +function persistApiCredentialsIfChanged(profile: string, api: API): void { + try { + writeApiCredentialsIfChanged(profile, api); + } catch { + // Do not fail an already-successful Zalo operation only because the + // best-effort session refresh could not be persisted. + } } function clearCredentials(profile: string): boolean { @@ -602,6 +773,7 @@ function clearCredentials(profile: string): boolean { try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); + credentialSignaturesByProfile.delete(profile); return true; } } catch { @@ -645,7 +817,7 @@ async function ensureApi( `Timed out restoring Zalo session for profile "${profile}"`, ); apiByProfile.set(profile, api); - touchCredentials(profile); + writeApiCredentials(profile, api, stored); return api; })(); @@ -660,6 +832,23 @@ async function ensureApi( } } +async function withZaloApi( + profileInput: string | null | undefined, + operation: (api: API) => Promise, + options: { + timeoutMs?: number; + shouldPersist?: (result: T) => boolean; + } = {}, +): Promise { + const profile = normalizeProfile(profileInput); + const api = await ensureApi(profile, options.timeoutMs); + const result = await operation(api); + if (options.shouldPersist?.(result) ?? true) { + persistApiCredentialsIfChanged(profile, api); + } + return result; +} + function invalidateApi(profileInput?: string | null): void { const profile = normalizeProfile(profileInput); const api = apiByProfile.get(profile); @@ -848,8 +1037,12 @@ export async function checkZaloAuthenticated(profileInput?: string | null): Prom return false; } try { - const api = await ensureApi(profile, 12_000); - await withTimeout(api.fetchAccountInfo(), 12_000, "Timed out checking Zalo session"); + await withZaloApi( + profile, + async (api) => + await withTimeout(api.fetchAccountInfo(), 12_000, "Timed out checking Zalo session"), + { timeoutMs: 12_000 }, + ); return true; } catch { invalidateApi(profile); @@ -859,24 +1052,26 @@ export async function checkZaloAuthenticated(profileInput?: string | null): Prom export async function getZaloUserInfo(profileInput?: string | null): Promise { 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: user.userId, - displayName: user.displayName || user.zaloName || user.userId, - avatar: user.avatar || undefined, - }; + return await withZaloApi(profile, async (api) => { + const info = await api.fetchAccountInfo(); + const user = normalizeAccountInfoUser(info); + if (!user?.userId) { + return null; + } + return { + userId: user.userId, + displayName: user.displayName || user.zaloName || user.userId, + avatar: user.avatar || undefined, + }; + }); } export async function listZaloFriends(profileInput?: string | null): Promise { const profile = normalizeProfile(profileInput); - const api = await ensureApi(profile); - const friends = await api.getAllFriends(); - return friends.map(mapFriend); + return await withZaloApi(profile, async (api) => { + const friends = await api.getAllFriends(); + return friends.map(mapFriend); + }); } export async function listZaloFriendsMatching( @@ -903,23 +1098,24 @@ export async function listZaloFriendsMatching( export async function listZaloGroups(profileInput?: string | null): Promise { 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; + return await withZaloApi(profile, async (api) => { + const allGroups = await api.getAllGroups(); + const ids = Object.keys(allGroups.gridVerMap ?? {}); + if (ids.length === 0) { + return []; } - rows.push(mapGroup(id, info as GroupInfo & Record)); - } - return rows; + 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)); + } + return rows; + }); } export async function listZaloGroupsMatching( @@ -943,72 +1139,72 @@ export async function listZaloGroupMembers( groupId: string, ): Promise { 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(); - for (const member of currentMembers) { - const id = toNumberId(member?.id); - if (!id) { - continue; + return await withZaloApi(profile, async (api) => { + const infoResponse = await api.getGroupInfo(groupId); + const groupInfo = infoResponse.gridInfoMap?.[groupId] as + | (GroupInfo & { memVerList?: unknown }) + | undefined; + if (!groupInfo) { + return []; } - currentById.set(id, { - displayName: - normalizeOptionalString(member.dName) ?? normalizeOptionalString(member.zaloName), - avatar: member.avatar || undefined, - }); - } - const uniqueIds = Array.from( - new Set([...memberIds, ...memVerIds, ...currentById.keys()]), - ); + 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 profileMap = new Map(); - 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) { + const currentById = new Map(); + for (const member of currentMembers) { + const id = toNumberId(member?.id); + if (!id) { continue; } - profileMap.set(id, { + currentById.set(id, { displayName: - normalizeOptionalString(profileValue.displayName) ?? - normalizeOptionalString(profileValue.zaloName), - avatar: profileValue.avatar || undefined, + normalizeOptionalString(member.dName) ?? normalizeOptionalString(member.zaloName), + avatar: member.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, - })); + const uniqueIds = Array.from( + new Set([...memberIds, ...memVerIds, ...currentById.keys()]), + ); + + const profileMap = new Map(); + 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: + normalizeOptionalString(profileValue.displayName) ?? + normalizeOptionalString(profileValue.zaloName), + 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( @@ -1025,18 +1221,19 @@ export async function resolveZaloGroupContext( 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: normalizeOptionalString(groupInfo?.name), - members: extractGroupMembersFromInfo(groupInfo), - }; - writeCachedGroupContext(profile, context); - return context; + return await withZaloApi(profile, async (api) => { + const response = await api.getGroupInfo(normalizedGroupId); + const groupInfo = response.gridInfoMap?.[normalizedGroupId] as + | (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] }) + | undefined; + const context: ZaloGroupContext = { + groupId: normalizedGroupId, + name: normalizeOptionalString(groupInfo?.name), + members: extractGroupMembersFromInfo(groupInfo), + }; + writeCachedGroupContext(profile, context); + return context; + }); } export async function sendZaloTextMessage( @@ -1050,92 +1247,97 @@ export async function sendZaloTextMessage( return { ok: false, error: "No threadId provided" }; } - const api = await ensureApi(profile); - const type = options.isGroup ? ThreadType.Group : ThreadType.User; + return await withZaloApi( + profile, + async (api) => { + const type = options.isGroup ? ThreadType.Group : ThreadType.User; - try { - if (options.mediaUrl?.trim()) { - const media = await loadOutboundMediaFromUrl(options.mediaUrl.trim(), { - mediaLocalRoots: options.mediaLocalRoots, - mediaReadFile: options.mediaReadFile, - }); - 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); + try { + if (options.mediaUrl?.trim()) { + const media = await loadOutboundMediaFromUrl(options.mediaUrl.trim(), { + mediaLocalRoots: options.mediaLocalRoots, + mediaReadFile: options.mediaReadFile, + }); + 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, + 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, ); - textMessageId = extractSendMessageId(textResponse); + return { ok: true, messageId: extractSendMessageId(response) }; } - 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, - }, - }, - ], + 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, ); - 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, - }; + return { ok: true, messageId: extractSendMessageId(response) }; + } catch (error) { + return { ok: false, error: toErrorMessage(error) }; } - - 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) }; - } + }, + { shouldPersist: (result) => result.ok }, + ); } export async function sendZaloTypingEvent( @@ -1147,13 +1349,14 @@ export async function sendZaloTypingEvent( 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"); + await withZaloApi(profile, async (api) => { + 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 { @@ -1196,17 +1399,22 @@ export async function sendZaloReaction(params: { 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 }; + return await withZaloApi( + profile, + async (api) => { + 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 }; + }, + { shouldPersist: (result) => result.ok }, + ); } catch (error) { return { ok: false, error: toErrorMessage(error) }; } @@ -1219,9 +1427,10 @@ export async function sendZaloDeliveredEvent(params: { isSeen?: boolean; }): Promise { 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); + await withZaloApi(profile, async (api) => { + const type = params.isGroup ? ThreadType.Group : ThreadType.User; + await api.sendDeliveredEvent(params.isSeen === true, params.message, type); + }); } export async function sendZaloSeenEvent(params: { @@ -1230,9 +1439,10 @@ export async function sendZaloSeenEvent(params: { message: ZaloEventMessage; }): Promise { const profile = normalizeProfile(params.profile); - const api = await ensureApi(profile); - const type = params.isGroup ? ThreadType.Group : ThreadType.User; - await api.sendSeenEvent(params.message, type); + await withZaloApi(profile, async (api) => { + const type = params.isGroup ? ThreadType.Group : ThreadType.User; + await api.sendSeenEvent(params.message, type); + }); } export async function sendZaloLink( @@ -1251,14 +1461,19 @@ export async function sendZaloLink( } 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 await withZaloApi( + profile, + async (api) => { + 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) }; + }, + { shouldPersist: (result) => result.ok }, ); - return { ok: true, messageId: String(response.msgId) }; } catch (error) { return { ok: false, error: toErrorMessage(error) }; } @@ -1375,7 +1590,7 @@ export async function startZaloQrLogin(params: { }; } - writeCredentials(profile, capturedCredentials); + writeApiCredentials(profile, api, capturedCredentials ?? undefined); invalidateApi(profile); apiByProfile.set(profile, api); current.connected = true; @@ -1521,8 +1736,10 @@ export async function startZaloListener(params: { ); } - const api = await ensureApi(profile); - const ownUserId = await resolveOwnUserId(api); + const { api, ownUserId } = await withZaloApi(profile, async (api) => ({ + api, + ownUserId: await resolveOwnUserId(api), + })); let stopped = false; let watchdogTimer: ReturnType | null = null; let lastWatchdogTickAt = Date.now();