mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:00:42 +00:00
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 <hxtxmu@gmail.com> Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
@@ -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<TOKEN>` 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
|
||||
|
||||
|
||||
465
extensions/zalouser/src/zalo-js.credentials.test.ts
Normal file
465
extensions/zalouser/src/zalo-js.credentials.test.ts
Normal file
@@ -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<StoredCredentialFile> {
|
||||
return JSON.parse(
|
||||
await readFile(credentialPath(stateDir, profile), "utf8"),
|
||||
) as StoredCredentialFile;
|
||||
}
|
||||
|
||||
async function waitForMtimeTick(): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -47,6 +47,7 @@ const LISTENER_WATCHDOG_MAX_GAP_MS = 35_000;
|
||||
|
||||
const apiByProfile = new Map<string, API>();
|
||||
const apiInitByProfile = new Map<string, Promise<API>>();
|
||||
const credentialSignaturesByProfile = new Map<string, string>();
|
||||
|
||||
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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -540,7 +617,7 @@ function mapGroup(groupId: string, group: GroupInfo & Record<string, unknown>):
|
||||
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<StoredZaloCredentials, "createdAt" | "lastUsedAt">,
|
||||
): 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<string, unknown>)
|
||||
.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<string, unknown>)
|
||||
.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<StoredZaloCredentials, "createdAt" | "lastUsedAt">,
|
||||
): 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<StoredZaloCredentials, "createdAt" | "lastUsedAt">>,
|
||||
): Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt"> {
|
||||
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<Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt">>,
|
||||
): 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<T>(
|
||||
profileInput: string | null | undefined,
|
||||
operation: (api: API) => Promise<T>,
|
||||
options: {
|
||||
timeoutMs?: number;
|
||||
shouldPersist?: (result: T) => boolean;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
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<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: 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<ZcaFriend[]> {
|
||||
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<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;
|
||||
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<string, unknown>));
|
||||
}
|
||||
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<string, unknown>));
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
}
|
||||
|
||||
export async function listZaloGroupsMatching(
|
||||
@@ -943,72 +1139,72 @@ export async function listZaloGroupMembers(
|
||||
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;
|
||||
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<string>([...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<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) {
|
||||
const currentById = new Map<string, { displayName?: string; avatar?: string }>();
|
||||
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<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:
|
||||
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<string> {
|
||||
@@ -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<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);
|
||||
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<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);
|
||||
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<typeof setInterval> | null = null;
|
||||
let lastWatchdogTickAt = Date.now();
|
||||
|
||||
Reference in New Issue
Block a user