@openclaw/zalouser 2026.3.13 → 2026.5.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/api.ts +9 -0
- package/channel-plugin-api.ts +3 -0
- package/contract-api.ts +2 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +29 -24
- package/openclaw.plugin.json +288 -1
- package/package.json +38 -11
- package/runtime-api.ts +67 -0
- package/secret-contract-api.ts +4 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +2 -0
- package/src/accounts.runtime.ts +1 -0
- package/src/accounts.test-mocks.ts +7 -3
- package/src/accounts.test.ts +53 -1
- package/src/accounts.ts +38 -24
- package/src/channel-api.ts +20 -0
- package/src/channel.adapters.ts +390 -0
- package/src/channel.directory.test.ts +47 -40
- package/src/channel.runtime.ts +12 -0
- package/src/channel.sendpayload.test.ts +41 -23
- package/src/channel.setup.test.ts +33 -0
- package/src/channel.setup.ts +12 -0
- package/src/channel.test.ts +231 -20
- package/src/channel.ts +176 -685
- package/src/config-schema.ts +5 -5
- package/src/directory.ts +54 -0
- package/src/doctor-contract.ts +156 -0
- package/src/doctor.test.ts +77 -0
- package/src/doctor.ts +37 -0
- package/src/group-policy.test.ts +4 -4
- package/src/group-policy.ts +4 -2
- package/src/monitor.account-scope.test.ts +2 -1
- package/src/monitor.group-gating.test.ts +162 -8
- package/src/monitor.ts +233 -173
- package/src/probe.ts +3 -2
- package/src/qr-temp-file.ts +1 -1
- package/src/reaction.ts +5 -2
- package/src/runtime.ts +6 -3
- package/src/security-audit.test.ts +80 -0
- package/src/security-audit.ts +71 -0
- package/src/send.test.ts +2 -2
- package/src/send.ts +3 -3
- package/src/session-route.ts +121 -0
- package/src/setup-core.ts +33 -0
- package/src/setup-surface.test.ts +363 -0
- package/src/setup-surface.ts +470 -0
- package/src/setup-test-helpers.ts +42 -0
- package/src/shared.ts +92 -0
- package/src/status-issues.test.ts +1 -13
- package/src/status-issues.ts +8 -2
- package/src/test-helpers.ts +1 -1
- package/src/text-styles.test.ts +1 -1
- package/src/text-styles.ts +5 -2
- package/src/tool.test.ts +66 -3
- package/src/tool.ts +76 -14
- package/src/types.ts +3 -3
- package/src/zalo-js.credentials.test.ts +465 -0
- package/src/zalo-js.test-mocks.ts +89 -0
- package/src/zalo-js.ts +491 -274
- package/src/zca-client.test.ts +24 -0
- package/src/zca-client.ts +24 -58
- package/src/zca-constants.ts +55 -0
- package/test-api.ts +21 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -107
- package/src/onboarding.ts +0 -340
package/src/zalo-js.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import fsp from "node:fs/promises";
|
|
4
3
|
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
|
-
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/
|
|
5
|
+
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
|
6
|
+
import { resolveStateDir as resolvePluginStateDir } from "openclaw/plugin-sdk/state-paths";
|
|
7
|
+
import {
|
|
8
|
+
normalizeLowercaseStringOrEmpty,
|
|
9
|
+
normalizeOptionalLowercaseString,
|
|
10
|
+
normalizeOptionalString,
|
|
11
|
+
} from "openclaw/plugin-sdk/text-runtime";
|
|
7
12
|
import { normalizeZaloReactionIcon } from "./reaction.js";
|
|
8
|
-
import { getZalouserRuntime } from "./runtime.js";
|
|
9
13
|
import type {
|
|
10
14
|
ZaloAuthStatus,
|
|
11
15
|
ZaloEventMessage,
|
|
@@ -19,17 +23,16 @@ import type {
|
|
|
19
23
|
ZcaUserInfo,
|
|
20
24
|
} from "./types.js";
|
|
21
25
|
import {
|
|
22
|
-
LoginQRCallbackEventType,
|
|
23
26
|
TextStyle,
|
|
24
|
-
ThreadType,
|
|
25
|
-
Zalo,
|
|
26
27
|
type API,
|
|
27
28
|
type Credentials,
|
|
28
29
|
type GroupInfo,
|
|
29
30
|
type LoginQRCallbackEvent,
|
|
30
31
|
type Message,
|
|
31
32
|
type User,
|
|
33
|
+
createZalo,
|
|
32
34
|
} from "./zca-client.js";
|
|
35
|
+
import { LoginQRCallbackEventType, ThreadType } from "./zca-constants.js";
|
|
33
36
|
|
|
34
37
|
const API_LOGIN_TIMEOUT_MS = 20_000;
|
|
35
38
|
const QR_LOGIN_TTL_MS = 3 * 60_000;
|
|
@@ -43,6 +46,7 @@ const LISTENER_WATCHDOG_MAX_GAP_MS = 35_000;
|
|
|
43
46
|
|
|
44
47
|
const apiByProfile = new Map<string, API>();
|
|
45
48
|
const apiInitByProfile = new Map<string, Promise<API>>();
|
|
49
|
+
const credentialSignaturesByProfile = new Map<string, string>();
|
|
46
50
|
|
|
47
51
|
type ActiveZaloQrLogin = {
|
|
48
52
|
id: string;
|
|
@@ -85,7 +89,7 @@ type StoredZaloCredentials = {
|
|
|
85
89
|
};
|
|
86
90
|
|
|
87
91
|
function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
88
|
-
return
|
|
92
|
+
return resolvePluginStateDir(env, os.homedir);
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -93,7 +97,7 @@ function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
function credentialsFilename(profile: string): string {
|
|
96
|
-
const trimmed = profile
|
|
100
|
+
const trimmed = normalizeLowercaseStringOrEmpty(profile);
|
|
97
101
|
if (!trimmed || trimmed === "default") {
|
|
98
102
|
return "credentials.json";
|
|
99
103
|
}
|
|
@@ -104,6 +108,82 @@ function resolveCredentialsPath(profile: string, env: NodeJS.ProcessEnv = proces
|
|
|
104
108
|
return path.join(resolveCredentialsDir(env), credentialsFilename(profile));
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
function isNodeErrorCode(error: unknown, code: string): boolean {
|
|
112
|
+
return (
|
|
113
|
+
typeof error === "object" &&
|
|
114
|
+
error !== null &&
|
|
115
|
+
"code" in error &&
|
|
116
|
+
(error as { code?: unknown }).code === code
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ensureCredentialsDir(): string {
|
|
121
|
+
const dir = resolveCredentialsDir();
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
123
|
+
const stat = fs.lstatSync(dir);
|
|
124
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
125
|
+
throw new Error("Refusing to use non-directory Zalo credentials path");
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
fs.chmodSync(dir, 0o700);
|
|
129
|
+
} catch {
|
|
130
|
+
// Best-effort on platforms that support POSIX permissions.
|
|
131
|
+
}
|
|
132
|
+
return dir;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isReadableCredentialFile(filePath: string): boolean {
|
|
136
|
+
try {
|
|
137
|
+
const stat = fs.lstatSync(filePath);
|
|
138
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (isNodeErrorCode(error, "ENOENT")) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertWritableCredentialTarget(filePath: string): void {
|
|
148
|
+
try {
|
|
149
|
+
const stat = fs.lstatSync(filePath);
|
|
150
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
151
|
+
throw new Error("Refusing to write Zalo credentials to symlinked path");
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (isNodeErrorCode(error, "ENOENT")) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function writeCredentialFileAtomic(filePath: string, payload: string): void {
|
|
162
|
+
const dir = ensureCredentialsDir();
|
|
163
|
+
assertWritableCredentialTarget(filePath);
|
|
164
|
+
const tempPath = path.join(dir, `.${path.basename(filePath)}.tmp-${process.pid}-${randomUUID()}`);
|
|
165
|
+
try {
|
|
166
|
+
fs.writeFileSync(tempPath, payload, { encoding: "utf-8", mode: 0o600, flag: "wx" });
|
|
167
|
+
try {
|
|
168
|
+
fs.chmodSync(tempPath, 0o600);
|
|
169
|
+
} catch {
|
|
170
|
+
// Best-effort on platforms that support POSIX permissions.
|
|
171
|
+
}
|
|
172
|
+
fs.renameSync(tempPath, filePath);
|
|
173
|
+
try {
|
|
174
|
+
fs.chmodSync(filePath, 0o600);
|
|
175
|
+
} catch {
|
|
176
|
+
// Best-effort on platforms that support POSIX permissions.
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
try {
|
|
180
|
+
fs.unlinkSync(tempPath);
|
|
181
|
+
} catch {
|
|
182
|
+
// The temp file is normally moved by renameSync.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
107
187
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
108
188
|
return new Promise((resolve, reject) => {
|
|
109
189
|
const timer = setTimeout(() => {
|
|
@@ -204,14 +284,17 @@ function normalizeAccountInfoUser(info: AccountInfoResponse): User | null {
|
|
|
204
284
|
}
|
|
205
285
|
return null;
|
|
206
286
|
}
|
|
207
|
-
return info
|
|
287
|
+
return info;
|
|
208
288
|
}
|
|
209
289
|
|
|
210
290
|
function toInteger(value: unknown, fallback = 0): number {
|
|
211
291
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
212
292
|
return Math.trunc(value);
|
|
213
293
|
}
|
|
214
|
-
const parsed = Number.parseInt(
|
|
294
|
+
const parsed = Number.parseInt(
|
|
295
|
+
typeof value === "string" ? value : typeof value === "number" ? String(value) : "",
|
|
296
|
+
10,
|
|
297
|
+
);
|
|
215
298
|
if (!Number.isFinite(parsed)) {
|
|
216
299
|
return fallback;
|
|
217
300
|
}
|
|
@@ -244,7 +327,10 @@ function resolveInboundTimestamp(rawTs: unknown): number {
|
|
|
244
327
|
if (typeof rawTs === "number" && Number.isFinite(rawTs)) {
|
|
245
328
|
return rawTs > 1_000_000_000_000 ? rawTs : rawTs * 1000;
|
|
246
329
|
}
|
|
247
|
-
const parsed = Number.parseInt(
|
|
330
|
+
const parsed = Number.parseInt(
|
|
331
|
+
typeof rawTs === "string" ? rawTs : typeof rawTs === "number" ? String(rawTs) : "",
|
|
332
|
+
10,
|
|
333
|
+
);
|
|
248
334
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
249
335
|
return Date.now();
|
|
250
336
|
}
|
|
@@ -489,13 +575,13 @@ function resolveUploadedVoiceAsset(
|
|
|
489
575
|
if (!item || typeof item !== "object") {
|
|
490
576
|
continue;
|
|
491
577
|
}
|
|
492
|
-
const fileType = item.fileType
|
|
578
|
+
const fileType = normalizeOptionalLowercaseString(item.fileType);
|
|
493
579
|
const fileUrl = item.fileUrl?.trim();
|
|
494
580
|
if (!fileUrl) {
|
|
495
581
|
continue;
|
|
496
582
|
}
|
|
497
583
|
if (fileType === "others" || fileType === "video") {
|
|
498
|
-
return { fileUrl, fileName: item.fileName
|
|
584
|
+
return { fileUrl, fileName: normalizeOptionalString(item.fileName) };
|
|
499
585
|
}
|
|
500
586
|
}
|
|
501
587
|
return undefined;
|
|
@@ -509,8 +595,8 @@ function buildZaloVoicePlaybackUrl(asset: { fileUrl: string; fileName?: string }
|
|
|
509
595
|
|
|
510
596
|
function mapFriend(friend: User): ZcaFriend {
|
|
511
597
|
return {
|
|
512
|
-
userId:
|
|
513
|
-
displayName: friend.displayName || friend.zaloName || friend.username ||
|
|
598
|
+
userId: friend.userId,
|
|
599
|
+
displayName: friend.displayName || friend.zaloName || friend.username || friend.userId,
|
|
514
600
|
avatar: friend.avatar || undefined,
|
|
515
601
|
};
|
|
516
602
|
}
|
|
@@ -521,8 +607,8 @@ function mapGroup(groupId: string, group: GroupInfo & Record<string, unknown>):
|
|
|
521
607
|
? group.totalMember
|
|
522
608
|
: undefined;
|
|
523
609
|
return {
|
|
524
|
-
groupId
|
|
525
|
-
name: group.name?.trim() ||
|
|
610
|
+
groupId,
|
|
611
|
+
name: group.name?.trim() || groupId,
|
|
526
612
|
memberCount: totalMember,
|
|
527
613
|
};
|
|
528
614
|
}
|
|
@@ -530,7 +616,7 @@ function mapGroup(groupId: string, group: GroupInfo & Record<string, unknown>):
|
|
|
530
616
|
function readCredentials(profile: string): StoredZaloCredentials | null {
|
|
531
617
|
const filePath = resolveCredentialsPath(profile);
|
|
532
618
|
try {
|
|
533
|
-
if (!
|
|
619
|
+
if (!isReadableCredentialFile(filePath)) {
|
|
534
620
|
return null;
|
|
535
621
|
}
|
|
536
622
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
@@ -544,7 +630,7 @@ function readCredentials(profile: string): StoredZaloCredentials | null {
|
|
|
544
630
|
) {
|
|
545
631
|
return null;
|
|
546
632
|
}
|
|
547
|
-
|
|
633
|
+
const credentials = {
|
|
548
634
|
imei: parsed.imei,
|
|
549
635
|
cookie: parsed.cookie as Credentials["cookie"],
|
|
550
636
|
userAgent: parsed.userAgent,
|
|
@@ -552,31 +638,73 @@ function readCredentials(profile: string): StoredZaloCredentials | null {
|
|
|
552
638
|
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
|
|
553
639
|
lastUsedAt: typeof parsed.lastUsedAt === "string" ? parsed.lastUsedAt : undefined,
|
|
554
640
|
};
|
|
641
|
+
credentialSignaturesByProfile.set(profile, credentialSignature(credentials));
|
|
642
|
+
return credentials;
|
|
555
643
|
} catch {
|
|
556
644
|
return null;
|
|
557
645
|
}
|
|
558
646
|
}
|
|
559
647
|
|
|
560
|
-
function
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
648
|
+
function credentialSignature(
|
|
649
|
+
credentials: Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt">,
|
|
650
|
+
): string {
|
|
651
|
+
return JSON.stringify({
|
|
652
|
+
imei: credentials.imei,
|
|
653
|
+
cookie: canonicalCredentialCookie(credentials.cookie),
|
|
654
|
+
userAgent: credentials.userAgent,
|
|
655
|
+
language: credentials.language,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function stableCanonicalValue(value: unknown): unknown {
|
|
660
|
+
if (Array.isArray(value)) {
|
|
661
|
+
return value.map(stableCanonicalValue);
|
|
564
662
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
663
|
+
if (!value || typeof value !== "object") {
|
|
664
|
+
return value;
|
|
665
|
+
}
|
|
666
|
+
return Object.fromEntries(
|
|
667
|
+
Object.entries(value as Record<string, unknown>)
|
|
668
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
669
|
+
.map(([key, entry]) => [key, stableCanonicalValue(entry)]),
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function stableSignatureValue(value: unknown): string {
|
|
674
|
+
return JSON.stringify(stableCanonicalValue(value)) ?? "undefined";
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function canonicalCookieArray(value: unknown[]): unknown[] {
|
|
678
|
+
return value
|
|
679
|
+
.map(stableCanonicalValue)
|
|
680
|
+
.toSorted((left, right) =>
|
|
681
|
+
stableSignatureValue(left).localeCompare(stableSignatureValue(right)),
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function canonicalCredentialCookie(cookie: Credentials["cookie"]): unknown {
|
|
686
|
+
if (Array.isArray(cookie)) {
|
|
687
|
+
return canonicalCookieArray(cookie);
|
|
688
|
+
}
|
|
689
|
+
if (!cookie || typeof cookie !== "object") {
|
|
690
|
+
return cookie;
|
|
691
|
+
}
|
|
692
|
+
return Object.fromEntries(
|
|
693
|
+
Object.entries(cookie as Record<string, unknown>)
|
|
694
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
695
|
+
.map(([key, entry]) => [
|
|
696
|
+
key,
|
|
697
|
+
key === "cookies" && Array.isArray(entry)
|
|
698
|
+
? canonicalCookieArray(entry)
|
|
699
|
+
: stableCanonicalValue(entry),
|
|
700
|
+
]),
|
|
701
|
+
);
|
|
572
702
|
}
|
|
573
703
|
|
|
574
704
|
function writeCredentials(
|
|
575
705
|
profile: string,
|
|
576
706
|
credentials: Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt">,
|
|
577
707
|
): void {
|
|
578
|
-
const dir = resolveCredentialsDir();
|
|
579
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
580
708
|
const existing = readCredentials(profile);
|
|
581
709
|
const now = new Date().toISOString();
|
|
582
710
|
const next: StoredZaloCredentials = {
|
|
@@ -584,7 +712,59 @@ function writeCredentials(
|
|
|
584
712
|
createdAt: existing?.createdAt ?? now,
|
|
585
713
|
lastUsedAt: now,
|
|
586
714
|
};
|
|
587
|
-
|
|
715
|
+
writeCredentialFileAtomic(resolveCredentialsPath(profile), JSON.stringify(next, null, 2));
|
|
716
|
+
credentialSignaturesByProfile.set(profile, credentialSignature(next));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function snapshotApiCredentials(
|
|
720
|
+
api: API,
|
|
721
|
+
fallback?: Partial<Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt">>,
|
|
722
|
+
): Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt"> {
|
|
723
|
+
const ctx = api.getContext();
|
|
724
|
+
const cookieJson = api.getCookie().toJSON();
|
|
725
|
+
const refreshedCookies =
|
|
726
|
+
Array.isArray(cookieJson?.cookies) && cookieJson.cookies.length > 0
|
|
727
|
+
? cookieJson.cookies
|
|
728
|
+
: fallback?.cookie;
|
|
729
|
+
const imei = normalizeOptionalString(ctx.imei) ?? normalizeOptionalString(fallback?.imei);
|
|
730
|
+
const userAgent =
|
|
731
|
+
normalizeOptionalString(ctx.userAgent) ?? normalizeOptionalString(fallback?.userAgent);
|
|
732
|
+
if (!imei || !refreshedCookies || !userAgent) {
|
|
733
|
+
throw new Error("Zalo API session did not expose refreshed credentials");
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
736
|
+
imei,
|
|
737
|
+
cookie: refreshedCookies as Credentials["cookie"],
|
|
738
|
+
userAgent,
|
|
739
|
+
language: normalizeOptionalString(ctx.language) ?? normalizeOptionalString(fallback?.language),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function writeApiCredentials(
|
|
744
|
+
profile: string,
|
|
745
|
+
api: API,
|
|
746
|
+
fallback?: Partial<Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt">>,
|
|
747
|
+
): void {
|
|
748
|
+
writeCredentials(profile, snapshotApiCredentials(api, fallback));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function writeApiCredentialsIfChanged(profile: string, api: API): boolean {
|
|
752
|
+
const credentials = snapshotApiCredentials(api);
|
|
753
|
+
const signature = credentialSignature(credentials);
|
|
754
|
+
if (credentialSignaturesByProfile.get(profile) === signature) {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
writeCredentials(profile, credentials);
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function persistApiCredentialsIfChanged(profile: string, api: API): void {
|
|
762
|
+
try {
|
|
763
|
+
writeApiCredentialsIfChanged(profile, api);
|
|
764
|
+
} catch {
|
|
765
|
+
// Do not fail an already-successful Zalo operation only because the
|
|
766
|
+
// best-effort session refresh could not be persisted.
|
|
767
|
+
}
|
|
588
768
|
}
|
|
589
769
|
|
|
590
770
|
function clearCredentials(profile: string): boolean {
|
|
@@ -592,6 +772,7 @@ function clearCredentials(profile: string): boolean {
|
|
|
592
772
|
try {
|
|
593
773
|
if (fs.existsSync(filePath)) {
|
|
594
774
|
fs.unlinkSync(filePath);
|
|
775
|
+
credentialSignaturesByProfile.delete(profile);
|
|
595
776
|
return true;
|
|
596
777
|
}
|
|
597
778
|
} catch {
|
|
@@ -618,9 +799,9 @@ async function ensureApi(
|
|
|
618
799
|
const initPromise = (async () => {
|
|
619
800
|
const stored = readCredentials(profile);
|
|
620
801
|
if (!stored) {
|
|
621
|
-
throw new Error(`No saved Zalo session for profile
|
|
802
|
+
throw new Error(`No saved Zalo session for profile "${profile}"`);
|
|
622
803
|
}
|
|
623
|
-
const zalo =
|
|
804
|
+
const zalo = await createZalo({
|
|
624
805
|
logging: false,
|
|
625
806
|
selfListen: false,
|
|
626
807
|
});
|
|
@@ -632,10 +813,10 @@ async function ensureApi(
|
|
|
632
813
|
language: stored.language,
|
|
633
814
|
}),
|
|
634
815
|
timeoutMs,
|
|
635
|
-
`Timed out restoring Zalo session for profile
|
|
816
|
+
`Timed out restoring Zalo session for profile "${profile}"`,
|
|
636
817
|
);
|
|
637
818
|
apiByProfile.set(profile, api);
|
|
638
|
-
|
|
819
|
+
writeApiCredentials(profile, api, stored);
|
|
639
820
|
return api;
|
|
640
821
|
})();
|
|
641
822
|
|
|
@@ -650,6 +831,23 @@ async function ensureApi(
|
|
|
650
831
|
}
|
|
651
832
|
}
|
|
652
833
|
|
|
834
|
+
async function withZaloApi<T>(
|
|
835
|
+
profileInput: string | null | undefined,
|
|
836
|
+
operation: (api: API) => Promise<T>,
|
|
837
|
+
options: {
|
|
838
|
+
timeoutMs?: number;
|
|
839
|
+
shouldPersist?: (result: T) => boolean;
|
|
840
|
+
} = {},
|
|
841
|
+
): Promise<T> {
|
|
842
|
+
const profile = normalizeProfile(profileInput);
|
|
843
|
+
const api = await ensureApi(profile, options.timeoutMs);
|
|
844
|
+
const result = await operation(api);
|
|
845
|
+
if (options.shouldPersist?.(result) ?? true) {
|
|
846
|
+
persistApiCredentialsIfChanged(profile, api);
|
|
847
|
+
}
|
|
848
|
+
return result;
|
|
849
|
+
}
|
|
850
|
+
|
|
653
851
|
function invalidateApi(profileInput?: string | null): void {
|
|
654
852
|
const profile = normalizeProfile(profileInput);
|
|
655
853
|
const api = apiByProfile.get(profile);
|
|
@@ -777,7 +975,7 @@ function extractGroupMembersFromInfo(
|
|
|
777
975
|
}
|
|
778
976
|
|
|
779
977
|
function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMessage | null {
|
|
780
|
-
const data = message.data
|
|
978
|
+
const data = message.data;
|
|
781
979
|
const isGroup = message.type === ThreadType.Group;
|
|
782
980
|
const senderId = toNumberId(data.uidFrom);
|
|
783
981
|
const threadId = isGroup
|
|
@@ -827,7 +1025,7 @@ function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMess
|
|
|
827
1025
|
};
|
|
828
1026
|
}
|
|
829
1027
|
|
|
830
|
-
|
|
1028
|
+
function zalouserSessionExists(profileInput?: string | null): boolean {
|
|
831
1029
|
const profile = normalizeProfile(profileInput);
|
|
832
1030
|
return readCredentials(profile) !== null;
|
|
833
1031
|
}
|
|
@@ -838,8 +1036,12 @@ export async function checkZaloAuthenticated(profileInput?: string | null): Prom
|
|
|
838
1036
|
return false;
|
|
839
1037
|
}
|
|
840
1038
|
try {
|
|
841
|
-
|
|
842
|
-
|
|
1039
|
+
await withZaloApi(
|
|
1040
|
+
profile,
|
|
1041
|
+
async (api) =>
|
|
1042
|
+
await withTimeout(api.fetchAccountInfo(), 12_000, "Timed out checking Zalo session"),
|
|
1043
|
+
{ timeoutMs: 12_000 },
|
|
1044
|
+
);
|
|
843
1045
|
return true;
|
|
844
1046
|
} catch {
|
|
845
1047
|
invalidateApi(profile);
|
|
@@ -849,24 +1051,26 @@ export async function checkZaloAuthenticated(profileInput?: string | null): Prom
|
|
|
849
1051
|
|
|
850
1052
|
export async function getZaloUserInfo(profileInput?: string | null): Promise<ZcaUserInfo | null> {
|
|
851
1053
|
const profile = normalizeProfile(profileInput);
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1054
|
+
return await withZaloApi(profile, async (api) => {
|
|
1055
|
+
const info = await api.fetchAccountInfo();
|
|
1056
|
+
const user = normalizeAccountInfoUser(info);
|
|
1057
|
+
if (!user?.userId) {
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
return {
|
|
1061
|
+
userId: user.userId,
|
|
1062
|
+
displayName: user.displayName || user.zaloName || user.userId,
|
|
1063
|
+
avatar: user.avatar || undefined,
|
|
1064
|
+
};
|
|
1065
|
+
});
|
|
863
1066
|
}
|
|
864
1067
|
|
|
865
1068
|
export async function listZaloFriends(profileInput?: string | null): Promise<ZcaFriend[]> {
|
|
866
1069
|
const profile = normalizeProfile(profileInput);
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1070
|
+
return await withZaloApi(profile, async (api) => {
|
|
1071
|
+
const friends = await api.getAllFriends();
|
|
1072
|
+
return friends.map(mapFriend);
|
|
1073
|
+
});
|
|
870
1074
|
}
|
|
871
1075
|
|
|
872
1076
|
export async function listZaloFriendsMatching(
|
|
@@ -874,42 +1078,43 @@ export async function listZaloFriendsMatching(
|
|
|
874
1078
|
query?: string | null,
|
|
875
1079
|
): Promise<ZcaFriend[]> {
|
|
876
1080
|
const friends = await listZaloFriends(profileInput);
|
|
877
|
-
const q = query
|
|
1081
|
+
const q = normalizeOptionalLowercaseString(query);
|
|
878
1082
|
if (!q) {
|
|
879
1083
|
return friends;
|
|
880
1084
|
}
|
|
881
1085
|
const scored = friends
|
|
882
1086
|
.map((friend) => {
|
|
883
|
-
const id = friend.userId
|
|
884
|
-
const name = friend.displayName
|
|
1087
|
+
const id = normalizeLowercaseStringOrEmpty(friend.userId);
|
|
1088
|
+
const name = normalizeLowercaseStringOrEmpty(friend.displayName);
|
|
885
1089
|
const exact = id === q || name === q;
|
|
886
1090
|
const includes = id.includes(q) || name.includes(q);
|
|
887
1091
|
return { friend, exact, includes };
|
|
888
1092
|
})
|
|
889
1093
|
.filter((entry) => entry.includes)
|
|
890
|
-
.
|
|
1094
|
+
.toSorted((a, b) => Number(b.exact) - Number(a.exact));
|
|
891
1095
|
return scored.map((entry) => entry.friend);
|
|
892
1096
|
}
|
|
893
1097
|
|
|
894
1098
|
export async function listZaloGroups(profileInput?: string | null): Promise<ZaloGroup[]> {
|
|
895
1099
|
const profile = normalizeProfile(profileInput);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
}
|
|
902
|
-
const details = await fetchGroupsByIds(api, ids);
|
|
903
|
-
const rows: ZaloGroup[] = [];
|
|
904
|
-
for (const id of ids) {
|
|
905
|
-
const info = details.get(id);
|
|
906
|
-
if (!info) {
|
|
907
|
-
rows.push({ groupId: id, name: id });
|
|
908
|
-
continue;
|
|
1100
|
+
return await withZaloApi(profile, async (api) => {
|
|
1101
|
+
const allGroups = await api.getAllGroups();
|
|
1102
|
+
const ids = Object.keys(allGroups.gridVerMap ?? {});
|
|
1103
|
+
if (ids.length === 0) {
|
|
1104
|
+
return [];
|
|
909
1105
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1106
|
+
const details = await fetchGroupsByIds(api, ids);
|
|
1107
|
+
const rows: ZaloGroup[] = [];
|
|
1108
|
+
for (const id of ids) {
|
|
1109
|
+
const info = details.get(id);
|
|
1110
|
+
if (!info) {
|
|
1111
|
+
rows.push({ groupId: id, name: id });
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
rows.push(mapGroup(id, info as GroupInfo & Record<string, unknown>));
|
|
1115
|
+
}
|
|
1116
|
+
return rows;
|
|
1117
|
+
});
|
|
913
1118
|
}
|
|
914
1119
|
|
|
915
1120
|
export async function listZaloGroupsMatching(
|
|
@@ -917,13 +1122,13 @@ export async function listZaloGroupsMatching(
|
|
|
917
1122
|
query?: string | null,
|
|
918
1123
|
): Promise<ZaloGroup[]> {
|
|
919
1124
|
const groups = await listZaloGroups(profileInput);
|
|
920
|
-
const q = query
|
|
1125
|
+
const q = normalizeOptionalLowercaseString(query);
|
|
921
1126
|
if (!q) {
|
|
922
1127
|
return groups;
|
|
923
1128
|
}
|
|
924
1129
|
return groups.filter((group) => {
|
|
925
|
-
const id = group.groupId
|
|
926
|
-
const name = group.name
|
|
1130
|
+
const id = normalizeLowercaseStringOrEmpty(group.groupId);
|
|
1131
|
+
const name = normalizeLowercaseStringOrEmpty(group.name);
|
|
927
1132
|
return id.includes(q) || name.includes(q);
|
|
928
1133
|
});
|
|
929
1134
|
}
|
|
@@ -933,69 +1138,72 @@ export async function listZaloGroupMembers(
|
|
|
933
1138
|
groupId: string,
|
|
934
1139
|
): Promise<ZaloGroupMember[]> {
|
|
935
1140
|
const profile = normalizeProfile(profileInput);
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
return [];
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
const memberIds = Array.isArray(groupInfo.memberIds)
|
|
947
|
-
? groupInfo.memberIds.map((id: unknown) => toNumberId(id)).filter(Boolean)
|
|
948
|
-
: [];
|
|
949
|
-
const memVerIds = Array.isArray(groupInfo.memVerList)
|
|
950
|
-
? groupInfo.memVerList.map((id: unknown) => toNumberId(id)).filter(Boolean)
|
|
951
|
-
: [];
|
|
952
|
-
const currentMembers = Array.isArray(groupInfo.currentMems) ? groupInfo.currentMems : [];
|
|
953
|
-
|
|
954
|
-
const currentById = new Map<string, { displayName?: string; avatar?: string }>();
|
|
955
|
-
for (const member of currentMembers) {
|
|
956
|
-
const id = toNumberId(member?.id);
|
|
957
|
-
if (!id) {
|
|
958
|
-
continue;
|
|
1141
|
+
return await withZaloApi(profile, async (api) => {
|
|
1142
|
+
const infoResponse = await api.getGroupInfo(groupId);
|
|
1143
|
+
const groupInfo = infoResponse.gridInfoMap?.[groupId] as
|
|
1144
|
+
| (GroupInfo & { memVerList?: unknown })
|
|
1145
|
+
| undefined;
|
|
1146
|
+
if (!groupInfo) {
|
|
1147
|
+
return [];
|
|
959
1148
|
}
|
|
960
|
-
currentById.set(id, {
|
|
961
|
-
displayName: member.dName?.trim() || member.zaloName?.trim() || undefined,
|
|
962
|
-
avatar: member.avatar || undefined,
|
|
963
|
-
});
|
|
964
|
-
}
|
|
965
1149
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
zaloName?: string;
|
|
979
|
-
avatar?: string;
|
|
980
|
-
}
|
|
981
|
-
>;
|
|
982
|
-
for (const [rawId, profileValue] of Object.entries(profileEntries)) {
|
|
983
|
-
const id = toNumberId(rawId) || toNumberId((profileValue as { id?: unknown })?.id);
|
|
984
|
-
if (!id || !profileValue) {
|
|
1150
|
+
const memberIds = Array.isArray(groupInfo.memberIds)
|
|
1151
|
+
? groupInfo.memberIds.map((id: unknown) => toNumberId(id)).filter(Boolean)
|
|
1152
|
+
: [];
|
|
1153
|
+
const memVerIds = Array.isArray(groupInfo.memVerList)
|
|
1154
|
+
? groupInfo.memVerList.map((id: unknown) => toNumberId(id)).filter(Boolean)
|
|
1155
|
+
: [];
|
|
1156
|
+
const currentMembers = Array.isArray(groupInfo.currentMems) ? groupInfo.currentMems : [];
|
|
1157
|
+
|
|
1158
|
+
const currentById = new Map<string, { displayName?: string; avatar?: string }>();
|
|
1159
|
+
for (const member of currentMembers) {
|
|
1160
|
+
const id = toNumberId(member?.id);
|
|
1161
|
+
if (!id) {
|
|
985
1162
|
continue;
|
|
986
1163
|
}
|
|
987
|
-
|
|
988
|
-
displayName:
|
|
989
|
-
|
|
1164
|
+
currentById.set(id, {
|
|
1165
|
+
displayName:
|
|
1166
|
+
normalizeOptionalString(member.dName) ?? normalizeOptionalString(member.zaloName),
|
|
1167
|
+
avatar: member.avatar || undefined,
|
|
990
1168
|
});
|
|
991
1169
|
}
|
|
992
|
-
}
|
|
993
1170
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1171
|
+
const uniqueIds = Array.from(
|
|
1172
|
+
new Set<string>([...memberIds, ...memVerIds, ...currentById.keys()]),
|
|
1173
|
+
);
|
|
1174
|
+
|
|
1175
|
+
const profileMap = new Map<string, { displayName?: string; avatar?: string }>();
|
|
1176
|
+
if (uniqueIds.length > 0) {
|
|
1177
|
+
const profiles = await api.getGroupMembersInfo(uniqueIds);
|
|
1178
|
+
const profileEntries = profiles.profiles as Record<
|
|
1179
|
+
string,
|
|
1180
|
+
{
|
|
1181
|
+
id?: string;
|
|
1182
|
+
displayName?: string;
|
|
1183
|
+
zaloName?: string;
|
|
1184
|
+
avatar?: string;
|
|
1185
|
+
}
|
|
1186
|
+
>;
|
|
1187
|
+
for (const [rawId, profileValue] of Object.entries(profileEntries)) {
|
|
1188
|
+
const id = toNumberId(rawId) || toNumberId((profileValue as { id?: unknown })?.id);
|
|
1189
|
+
if (!id || !profileValue) {
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
profileMap.set(id, {
|
|
1193
|
+
displayName:
|
|
1194
|
+
normalizeOptionalString(profileValue.displayName) ??
|
|
1195
|
+
normalizeOptionalString(profileValue.zaloName),
|
|
1196
|
+
avatar: profileValue.avatar || undefined,
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return uniqueIds.map((id) => ({
|
|
1202
|
+
userId: id,
|
|
1203
|
+
displayName: profileMap.get(id)?.displayName || currentById.get(id)?.displayName || id,
|
|
1204
|
+
avatar: profileMap.get(id)?.avatar || currentById.get(id)?.avatar,
|
|
1205
|
+
}));
|
|
1206
|
+
});
|
|
999
1207
|
}
|
|
1000
1208
|
|
|
1001
1209
|
export async function resolveZaloGroupContext(
|
|
@@ -1012,18 +1220,19 @@ export async function resolveZaloGroupContext(
|
|
|
1012
1220
|
return cached;
|
|
1013
1221
|
}
|
|
1014
1222
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1223
|
+
return await withZaloApi(profile, async (api) => {
|
|
1224
|
+
const response = await api.getGroupInfo(normalizedGroupId);
|
|
1225
|
+
const groupInfo = response.gridInfoMap?.[normalizedGroupId] as
|
|
1226
|
+
| (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] })
|
|
1227
|
+
| undefined;
|
|
1228
|
+
const context: ZaloGroupContext = {
|
|
1229
|
+
groupId: normalizedGroupId,
|
|
1230
|
+
name: normalizeOptionalString(groupInfo?.name),
|
|
1231
|
+
members: extractGroupMembersFromInfo(groupInfo),
|
|
1232
|
+
};
|
|
1233
|
+
writeCachedGroupContext(profile, context);
|
|
1234
|
+
return context;
|
|
1235
|
+
});
|
|
1027
1236
|
}
|
|
1028
1237
|
|
|
1029
1238
|
export async function sendZaloTextMessage(
|
|
@@ -1037,91 +1246,97 @@ export async function sendZaloTextMessage(
|
|
|
1037
1246
|
return { ok: false, error: "No threadId provided" };
|
|
1038
1247
|
}
|
|
1039
1248
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1249
|
+
return await withZaloApi(
|
|
1250
|
+
profile,
|
|
1251
|
+
async (api) => {
|
|
1252
|
+
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
|
1042
1253
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1254
|
+
try {
|
|
1255
|
+
if (options.mediaUrl?.trim()) {
|
|
1256
|
+
const media = await loadOutboundMediaFromUrl(options.mediaUrl.trim(), {
|
|
1257
|
+
mediaLocalRoots: options.mediaLocalRoots,
|
|
1258
|
+
mediaReadFile: options.mediaReadFile,
|
|
1259
|
+
});
|
|
1260
|
+
const fileName = resolveMediaFileName({
|
|
1261
|
+
mediaUrl: options.mediaUrl,
|
|
1262
|
+
fileName: media.fileName,
|
|
1263
|
+
contentType: media.contentType,
|
|
1264
|
+
kind: media.kind,
|
|
1265
|
+
});
|
|
1266
|
+
const payloadText = (text || options.caption || "").slice(0, 2000);
|
|
1267
|
+
const textStyles = clampTextStyles(payloadText, options.textStyles);
|
|
1268
|
+
|
|
1269
|
+
if (media.kind === "audio") {
|
|
1270
|
+
let textMessageId: string | undefined;
|
|
1271
|
+
if (payloadText) {
|
|
1272
|
+
const textResponse = await api.sendMessage(
|
|
1273
|
+
textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
|
|
1274
|
+
trimmedThreadId,
|
|
1275
|
+
type,
|
|
1276
|
+
);
|
|
1277
|
+
textMessageId = extractSendMessageId(textResponse);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const attachmentFileName = fileName.includes(".") ? fileName : `${fileName}.bin`;
|
|
1281
|
+
const uploaded = await api.uploadAttachment(
|
|
1282
|
+
[
|
|
1283
|
+
{
|
|
1284
|
+
data: media.buffer,
|
|
1285
|
+
filename: attachmentFileName as `${string}.${string}`,
|
|
1286
|
+
metadata: {
|
|
1287
|
+
totalSize: media.buffer.length,
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
],
|
|
1291
|
+
trimmedThreadId,
|
|
1292
|
+
type,
|
|
1293
|
+
);
|
|
1294
|
+
const voiceAsset = resolveUploadedVoiceAsset(uploaded);
|
|
1295
|
+
if (!voiceAsset) {
|
|
1296
|
+
throw new Error("Failed to resolve uploaded audio URL for voice message");
|
|
1297
|
+
}
|
|
1298
|
+
const voiceUrl = buildZaloVoicePlaybackUrl(voiceAsset);
|
|
1299
|
+
const response = await api.sendVoice({ voiceUrl }, trimmedThreadId, type);
|
|
1300
|
+
return {
|
|
1301
|
+
ok: true,
|
|
1302
|
+
messageId: extractSendMessageId(response) ?? textMessageId,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const response = await api.sendMessage(
|
|
1307
|
+
{
|
|
1308
|
+
msg: payloadText,
|
|
1309
|
+
...(textStyles ? { styles: textStyles } : {}),
|
|
1310
|
+
attachments: [
|
|
1311
|
+
{
|
|
1312
|
+
data: media.buffer,
|
|
1313
|
+
filename: fileName.includes(".") ? fileName : `${fileName}.bin`,
|
|
1314
|
+
metadata: {
|
|
1315
|
+
totalSize: media.buffer.length,
|
|
1316
|
+
},
|
|
1317
|
+
},
|
|
1318
|
+
],
|
|
1319
|
+
},
|
|
1062
1320
|
trimmedThreadId,
|
|
1063
1321
|
type,
|
|
1064
1322
|
);
|
|
1065
|
-
|
|
1323
|
+
return { ok: true, messageId: extractSendMessageId(response) };
|
|
1066
1324
|
}
|
|
1067
1325
|
|
|
1068
|
-
const
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
data: media.buffer,
|
|
1073
|
-
filename: attachmentFileName as `${string}.${string}`,
|
|
1074
|
-
metadata: {
|
|
1075
|
-
totalSize: media.buffer.length,
|
|
1076
|
-
},
|
|
1077
|
-
},
|
|
1078
|
-
],
|
|
1326
|
+
const payloadText = text.slice(0, 2000);
|
|
1327
|
+
const textStyles = clampTextStyles(payloadText, options.textStyles);
|
|
1328
|
+
const response = await api.sendMessage(
|
|
1329
|
+
textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
|
|
1079
1330
|
trimmedThreadId,
|
|
1080
1331
|
type,
|
|
1081
1332
|
);
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
}
|
|
1086
|
-
const voiceUrl = buildZaloVoicePlaybackUrl(voiceAsset);
|
|
1087
|
-
const response = await api.sendVoice({ voiceUrl }, trimmedThreadId, type);
|
|
1088
|
-
return {
|
|
1089
|
-
ok: true,
|
|
1090
|
-
messageId: extractSendMessageId(response) ?? textMessageId,
|
|
1091
|
-
};
|
|
1333
|
+
return { ok: true, messageId: extractSendMessageId(response) };
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
1092
1336
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
msg: payloadText,
|
|
1097
|
-
...(textStyles ? { styles: textStyles } : {}),
|
|
1098
|
-
attachments: [
|
|
1099
|
-
{
|
|
1100
|
-
data: media.buffer,
|
|
1101
|
-
filename: fileName.includes(".") ? fileName : `${fileName}.bin`,
|
|
1102
|
-
metadata: {
|
|
1103
|
-
totalSize: media.buffer.length,
|
|
1104
|
-
},
|
|
1105
|
-
},
|
|
1106
|
-
],
|
|
1107
|
-
},
|
|
1108
|
-
trimmedThreadId,
|
|
1109
|
-
type,
|
|
1110
|
-
);
|
|
1111
|
-
return { ok: true, messageId: extractSendMessageId(response) };
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
const payloadText = text.slice(0, 2000);
|
|
1115
|
-
const textStyles = clampTextStyles(payloadText, options.textStyles);
|
|
1116
|
-
const response = await api.sendMessage(
|
|
1117
|
-
textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
|
|
1118
|
-
trimmedThreadId,
|
|
1119
|
-
type,
|
|
1120
|
-
);
|
|
1121
|
-
return { ok: true, messageId: extractSendMessageId(response) };
|
|
1122
|
-
} catch (error) {
|
|
1123
|
-
return { ok: false, error: toErrorMessage(error) };
|
|
1124
|
-
}
|
|
1337
|
+
},
|
|
1338
|
+
{ shouldPersist: (result) => result.ok },
|
|
1339
|
+
);
|
|
1125
1340
|
}
|
|
1126
1341
|
|
|
1127
1342
|
export async function sendZaloTypingEvent(
|
|
@@ -1133,13 +1348,14 @@ export async function sendZaloTypingEvent(
|
|
|
1133
1348
|
if (!trimmedThreadId) {
|
|
1134
1349
|
throw new Error("No threadId provided");
|
|
1135
1350
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1351
|
+
await withZaloApi(profile, async (api) => {
|
|
1352
|
+
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
|
1353
|
+
if ("sendTypingEvent" in api && typeof api.sendTypingEvent === "function") {
|
|
1354
|
+
await (api as API & ApiTypingCapability).sendTypingEvent(trimmedThreadId, type);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
throw new Error("Zalo typing indicator is not supported by current API session");
|
|
1358
|
+
});
|
|
1143
1359
|
}
|
|
1144
1360
|
|
|
1145
1361
|
async function resolveOwnUserId(api: API): Promise<string> {
|
|
@@ -1182,17 +1398,22 @@ export async function sendZaloReaction(params: {
|
|
|
1182
1398
|
return { ok: false, error: "threadId, msgId, and cliMsgId are required" };
|
|
1183
1399
|
}
|
|
1184
1400
|
try {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1401
|
+
return await withZaloApi(
|
|
1402
|
+
profile,
|
|
1403
|
+
async (api) => {
|
|
1404
|
+
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
|
|
1405
|
+
const icon = params.remove
|
|
1406
|
+
? { rType: -1, source: 6, icon: "" }
|
|
1407
|
+
: normalizeZaloReactionIcon(params.emoji);
|
|
1408
|
+
await api.addReaction(icon, {
|
|
1409
|
+
data: { msgId, cliMsgId },
|
|
1410
|
+
threadId,
|
|
1411
|
+
type,
|
|
1412
|
+
});
|
|
1413
|
+
return { ok: true };
|
|
1414
|
+
},
|
|
1415
|
+
{ shouldPersist: (result) => result.ok },
|
|
1416
|
+
);
|
|
1196
1417
|
} catch (error) {
|
|
1197
1418
|
return { ok: false, error: toErrorMessage(error) };
|
|
1198
1419
|
}
|
|
@@ -1205,9 +1426,10 @@ export async function sendZaloDeliveredEvent(params: {
|
|
|
1205
1426
|
isSeen?: boolean;
|
|
1206
1427
|
}): Promise<void> {
|
|
1207
1428
|
const profile = normalizeProfile(params.profile);
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1429
|
+
await withZaloApi(profile, async (api) => {
|
|
1430
|
+
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
|
|
1431
|
+
await api.sendDeliveredEvent(params.isSeen === true, params.message, type);
|
|
1432
|
+
});
|
|
1211
1433
|
}
|
|
1212
1434
|
|
|
1213
1435
|
export async function sendZaloSeenEvent(params: {
|
|
@@ -1216,9 +1438,10 @@ export async function sendZaloSeenEvent(params: {
|
|
|
1216
1438
|
message: ZaloEventMessage;
|
|
1217
1439
|
}): Promise<void> {
|
|
1218
1440
|
const profile = normalizeProfile(params.profile);
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1441
|
+
await withZaloApi(profile, async (api) => {
|
|
1442
|
+
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
|
|
1443
|
+
await api.sendSeenEvent(params.message, type);
|
|
1444
|
+
});
|
|
1222
1445
|
}
|
|
1223
1446
|
|
|
1224
1447
|
export async function sendZaloLink(
|
|
@@ -1237,14 +1460,19 @@ export async function sendZaloLink(
|
|
|
1237
1460
|
}
|
|
1238
1461
|
|
|
1239
1462
|
try {
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1463
|
+
return await withZaloApi(
|
|
1464
|
+
profile,
|
|
1465
|
+
async (api) => {
|
|
1466
|
+
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
|
1467
|
+
const response = await api.sendLink(
|
|
1468
|
+
{ link: trimmedUrl, msg: options.caption },
|
|
1469
|
+
trimmedThreadId,
|
|
1470
|
+
type,
|
|
1471
|
+
);
|
|
1472
|
+
return { ok: true, messageId: String(response.msgId) };
|
|
1473
|
+
},
|
|
1474
|
+
{ shouldPersist: (result) => result.ok },
|
|
1246
1475
|
);
|
|
1247
|
-
return { ok: true, messageId: String(response.msgId) };
|
|
1248
1476
|
} catch (error) {
|
|
1249
1477
|
return { ok: false, error: toErrorMessage(error) };
|
|
1250
1478
|
}
|
|
@@ -1294,7 +1522,7 @@ export async function startZaloQrLogin(params: {
|
|
|
1294
1522
|
let capturedCredentials: Omit<StoredZaloCredentials, "createdAt" | "lastUsedAt"> | null =
|
|
1295
1523
|
null;
|
|
1296
1524
|
try {
|
|
1297
|
-
const zalo =
|
|
1525
|
+
const zalo = await createZalo({ logging: false, selfListen: false });
|
|
1298
1526
|
const api = await zalo.loginQR(undefined, (event: LoginQRCallbackEvent) => {
|
|
1299
1527
|
const current = activeQrLogins.get(profile);
|
|
1300
1528
|
if (!current || current.id !== login.id) {
|
|
@@ -1361,7 +1589,7 @@ export async function startZaloQrLogin(params: {
|
|
|
1361
1589
|
};
|
|
1362
1590
|
}
|
|
1363
1591
|
|
|
1364
|
-
|
|
1592
|
+
writeApiCredentials(profile, api, capturedCredentials ?? undefined);
|
|
1365
1593
|
invalidateApi(profile);
|
|
1366
1594
|
apiByProfile.set(profile, api);
|
|
1367
1595
|
current.connected = true;
|
|
@@ -1503,12 +1731,14 @@ export async function startZaloListener(params: {
|
|
|
1503
1731
|
const existing = activeListeners.get(profile);
|
|
1504
1732
|
if (existing) {
|
|
1505
1733
|
throw new Error(
|
|
1506
|
-
`Zalo listener already running for profile
|
|
1734
|
+
`Zalo listener already running for profile "${profile}" (account "${existing.accountId}")`,
|
|
1507
1735
|
);
|
|
1508
1736
|
}
|
|
1509
1737
|
|
|
1510
|
-
const api = await
|
|
1511
|
-
|
|
1738
|
+
const { api, ownUserId } = await withZaloApi(profile, async (api) => ({
|
|
1739
|
+
api,
|
|
1740
|
+
ownUserId: await resolveOwnUserId(api),
|
|
1741
|
+
}));
|
|
1512
1742
|
let stopped = false;
|
|
1513
1743
|
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
|
|
1514
1744
|
let lastWatchdogTickAt = Date.now();
|
|
@@ -1619,7 +1849,7 @@ export async function resolveZaloGroupsByEntries(params: {
|
|
|
1619
1849
|
const groups = await listZaloGroups(params.profile);
|
|
1620
1850
|
const byName = new Map<string, ZaloGroup[]>();
|
|
1621
1851
|
for (const group of groups) {
|
|
1622
|
-
const key = group.name
|
|
1852
|
+
const key = normalizeOptionalLowercaseString(group.name);
|
|
1623
1853
|
if (!key) {
|
|
1624
1854
|
continue;
|
|
1625
1855
|
}
|
|
@@ -1636,7 +1866,7 @@ export async function resolveZaloGroupsByEntries(params: {
|
|
|
1636
1866
|
if (/^\d+$/.test(trimmed)) {
|
|
1637
1867
|
return { input, resolved: true, id: trimmed };
|
|
1638
1868
|
}
|
|
1639
|
-
const candidates = byName.get(trimmed
|
|
1869
|
+
const candidates = byName.get(normalizeLowercaseStringOrEmpty(trimmed)) ?? [];
|
|
1640
1870
|
const match = candidates[0];
|
|
1641
1871
|
return match ? { input, resolved: true, id: match.groupId } : { input, resolved: false };
|
|
1642
1872
|
});
|
|
@@ -1649,7 +1879,7 @@ export async function resolveZaloAllowFromEntries(params: {
|
|
|
1649
1879
|
const friends = await listZaloFriends(params.profile);
|
|
1650
1880
|
const byName = new Map<string, ZcaFriend[]>();
|
|
1651
1881
|
for (const friend of friends) {
|
|
1652
|
-
const key = friend.displayName
|
|
1882
|
+
const key = normalizeOptionalLowercaseString(friend.displayName);
|
|
1653
1883
|
if (!key) {
|
|
1654
1884
|
continue;
|
|
1655
1885
|
}
|
|
@@ -1666,7 +1896,7 @@ export async function resolveZaloAllowFromEntries(params: {
|
|
|
1666
1896
|
if (/^\d+$/.test(trimmed)) {
|
|
1667
1897
|
return { input, resolved: true, id: trimmed };
|
|
1668
1898
|
}
|
|
1669
|
-
const matches = byName.get(trimmed
|
|
1899
|
+
const matches = byName.get(normalizeLowercaseStringOrEmpty(trimmed)) ?? [];
|
|
1670
1900
|
const match = matches[0];
|
|
1671
1901
|
if (!match) {
|
|
1672
1902
|
return { input, resolved: false };
|
|
@@ -1679,16 +1909,3 @@ export async function resolveZaloAllowFromEntries(params: {
|
|
|
1679
1909
|
};
|
|
1680
1910
|
});
|
|
1681
1911
|
}
|
|
1682
|
-
|
|
1683
|
-
export async function clearProfileRuntimeArtifacts(profileInput?: string | null): Promise<void> {
|
|
1684
|
-
const profile = normalizeProfile(profileInput);
|
|
1685
|
-
resetQrLogin(profile);
|
|
1686
|
-
clearCachedGroupContext(profile);
|
|
1687
|
-
const listener = activeListeners.get(profile);
|
|
1688
|
-
if (listener) {
|
|
1689
|
-
listener.stop();
|
|
1690
|
-
activeListeners.delete(profile);
|
|
1691
|
-
}
|
|
1692
|
-
invalidateApi(profile);
|
|
1693
|
-
await fsp.mkdir(resolveCredentialsDir(), { recursive: true }).catch(() => undefined);
|
|
1694
|
-
}
|