@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.
Files changed (67) hide show
  1. package/README.md +4 -3
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +3 -0
  4. package/contract-api.ts +2 -0
  5. package/doctor-contract-api.ts +1 -0
  6. package/index.ts +29 -24
  7. package/openclaw.plugin.json +288 -1
  8. package/package.json +38 -11
  9. package/runtime-api.ts +67 -0
  10. package/secret-contract-api.ts +4 -0
  11. package/setup-entry.ts +9 -0
  12. package/setup-plugin-api.ts +2 -0
  13. package/src/accounts.runtime.ts +1 -0
  14. package/src/accounts.test-mocks.ts +7 -3
  15. package/src/accounts.test.ts +53 -1
  16. package/src/accounts.ts +38 -24
  17. package/src/channel-api.ts +20 -0
  18. package/src/channel.adapters.ts +390 -0
  19. package/src/channel.directory.test.ts +47 -40
  20. package/src/channel.runtime.ts +12 -0
  21. package/src/channel.sendpayload.test.ts +41 -23
  22. package/src/channel.setup.test.ts +33 -0
  23. package/src/channel.setup.ts +12 -0
  24. package/src/channel.test.ts +231 -20
  25. package/src/channel.ts +176 -685
  26. package/src/config-schema.ts +5 -5
  27. package/src/directory.ts +54 -0
  28. package/src/doctor-contract.ts +156 -0
  29. package/src/doctor.test.ts +77 -0
  30. package/src/doctor.ts +37 -0
  31. package/src/group-policy.test.ts +4 -4
  32. package/src/group-policy.ts +4 -2
  33. package/src/monitor.account-scope.test.ts +2 -1
  34. package/src/monitor.group-gating.test.ts +162 -8
  35. package/src/monitor.ts +233 -173
  36. package/src/probe.ts +3 -2
  37. package/src/qr-temp-file.ts +1 -1
  38. package/src/reaction.ts +5 -2
  39. package/src/runtime.ts +6 -3
  40. package/src/security-audit.test.ts +80 -0
  41. package/src/security-audit.ts +71 -0
  42. package/src/send.test.ts +2 -2
  43. package/src/send.ts +3 -3
  44. package/src/session-route.ts +121 -0
  45. package/src/setup-core.ts +33 -0
  46. package/src/setup-surface.test.ts +363 -0
  47. package/src/setup-surface.ts +470 -0
  48. package/src/setup-test-helpers.ts +42 -0
  49. package/src/shared.ts +92 -0
  50. package/src/status-issues.test.ts +1 -13
  51. package/src/status-issues.ts +8 -2
  52. package/src/test-helpers.ts +1 -1
  53. package/src/text-styles.test.ts +1 -1
  54. package/src/text-styles.ts +5 -2
  55. package/src/tool.test.ts +66 -3
  56. package/src/tool.ts +76 -14
  57. package/src/types.ts +3 -3
  58. package/src/zalo-js.credentials.test.ts +465 -0
  59. package/src/zalo-js.test-mocks.ts +89 -0
  60. package/src/zalo-js.ts +491 -274
  61. package/src/zca-client.test.ts +24 -0
  62. package/src/zca-client.ts +24 -58
  63. package/src/zca-constants.ts +55 -0
  64. package/test-api.ts +21 -0
  65. package/tsconfig.json +16 -0
  66. package/CHANGELOG.md +0 -107
  67. 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/zalouser";
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 getZalouserRuntime().state.resolveStateDir(env, os.homedir);
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.trim().toLowerCase();
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 as User;
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(String(value ?? ""), 10);
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(String(rawTs ?? ""), 10);
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?.toLowerCase();
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?.trim() || undefined };
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: String(friend.userId),
513
- displayName: friend.displayName || friend.zaloName || friend.username || String(friend.userId),
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: String(groupId),
525
- name: group.name?.trim() || String(groupId),
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 (!fs.existsSync(filePath)) {
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
- return {
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 touchCredentials(profile: string): void {
561
- const existing = readCredentials(profile);
562
- if (!existing) {
563
- return;
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
- const next: StoredZaloCredentials = {
566
- ...existing,
567
- lastUsedAt: new Date().toISOString(),
568
- };
569
- const dir = resolveCredentialsDir();
570
- fs.mkdirSync(dir, { recursive: true });
571
- fs.writeFileSync(resolveCredentialsPath(profile), JSON.stringify(next, null, 2), "utf-8");
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
- fs.writeFileSync(resolveCredentialsPath(profile), JSON.stringify(next, null, 2), "utf-8");
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 \"${profile}\"`);
802
+ throw new Error(`No saved Zalo session for profile "${profile}"`);
622
803
  }
623
- const zalo = new 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 \"${profile}\"`,
816
+ `Timed out restoring Zalo session for profile "${profile}"`,
636
817
  );
637
818
  apiByProfile.set(profile, api);
638
- touchCredentials(profile);
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 as Record<string, unknown>;
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
- export function zalouserSessionExists(profileInput?: string | null): boolean {
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
- const api = await ensureApi(profile, 12_000);
842
- await withTimeout(api.fetchAccountInfo(), 12_000, "Timed out checking Zalo session");
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
- const api = await ensureApi(profile);
853
- const info = await api.fetchAccountInfo();
854
- const user = normalizeAccountInfoUser(info);
855
- if (!user?.userId) {
856
- return null;
857
- }
858
- return {
859
- userId: String(user.userId),
860
- displayName: user.displayName || user.zaloName || String(user.userId),
861
- avatar: user.avatar || undefined,
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
- const api = await ensureApi(profile);
868
- const friends = await api.getAllFriends();
869
- return friends.map(mapFriend);
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?.trim().toLowerCase();
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.toLowerCase();
884
- const name = friend.displayName.toLowerCase();
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
- .sort((a, b) => Number(b.exact) - Number(a.exact));
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
- const api = await ensureApi(profile);
897
- const allGroups = await api.getAllGroups();
898
- const ids = Object.keys(allGroups.gridVerMap ?? {});
899
- if (ids.length === 0) {
900
- return [];
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
- rows.push(mapGroup(id, info as GroupInfo & Record<string, unknown>));
911
- }
912
- return rows;
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?.trim().toLowerCase();
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.toLowerCase();
926
- const name = group.name.toLowerCase();
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
- const api = await ensureApi(profile);
937
-
938
- const infoResponse = await api.getGroupInfo(groupId);
939
- const groupInfo = infoResponse.gridInfoMap?.[groupId] as
940
- | (GroupInfo & { memVerList?: unknown })
941
- | undefined;
942
- if (!groupInfo) {
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
- const uniqueIds = Array.from(
967
- new Set<string>([...memberIds, ...memVerIds, ...currentById.keys()]),
968
- );
969
-
970
- const profileMap = new Map<string, { displayName?: string; avatar?: string }>();
971
- if (uniqueIds.length > 0) {
972
- const profiles = await api.getGroupMembersInfo(uniqueIds);
973
- const profileEntries = profiles.profiles as Record<
974
- string,
975
- {
976
- id?: string;
977
- displayName?: string;
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
- profileMap.set(id, {
988
- displayName: profileValue.displayName?.trim() || profileValue.zaloName?.trim() || undefined,
989
- avatar: profileValue.avatar || undefined,
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
- return uniqueIds.map((id) => ({
995
- userId: id,
996
- displayName: profileMap.get(id)?.displayName || currentById.get(id)?.displayName || id,
997
- avatar: profileMap.get(id)?.avatar || currentById.get(id)?.avatar,
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
- const api = await ensureApi(profile);
1016
- const response = await api.getGroupInfo(normalizedGroupId);
1017
- const groupInfo = response.gridInfoMap?.[normalizedGroupId] as
1018
- | (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] })
1019
- | undefined;
1020
- const context: ZaloGroupContext = {
1021
- groupId: normalizedGroupId,
1022
- name: groupInfo?.name?.trim() || undefined,
1023
- members: extractGroupMembersFromInfo(groupInfo),
1024
- };
1025
- writeCachedGroupContext(profile, context);
1026
- return context;
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
- const api = await ensureApi(profile);
1041
- const type = options.isGroup ? ThreadType.Group : ThreadType.User;
1249
+ return await withZaloApi(
1250
+ profile,
1251
+ async (api) => {
1252
+ const type = options.isGroup ? ThreadType.Group : ThreadType.User;
1042
1253
 
1043
- try {
1044
- if (options.mediaUrl?.trim()) {
1045
- const media = await loadOutboundMediaFromUrl(options.mediaUrl.trim(), {
1046
- mediaLocalRoots: options.mediaLocalRoots,
1047
- });
1048
- const fileName = resolveMediaFileName({
1049
- mediaUrl: options.mediaUrl,
1050
- fileName: media.fileName,
1051
- contentType: media.contentType,
1052
- kind: media.kind,
1053
- });
1054
- const payloadText = (text || options.caption || "").slice(0, 2000);
1055
- const textStyles = clampTextStyles(payloadText, options.textStyles);
1056
-
1057
- if (media.kind === "audio") {
1058
- let textMessageId: string | undefined;
1059
- if (payloadText) {
1060
- const textResponse = await api.sendMessage(
1061
- textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
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
- textMessageId = extractSendMessageId(textResponse);
1323
+ return { ok: true, messageId: extractSendMessageId(response) };
1066
1324
  }
1067
1325
 
1068
- const attachmentFileName = fileName.includes(".") ? fileName : `${fileName}.bin`;
1069
- const uploaded = await api.uploadAttachment(
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
- const voiceAsset = resolveUploadedVoiceAsset(uploaded);
1083
- if (!voiceAsset) {
1084
- throw new Error("Failed to resolve uploaded audio URL for voice message");
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
- const response = await api.sendMessage(
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
- const api = await ensureApi(profile);
1137
- const type = options.isGroup ? ThreadType.Group : ThreadType.User;
1138
- if ("sendTypingEvent" in api && typeof api.sendTypingEvent === "function") {
1139
- await (api as API & ApiTypingCapability).sendTypingEvent(trimmedThreadId, type);
1140
- return;
1141
- }
1142
- throw new Error("Zalo typing indicator is not supported by current API session");
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
- const api = await ensureApi(profile);
1186
- const type = params.isGroup ? ThreadType.Group : ThreadType.User;
1187
- const icon = params.remove
1188
- ? { rType: -1, source: 6, icon: "" }
1189
- : normalizeZaloReactionIcon(params.emoji);
1190
- await api.addReaction(icon, {
1191
- data: { msgId, cliMsgId },
1192
- threadId,
1193
- type,
1194
- });
1195
- return { ok: true };
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
- const api = await ensureApi(profile);
1209
- const type = params.isGroup ? ThreadType.Group : ThreadType.User;
1210
- await api.sendDeliveredEvent(params.isSeen === true, params.message, type);
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
- const api = await ensureApi(profile);
1220
- const type = params.isGroup ? ThreadType.Group : ThreadType.User;
1221
- await api.sendSeenEvent(params.message, type);
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
- const api = await ensureApi(profile);
1241
- const type = options.isGroup ? ThreadType.Group : ThreadType.User;
1242
- const response = await api.sendLink(
1243
- { link: trimmedUrl, msg: options.caption },
1244
- trimmedThreadId,
1245
- type,
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 = new Zalo({ logging: false, selfListen: false });
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
- writeCredentials(profile, capturedCredentials);
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 \"${profile}\" (account \"${existing.accountId}\")`,
1734
+ `Zalo listener already running for profile "${profile}" (account "${existing.accountId}")`,
1507
1735
  );
1508
1736
  }
1509
1737
 
1510
- const api = await ensureApi(profile);
1511
- const ownUserId = await resolveOwnUserId(api);
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.trim().toLowerCase();
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.toLowerCase()) ?? [];
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.trim().toLowerCase();
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.toLowerCase()) ?? [];
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
- }