@tuturuuu/ai 0.1.0 → 0.2.0

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.
@@ -17,7 +17,13 @@ import {
17
17
  import {
18
18
  type API,
19
19
  type Credentials,
20
+ type FriendEvent,
21
+ FriendEventType,
22
+ GroupMessage,
23
+ type LoginQRCallbackEvent,
24
+ LoginQRCallbackEventType,
20
25
  ThreadType,
26
+ UserMessage,
21
27
  Zalo,
22
28
  type Message as ZcaMessage,
23
29
  } from 'zca-js';
@@ -41,6 +47,63 @@ export interface ZaloPersonalStatus {
41
47
  startedAt: string | null;
42
48
  }
43
49
 
50
+ export const ZALO_PERSONAL_QR_TTL_MS = 100_000;
51
+
52
+ export type ZaloPersonalQrLoginEvent =
53
+ | {
54
+ actions: ZaloPersonalQrLoginActions;
55
+ expiresAt: string;
56
+ qrImageDataUrl: string;
57
+ type: 'qr_generated';
58
+ }
59
+ | {
60
+ actions: ZaloPersonalQrLoginActions;
61
+ type: 'qr_expired';
62
+ }
63
+ | {
64
+ actions: ZaloPersonalQrLoginActions;
65
+ scannedProfile: ZaloPersonalQrScannedProfile;
66
+ type: 'qr_scanned';
67
+ }
68
+ | {
69
+ actions: ZaloPersonalQrLoginActions;
70
+ type: 'qr_declined';
71
+ }
72
+ | {
73
+ type: 'credentials_ready';
74
+ }
75
+ | {
76
+ ownId: string | null;
77
+ type: 'authenticated';
78
+ };
79
+
80
+ export interface ZaloPersonalQrLoginActions {
81
+ abort: () => unknown;
82
+ retry: () => unknown;
83
+ }
84
+
85
+ export interface ZaloPersonalQrScannedProfile {
86
+ avatar: string | null;
87
+ displayName: string | null;
88
+ }
89
+
90
+ export interface ZaloPersonalQrLoginCredentials {
91
+ cookieJson: string;
92
+ imei: string;
93
+ userAgent: string;
94
+ }
95
+
96
+ export interface ZaloPersonalQrLoginResult {
97
+ api: API;
98
+ credentials: ZaloPersonalQrLoginCredentials;
99
+ ownId: string | null;
100
+ }
101
+
102
+ export interface ZaloPersonalQrLoginOptions {
103
+ language?: string;
104
+ userAgent?: string;
105
+ }
106
+
44
107
  export interface ZaloPersonalThreadRef {
45
108
  externalThreadId: string;
46
109
  threadType: ThreadType;
@@ -57,7 +120,74 @@ export interface ZaloPersonalSentRaw {
57
120
  ts: number;
58
121
  }
59
122
 
60
- export type ZaloPersonalRawMessage = ZcaMessage | ZaloPersonalSentRaw;
123
+ export interface ZaloPersonalFriendRequestRaw {
124
+ externalThreadId: string;
125
+ id: string;
126
+ kind: 'friend_request';
127
+ original: FriendEvent;
128
+ senderId: string;
129
+ text: string;
130
+ threadId: string;
131
+ ts: number;
132
+ }
133
+
134
+ export type ZaloPersonalRawMessage =
135
+ | ZcaMessage
136
+ | ZaloPersonalFriendRequestRaw
137
+ | ZaloPersonalSentRaw;
138
+
139
+ export interface ZaloPersonalHistorySyncOptions {
140
+ includeGroups?: boolean;
141
+ includeListenerBackfill?: boolean;
142
+ includeUsers?: boolean;
143
+ maxGroups?: number;
144
+ maxPagesPerType?: number;
145
+ messagesPerGroup?: number;
146
+ pageTimeoutMs?: number;
147
+ }
148
+
149
+ export interface ZaloPersonalHistorySyncResult {
150
+ exhausted: boolean;
151
+ failedGroupHistories: number;
152
+ groupMessages: number;
153
+ groupsScanned: number;
154
+ messages: Message<ZaloPersonalRawMessage>[];
155
+ pageCount: number;
156
+ threads: ThreadInfo[];
157
+ timedOut: boolean;
158
+ userMessages: number;
159
+ }
160
+
161
+ export interface ZaloPersonalPhoneSyncOptions {
162
+ fromSeqId?: number;
163
+ maxPulls?: number;
164
+ minSeqId?: number;
165
+ pullDelayMs?: number;
166
+ tempKey?: string;
167
+ useListenerWakeup?: boolean;
168
+ }
169
+
170
+ export type ZaloPersonalPhoneSyncStatus =
171
+ | 'completed'
172
+ | 'completed_no_payload'
173
+ | 'failed'
174
+ | 'partial'
175
+ | 'waiting_for_phone';
176
+
177
+ export interface ZaloPersonalPhoneSyncResult {
178
+ approvalRequested: boolean;
179
+ cleaned: boolean;
180
+ error: string | null;
181
+ groupMessages: number;
182
+ messages: Message<ZaloPersonalRawMessage>[];
183
+ pullAttempts: number;
184
+ requestAccepted: boolean;
185
+ requestHttpError: string | null;
186
+ requestViaHttp: boolean;
187
+ requestViaWebSocket: boolean;
188
+ status: ZaloPersonalPhoneSyncStatus;
189
+ userMessages: number;
190
+ }
61
191
 
62
192
  export type ZaloPersonalAdapter = Adapter<
63
193
  ZaloPersonalThreadRef,
@@ -66,10 +196,82 @@ export type ZaloPersonalAdapter = Adapter<
66
196
  getPersonalStatus(): ZaloPersonalStatus;
67
197
  startPersonalListener(): Promise<ZaloPersonalStatus>;
68
198
  stopPersonalListener(): Promise<ZaloPersonalStatus>;
199
+ syncPersonalHistory(
200
+ options?: ZaloPersonalHistorySyncOptions
201
+ ): Promise<ZaloPersonalHistorySyncResult>;
202
+ syncPersonalPhoneHistory(
203
+ options?: ZaloPersonalPhoneSyncOptions
204
+ ): Promise<ZaloPersonalPhoneSyncResult>;
69
205
  validateLogin(): Promise<ZaloPersonalStatus>;
70
206
  };
71
207
 
72
208
  const THREAD_ID_PREFIX = 'zalo-personal';
209
+ const DEFAULT_HISTORY_MAX_GROUPS = 1000;
210
+ const DEFAULT_HISTORY_MAX_PAGES_PER_TYPE = 1000;
211
+ const DEFAULT_HISTORY_MESSAGES_PER_GROUP = 200;
212
+ const DEFAULT_HISTORY_PAGE_TIMEOUT_MS = 8000;
213
+ const HISTORY_CONNECT_TIMEOUT_MS = 5000;
214
+ const DEFAULT_PHONE_SYNC_MAX_PULLS = 90;
215
+ const DEFAULT_PHONE_SYNC_PULL_DELAY_MS = 1000;
216
+ const PHONE_SYNC_PC_NAME_MAX_LENGTH = 80;
217
+ const ZALO_LISTENER_SOCKET_OPEN_STATE = 1;
218
+ const ZALO_PHONE_SYNC_REQUEST_CMD = 590;
219
+ const ZALO_PHONE_SYNC_WAKEUP_CMD = 592;
220
+ const PHONE_SYNC_TRANSFER_API_MARKER =
221
+ '__tuturuuuZaloPersonalTransferApisAttached';
222
+
223
+ type ZaloPersonalTransferApi = API & {
224
+ [PHONE_SYNC_TRANSFER_API_MARKER]?: true;
225
+ tuturuuuCancelMobileMessages?: (
226
+ props: ZaloPersonalPhoneSyncPublicKeyProps
227
+ ) => Promise<unknown>;
228
+ tuturuuuCleanMobileSync?: (
229
+ props: ZaloPersonalPhoneSyncPublicKeyProps
230
+ ) => Promise<unknown>;
231
+ tuturuuuGetBackupMsgInfo?: (
232
+ props?: ZaloPersonalPhoneSyncRetryProps
233
+ ) => Promise<unknown>;
234
+ tuturuuuGetCrossDb?: (
235
+ props: ZaloPersonalPhoneSyncCrossDbProps
236
+ ) => Promise<unknown>;
237
+ tuturuuuPullMobileMessages?: (
238
+ props: ZaloPersonalPhoneSyncPullProps
239
+ ) => Promise<unknown>;
240
+ tuturuuuRequestPhoneSync?: (
241
+ props: ZaloPersonalPhoneSyncRequestProps
242
+ ) => Promise<unknown>;
243
+ };
244
+
245
+ interface ZaloPersonalThreadProfile {
246
+ avatarUrl: string | null;
247
+ title: string | null;
248
+ }
249
+
250
+ interface ZaloPersonalPhoneSyncCrossDbProps {
251
+ retry?: number;
252
+ syncSession: string;
253
+ }
254
+
255
+ interface ZaloPersonalPhoneSyncPublicKeyProps {
256
+ publicKey: string;
257
+ }
258
+
259
+ interface ZaloPersonalPhoneSyncPullProps {
260
+ fromSeqId: number;
261
+ isRetry: number;
262
+ minSeqId: number;
263
+ publicKey: string;
264
+ tempKey: string;
265
+ }
266
+
267
+ interface ZaloPersonalPhoneSyncRequestProps {
268
+ data: Record<string, unknown>;
269
+ reqId: string;
270
+ }
271
+
272
+ interface ZaloPersonalPhoneSyncRetryProps {
273
+ retry?: number;
274
+ }
73
275
 
74
276
  export function parseZaloPersonalCookieJson(
75
277
  cookieJson: string
@@ -87,6 +289,63 @@ export function parseZaloPersonalCookieJson(
87
289
  throw new Error('zalo_personal_cookie_json_invalid');
88
290
  }
89
291
 
292
+ export async function loginZaloPersonalWithQr(
293
+ options: ZaloPersonalQrLoginOptions = {},
294
+ onEvent?: (event: ZaloPersonalQrLoginEvent) => void | Promise<void>
295
+ ): Promise<ZaloPersonalQrLoginResult> {
296
+ let credentials: ZaloPersonalQrLoginCredentials | null = null;
297
+ let rejectTerminal: ((error: Error) => void) | null = null;
298
+ const terminal = new Promise<never>((_, reject) => {
299
+ rejectTerminal = reject;
300
+ });
301
+
302
+ const zalo = new Zalo();
303
+ const apiPromise = zalo.loginQR(
304
+ {
305
+ language: options.language,
306
+ userAgent: options.userAgent,
307
+ },
308
+ (event) => {
309
+ if (event.type === LoginQRCallbackEventType.GotLoginInfo) {
310
+ credentials = {
311
+ cookieJson: JSON.stringify(event.data.cookie),
312
+ imei: event.data.imei,
313
+ userAgent: event.data.userAgent,
314
+ };
315
+ }
316
+
317
+ handleQrLoginEvent(event, (loginEvent) => {
318
+ if (loginEvent.type === 'qr_declined') {
319
+ rejectTerminal?.(new Error('zalo_personal_qr_declined'));
320
+ loginEvent.actions.abort();
321
+ } else if (loginEvent.type === 'qr_expired') {
322
+ rejectTerminal?.(new Error('zalo_personal_qr_expired'));
323
+ }
324
+
325
+ void Promise.resolve(onEvent?.(loginEvent)).catch(() => undefined);
326
+ });
327
+ }
328
+ );
329
+
330
+ void apiPromise.catch(() => undefined);
331
+ const api = await Promise.race([apiPromise, terminal]);
332
+
333
+ if (!credentials) {
334
+ throw new Error('zalo_personal_qr_credentials_missing');
335
+ }
336
+
337
+ const ownId = api.getOwnId() || null;
338
+ await Promise.resolve(onEvent?.({ ownId, type: 'authenticated' })).catch(
339
+ () => undefined
340
+ );
341
+
342
+ return {
343
+ api,
344
+ credentials,
345
+ ownId,
346
+ };
347
+ }
348
+
90
349
  export function createZaloPersonalAdapter(
91
350
  config: ZaloPersonalAdapterConfig
92
351
  ): ZaloPersonalAdapter {
@@ -101,6 +360,8 @@ export function createZaloPersonalAdapter(
101
360
  running: false,
102
361
  startedAt: null,
103
362
  };
363
+ const groupProfileCache = new Map<string, ZaloPersonalThreadProfile | null>();
364
+ const userProfileCache = new Map<string, ZaloPersonalThreadProfile | null>();
104
365
 
105
366
  function setStatus(update: Partial<ZaloPersonalStatus>) {
106
367
  status = { ...status, ...update };
@@ -144,6 +405,27 @@ export function createZaloPersonalAdapter(
144
405
  });
145
406
  }
146
407
 
408
+ function toThreadInfo(
409
+ ref: ZaloPersonalThreadRef,
410
+ profile: ZaloPersonalThreadProfile | null
411
+ ): ThreadInfo {
412
+ const threadType = ref.threadType === ThreadType.Group ? 'group' : 'user';
413
+
414
+ return {
415
+ channelId: config.channelId,
416
+ channelName: config.displayName,
417
+ id: encodeThreadId(ref),
418
+ isDM: ref.threadType === ThreadType.User,
419
+ metadata: {
420
+ accountMode: 'personal',
421
+ authorAvatarUrl: profile?.avatarUrl ?? null,
422
+ externalThreadId: ref.externalThreadId,
423
+ threadTitle: profile?.title ?? null,
424
+ threadType,
425
+ },
426
+ };
427
+ }
428
+
147
429
  async function connect() {
148
430
  if (api) return api;
149
431
 
@@ -210,6 +492,9 @@ export function createZaloPersonalAdapter(
210
492
  apiInstance.listener.on('message', (message) => {
211
493
  void handleIncomingMessage(message);
212
494
  });
495
+ apiInstance.listener.on('friend_event', (event) => {
496
+ void handleFriendEvent(event);
497
+ });
213
498
 
214
499
  listenersAttached = true;
215
500
  }
@@ -233,6 +518,372 @@ export function createZaloPersonalAdapter(
233
518
  });
234
519
  }
235
520
 
521
+ async function handleFriendEvent(event: FriendEvent) {
522
+ if (
523
+ event.type !== FriendEventType.REQUEST ||
524
+ event.isSelf ||
525
+ !isRecord(event.data) ||
526
+ typeof event.data.message !== 'string' ||
527
+ !event.data.message.trim() ||
528
+ typeof event.data.fromUid !== 'string' ||
529
+ !event.data.fromUid.trim()
530
+ ) {
531
+ return;
532
+ }
533
+
534
+ const senderId = event.data.fromUid.trim();
535
+ const threadId = encodeThreadId({
536
+ externalThreadId: senderId,
537
+ threadType: ThreadType.User,
538
+ });
539
+ const raw: ZaloPersonalFriendRequestRaw = {
540
+ externalThreadId: senderId,
541
+ id: `friend-request:${senderId}:${Date.now()}`,
542
+ kind: 'friend_request',
543
+ original: event,
544
+ senderId,
545
+ text: event.data.message.trim(),
546
+ threadId,
547
+ ts: Date.now(),
548
+ };
549
+ const sdkMessage = adapter.parseMessage(raw);
550
+ setStatus({ lastEventAt: new Date(raw.ts).toISOString() });
551
+
552
+ await chat?.processMessage(adapter, threadId, sdkMessage, {
553
+ waitUntil: (task) => {
554
+ void task.catch((error) => {
555
+ setStatus({
556
+ lastError: error instanceof Error ? error.message : String(error),
557
+ });
558
+ });
559
+ },
560
+ });
561
+ }
562
+
563
+ async function waitForListenerConnection(apiInstance: API) {
564
+ if (isZaloListenerSocketOpen(apiInstance.listener)) return;
565
+
566
+ await new Promise<void>((resolve) => {
567
+ let settled = false;
568
+ const cleanup = () => {
569
+ clearTimeout(timer);
570
+ removeZaloListener(
571
+ apiInstance.listener,
572
+ 'connected',
573
+ onConnected as (...args: unknown[]) => void
574
+ );
575
+ };
576
+ const finish = () => {
577
+ if (settled) return;
578
+ settled = true;
579
+ cleanup();
580
+ resolve();
581
+ };
582
+ const onConnected = () => {
583
+ if (isZaloListenerSocketOpen(apiInstance.listener)) {
584
+ finish();
585
+ }
586
+ };
587
+ const timer = setTimeout(finish, HISTORY_CONNECT_TIMEOUT_MS);
588
+
589
+ apiInstance.listener.on('connected', onConnected);
590
+ void Promise.resolve().then(() => {
591
+ if (isZaloListenerSocketOpen(apiInstance.listener)) {
592
+ finish();
593
+ }
594
+ });
595
+ });
596
+ }
597
+
598
+ async function requestOldMessagePage({
599
+ apiInstance,
600
+ lastMsgId,
601
+ pageTimeoutMs,
602
+ threadType,
603
+ }: {
604
+ apiInstance: API;
605
+ lastMsgId: string | null;
606
+ pageTimeoutMs: number;
607
+ threadType: ThreadType;
608
+ }): Promise<{
609
+ messages: ZcaMessage[];
610
+ timedOut: boolean;
611
+ }> {
612
+ return await new Promise((resolve) => {
613
+ let settled = false;
614
+ const cleanup = () => {
615
+ clearTimeout(timer);
616
+ removeZaloListener(
617
+ apiInstance.listener,
618
+ 'old_messages',
619
+ onMessages as (...args: unknown[]) => void
620
+ );
621
+ };
622
+ const finish = (messages: ZcaMessage[], timedOut: boolean) => {
623
+ if (settled) return;
624
+ settled = true;
625
+ cleanup();
626
+ resolve({ messages, timedOut });
627
+ };
628
+ const onMessages = (messages: ZcaMessage[], type: ThreadType) => {
629
+ if (type !== threadType) return;
630
+ finish(messages, false);
631
+ };
632
+ const timer = setTimeout(
633
+ () => finish([], true),
634
+ Math.max(1000, pageTimeoutMs)
635
+ );
636
+
637
+ apiInstance.listener.on('old_messages', onMessages);
638
+ apiInstance.listener.requestOldMessages(threadType, lastMsgId);
639
+ });
640
+ }
641
+
642
+ async function syncThreadTypeHistory({
643
+ apiInstance,
644
+ maxPages,
645
+ pageTimeoutMs,
646
+ threadType,
647
+ }: {
648
+ apiInstance: API;
649
+ maxPages: number;
650
+ pageTimeoutMs: number;
651
+ threadType: ThreadType;
652
+ }) {
653
+ const messages: Message<ZaloPersonalRawMessage>[] = [];
654
+ const seen = new Set<string>();
655
+ let exhausted = false;
656
+ let lastMsgId: string | null = null;
657
+ let pageCount = 0;
658
+ let timedOut = false;
659
+
660
+ for (let page = 0; page < maxPages; page += 1) {
661
+ const pageResult = await requestOldMessagePage({
662
+ apiInstance,
663
+ lastMsgId,
664
+ pageTimeoutMs,
665
+ threadType,
666
+ });
667
+ pageCount += 1;
668
+
669
+ if (pageResult.timedOut) {
670
+ timedOut = true;
671
+ break;
672
+ }
673
+
674
+ if (pageResult.messages.length === 0) {
675
+ exhausted = true;
676
+ break;
677
+ }
678
+
679
+ let newMessages = 0;
680
+
681
+ for (const raw of pageResult.messages) {
682
+ const id = zaloMessageUniqueKey(raw);
683
+ if (seen.has(id)) continue;
684
+
685
+ seen.add(id);
686
+ messages.push(adapter.parseMessage(raw));
687
+ newMessages += 1;
688
+ }
689
+
690
+ const nextLastMsgId = getOldestZaloMessageId(pageResult.messages);
691
+
692
+ if (!nextLastMsgId || nextLastMsgId === lastMsgId || newMessages === 0) {
693
+ exhausted = true;
694
+ break;
695
+ }
696
+
697
+ lastMsgId = nextLastMsgId;
698
+ }
699
+
700
+ return {
701
+ exhausted,
702
+ messages,
703
+ pageCount,
704
+ timedOut,
705
+ };
706
+ }
707
+
708
+ async function syncEnumeratedGroupHistory({
709
+ apiInstance,
710
+ maxGroups,
711
+ messagesPerGroup,
712
+ }: {
713
+ apiInstance: API;
714
+ maxGroups: number;
715
+ messagesPerGroup: number;
716
+ }) {
717
+ const messages: Message<ZaloPersonalRawMessage>[] = [];
718
+ const seen = new Set<string>();
719
+ const threads: ThreadInfo[] = [];
720
+ let failedGroupHistories = 0;
721
+ const groups = await apiInstance.getAllGroups().catch((error) => {
722
+ failedGroupHistories += 1;
723
+ setStatus({
724
+ lastError: error instanceof Error ? error.message : String(error),
725
+ });
726
+
727
+ return null;
728
+ });
729
+ if (!groups) {
730
+ return {
731
+ failedGroupHistories,
732
+ groupsScanned: 0,
733
+ messages,
734
+ threads,
735
+ };
736
+ }
737
+
738
+ const groupIds = await collectZaloGroupIds({
739
+ apiInstance,
740
+ groups,
741
+ maxGroups,
742
+ });
743
+
744
+ for (const groupId of groupIds) {
745
+ const profile = await getZaloGroupThreadProfile(apiInstance, groupId);
746
+ threads.push(
747
+ toThreadInfo(
748
+ {
749
+ externalThreadId: groupId,
750
+ threadType: ThreadType.Group,
751
+ },
752
+ profile
753
+ )
754
+ );
755
+
756
+ try {
757
+ const history = await apiInstance.getGroupChatHistory(
758
+ groupId,
759
+ messagesPerGroup
760
+ );
761
+
762
+ for (const raw of history.groupMsgs) {
763
+ const id = zaloMessageUniqueKey(raw);
764
+ if (seen.has(id)) continue;
765
+
766
+ seen.add(id);
767
+ messages.push(adapter.parseMessage(raw));
768
+ }
769
+ } catch (error) {
770
+ failedGroupHistories += 1;
771
+ setStatus({
772
+ lastError: error instanceof Error ? error.message : String(error),
773
+ });
774
+ }
775
+ }
776
+
777
+ return {
778
+ failedGroupHistories,
779
+ groupsScanned: groupIds.length,
780
+ messages,
781
+ threads,
782
+ };
783
+ }
784
+
785
+ async function collectZaloGroupIds({
786
+ apiInstance,
787
+ groups,
788
+ maxGroups,
789
+ }: {
790
+ apiInstance: API;
791
+ groups: { gridVerMap?: Record<string, unknown> };
792
+ maxGroups: number;
793
+ }) {
794
+ const groupIds = new Set(Object.keys(groups.gridVerMap ?? {}));
795
+
796
+ const hidden = await apiInstance.getHiddenConversations().catch(() => null);
797
+
798
+ for (const thread of hidden?.threads ?? []) {
799
+ if (thread.is_group === 1 && thread.thread_id) {
800
+ groupIds.add(thread.thread_id);
801
+ }
802
+ }
803
+
804
+ const pinned = await apiInstance.getPinConversations().catch(() => null);
805
+
806
+ for (const conversationId of pinned?.conversations ?? []) {
807
+ if (isLikelyZaloGroupId(conversationId)) {
808
+ groupIds.add(conversationId);
809
+ }
810
+ }
811
+
812
+ const archived = await apiInstance.getArchivedChatList().catch(() => null);
813
+
814
+ for (const groupId of extractZaloGroupIds(archived)) {
815
+ groupIds.add(groupId);
816
+ }
817
+
818
+ return [...groupIds].slice(0, maxGroups);
819
+ }
820
+
821
+ async function getZaloGroupThreadProfile(
822
+ apiInstance: API,
823
+ groupId: string
824
+ ): Promise<ZaloPersonalThreadProfile | null> {
825
+ if (groupProfileCache.has(groupId)) {
826
+ return groupProfileCache.get(groupId) ?? null;
827
+ }
828
+
829
+ const profile = await apiInstance
830
+ .getGroupInfo(groupId)
831
+ .then((result) => {
832
+ const normalizedGroupId = groupId.replace(/^g/u, '');
833
+ const info =
834
+ result.gridInfoMap?.[groupId] ??
835
+ result.gridInfoMap?.[normalizedGroupId];
836
+
837
+ if (!info) return null;
838
+
839
+ return {
840
+ avatarUrl: stringValue(info.fullAvt || info.avt) || null,
841
+ title: stringValue(info.name) || null,
842
+ };
843
+ })
844
+ .catch(() => null);
845
+
846
+ groupProfileCache.set(groupId, profile);
847
+
848
+ return profile;
849
+ }
850
+
851
+ async function getZaloUserThreadProfile(
852
+ apiInstance: API,
853
+ userId: string
854
+ ): Promise<ZaloPersonalThreadProfile | null> {
855
+ if (userProfileCache.has(userId)) {
856
+ return userProfileCache.get(userId) ?? null;
857
+ }
858
+
859
+ const profile = await apiInstance
860
+ .getUserInfo(userId)
861
+ .then((result) => {
862
+ const changedProfiles = Object.values(result.changed_profiles ?? {});
863
+ const info =
864
+ result.changed_profiles?.[userId] ??
865
+ result.changed_profiles?.[`${userId}_0`] ??
866
+ changedProfiles.find((candidate) => candidate.userId === userId) ??
867
+ null;
868
+
869
+ if (!info) return null;
870
+
871
+ return {
872
+ avatarUrl: stringValue(info.avatar) || null,
873
+ title:
874
+ stringValue(info.displayName) ||
875
+ stringValue(info.zaloName) ||
876
+ stringValue(info.username) ||
877
+ null,
878
+ };
879
+ })
880
+ .catch(() => null);
881
+
882
+ userProfileCache.set(userId, profile);
883
+
884
+ return profile;
885
+ }
886
+
236
887
  function unsupported(feature: string) {
237
888
  return new Error(`zalo_personal_${feature}_unsupported`);
238
889
  }
@@ -285,27 +936,32 @@ export function createZaloPersonalAdapter(
285
936
  return { messages };
286
937
  },
287
938
  fetchThread: async (threadId): Promise<ThreadInfo> => {
939
+ const apiInstance = await connect();
288
940
  const thread = decodeThreadId(threadId);
941
+ const profile =
942
+ thread.threadType === ThreadType.Group
943
+ ? await getZaloGroupThreadProfile(
944
+ apiInstance,
945
+ thread.externalThreadId
946
+ )
947
+ : await getZaloUserThreadProfile(
948
+ apiInstance,
949
+ thread.externalThreadId
950
+ );
951
+ return toThreadInfo(thread, profile);
952
+ },
953
+ getPersonalStatus: () => status,
954
+ getUser: async (userId): Promise<UserInfo | null> => {
955
+ const apiInstance = await connect();
956
+ const profile = await getZaloUserThreadProfile(apiInstance, userId);
289
957
 
290
958
  return {
291
- channelId: config.channelId,
292
- channelName: config.displayName,
293
- id: threadId,
294
- isDM: thread.threadType === ThreadType.User,
295
- metadata: {
296
- accountMode: 'personal',
297
- externalThreadId: thread.externalThreadId,
298
- threadType: thread.threadType === ThreadType.Group ? 'group' : 'user',
299
- },
959
+ fullName: profile?.title ?? userId,
960
+ isBot: false,
961
+ userId,
962
+ userName: profile?.title ?? userId,
300
963
  };
301
964
  },
302
- getPersonalStatus: () => status,
303
- getUser: async (userId): Promise<UserInfo | null> => ({
304
- fullName: userId,
305
- isBot: false,
306
- userId,
307
- userName: userId,
308
- }),
309
965
  handleWebhook: async () =>
310
966
  Response.json(
311
967
  { error: 'Personal Zalo channels use a listener, not webhooks.' },
@@ -345,6 +1001,28 @@ export function createZaloPersonalAdapter(
345
1001
  });
346
1002
  }
347
1003
 
1004
+ if (isFriendRequestRaw(raw)) {
1005
+ return new Message<ZaloPersonalRawMessage>({
1006
+ attachments: [],
1007
+ author: {
1008
+ fullName: raw.senderId,
1009
+ isBot: false,
1010
+ isMe: false,
1011
+ userId: raw.senderId,
1012
+ userName: raw.senderId,
1013
+ },
1014
+ formatted: parseMarkdown(raw.text),
1015
+ id: raw.id,
1016
+ metadata: {
1017
+ dateSent: new Date(raw.ts),
1018
+ edited: false,
1019
+ },
1020
+ raw,
1021
+ text: raw.text,
1022
+ threadId: raw.threadId,
1023
+ });
1024
+ }
1025
+
348
1026
  const text =
349
1027
  typeof raw.data.content === 'string'
350
1028
  ? raw.data.content
@@ -409,6 +1087,9 @@ export function createZaloPersonalAdapter(
409
1087
  startPersonalListener: async () => {
410
1088
  const apiInstance = await connect();
411
1089
  attachListeners(apiInstance);
1090
+ if (status.running && isZaloListenerSocketOpen(apiInstance.listener)) {
1091
+ return status;
1092
+ }
412
1093
  apiInstance.listener.start({ retryOnClose: true });
413
1094
 
414
1095
  return setStatus({
@@ -434,37 +1115,1014 @@ export function createZaloPersonalAdapter(
434
1115
  running: false,
435
1116
  });
436
1117
  },
437
- stream: async (
438
- threadId,
439
- textStream: AsyncIterable<string | StreamChunk>,
440
- _options?: StreamOptions
441
- ): Promise<RawMessage<ZaloPersonalRawMessage> | null> => {
442
- let text = '';
1118
+ syncPersonalHistory: async (options = {}) => {
1119
+ const apiInstance = await connect();
443
1120
 
444
- for await (const chunk of textStream) {
445
- if (typeof chunk === 'string') {
446
- text += chunk;
447
- } else if (chunk.type === 'markdown_text') {
448
- text += chunk.text;
449
- }
1121
+ const maxPages = Math.max(
1122
+ 1,
1123
+ options.maxPagesPerType ?? DEFAULT_HISTORY_MAX_PAGES_PER_TYPE
1124
+ );
1125
+ const pageTimeoutMs =
1126
+ options.pageTimeoutMs ?? DEFAULT_HISTORY_PAGE_TIMEOUT_MS;
1127
+ const includeUsers = options.includeUsers ?? true;
1128
+ const includeGroups = options.includeGroups ?? true;
1129
+ const includeListenerBackfill = options.includeListenerBackfill ?? true;
1130
+ const maxGroups = Math.max(
1131
+ 1,
1132
+ options.maxGroups ?? DEFAULT_HISTORY_MAX_GROUPS
1133
+ );
1134
+ const messagesPerGroup = Math.max(
1135
+ 1,
1136
+ options.messagesPerGroup ?? DEFAULT_HISTORY_MESSAGES_PER_GROUP
1137
+ );
1138
+
1139
+ if (includeListenerBackfill && (includeUsers || includeGroups)) {
1140
+ attachListeners(apiInstance);
1141
+ await adapter.startPersonalListener();
1142
+ await waitForListenerConnection(apiInstance);
450
1143
  }
451
1144
 
452
- return adapter.postMessage(threadId, text.trim() || 'Done.');
453
- },
454
- userName: config.displayName,
455
- validateLogin: async () => {
456
- const apiInstance = await connect();
1145
+ const userResult =
1146
+ includeUsers && includeListenerBackfill
1147
+ ? await syncThreadTypeHistory({
1148
+ apiInstance,
1149
+ maxPages,
1150
+ pageTimeoutMs,
1151
+ threadType: ThreadType.User,
1152
+ })
1153
+ : { exhausted: true, messages: [], pageCount: 0, timedOut: false };
1154
+ const groupResult =
1155
+ includeGroups && includeListenerBackfill
1156
+ ? await syncThreadTypeHistory({
1157
+ apiInstance,
1158
+ maxPages,
1159
+ pageTimeoutMs,
1160
+ threadType: ThreadType.Group,
1161
+ })
1162
+ : { exhausted: true, messages: [], pageCount: 0, timedOut: false };
1163
+ const enumeratedGroupResult = includeGroups
1164
+ ? await syncEnumeratedGroupHistory({
1165
+ apiInstance,
1166
+ maxGroups,
1167
+ messagesPerGroup,
1168
+ })
1169
+ : {
1170
+ failedGroupHistories: 0,
1171
+ groupsScanned: 0,
1172
+ messages: [],
1173
+ threads: [],
1174
+ };
1175
+ const messages = dedupeSdkMessages([
1176
+ ...userResult.messages,
1177
+ ...groupResult.messages,
1178
+ ...enumeratedGroupResult.messages,
1179
+ ]).sort(
1180
+ (a, b) => a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime()
1181
+ );
1182
+ const userMessages = messages.filter(
1183
+ (message) =>
1184
+ decodeThreadId(message.threadId).threadType === ThreadType.User
1185
+ ).length;
1186
+ const groupMessages = messages.length - userMessages;
1187
+ const now = new Date().toISOString();
457
1188
 
458
- return setStatus({
459
- connected: true,
460
- lastError: null,
461
- ownId: apiInstance.getOwnId() || status.ownId,
1189
+ setStatus({
1190
+ lastError:
1191
+ userResult.timedOut || groupResult.timedOut
1192
+ ? 'zalo_personal_history_sync_timed_out'
1193
+ : enumeratedGroupResult.failedGroupHistories > 0
1194
+ ? 'zalo_personal_history_sync_partial'
1195
+ : null,
1196
+ lastEventAt: messages.at(-1)?.metadata.dateSent.toISOString() ?? now,
462
1197
  });
1198
+
1199
+ return {
1200
+ exhausted:
1201
+ userResult.exhausted &&
1202
+ groupResult.exhausted &&
1203
+ enumeratedGroupResult.failedGroupHistories === 0,
1204
+ failedGroupHistories: enumeratedGroupResult.failedGroupHistories,
1205
+ groupMessages,
1206
+ groupsScanned: enumeratedGroupResult.groupsScanned,
1207
+ messages,
1208
+ pageCount: userResult.pageCount + groupResult.pageCount,
1209
+ threads: dedupeThreads(enumeratedGroupResult.threads),
1210
+ timedOut: userResult.timedOut || groupResult.timedOut,
1211
+ userMessages,
1212
+ };
463
1213
  },
464
- };
1214
+ syncPersonalPhoneHistory: async (options = {}) => {
1215
+ const apiInstance = await connect();
1216
+ const transferApi = attachZaloPersonalTransferApis(apiInstance, config);
1217
+ const keyPair = await generatePhoneSyncKeyPair();
1218
+ const requestId = createPhoneSyncRequestId();
1219
+ const publicKey = keyPair.publicKeyBase64;
1220
+ const maxPulls = Math.max(
1221
+ 1,
1222
+ options.maxPulls ?? DEFAULT_PHONE_SYNC_MAX_PULLS
1223
+ );
1224
+ const pullDelayMs = Math.max(
1225
+ 250,
1226
+ options.pullDelayMs ?? DEFAULT_PHONE_SYNC_PULL_DELAY_MS
1227
+ );
1228
+ let fromSeqId = Math.max(0, options.fromSeqId ?? 0);
1229
+ let minSeqId = Math.max(0, options.minSeqId ?? 0);
1230
+ let tempKey = options.tempKey ?? '';
1231
+ let requestViaHttp = false;
1232
+ let requestViaWebSocket = false;
1233
+ let requestAccepted = false;
1234
+ let requestHttpError: string | null = null;
1235
+ let cleaned = false;
1236
+ let lastError: string | null = null;
1237
+ let pullAttempts = 0;
1238
+ const messages: Message<ZaloPersonalRawMessage>[] = [];
465
1239
 
466
- return adapter;
467
- }
1240
+ const syncPayload = buildPhoneSyncPayload({
1241
+ config,
1242
+ publicKey,
1243
+ requestId,
1244
+ });
1245
+ const useListenerWakeup = options.useListenerWakeup ?? true;
1246
+
1247
+ try {
1248
+ if (useListenerWakeup) {
1249
+ attachListeners(apiInstance);
1250
+ await adapter.startPersonalListener();
1251
+ await waitForListenerConnection(apiInstance);
1252
+ requestViaWebSocket = sendPhoneSyncApprovalRequest({
1253
+ apiInstance,
1254
+ payload: syncPayload,
1255
+ requestId,
1256
+ });
1257
+ }
1258
+
1259
+ try {
1260
+ await transferApi.tuturuuuRequestPhoneSync?.({
1261
+ data: syncPayload,
1262
+ reqId: requestId,
1263
+ });
1264
+ requestViaHttp = true;
1265
+ } catch (error) {
1266
+ requestHttpError = toSafePhoneSyncError(error);
1267
+
1268
+ if (!requestViaWebSocket) {
1269
+ throw error;
1270
+ }
1271
+ }
1272
+
1273
+ requestAccepted = requestViaWebSocket || requestViaHttp;
1274
+
1275
+ if (!requestAccepted) {
1276
+ throw new Error('zalo_personal_phone_sync_request_not_sent');
1277
+ }
1278
+
1279
+ for (let attempt = 0; attempt < maxPulls; attempt += 1) {
1280
+ if (attempt > 0) {
1281
+ await delay(pullDelayMs);
1282
+ }
1283
+
1284
+ try {
1285
+ const pullResponse = await transferApi.tuturuuuPullMobileMessages?.(
1286
+ {
1287
+ fromSeqId,
1288
+ isRetry: attempt > 0 ? 1 : 0,
1289
+ minSeqId,
1290
+ publicKey,
1291
+ tempKey,
1292
+ }
1293
+ );
1294
+ pullAttempts += 1;
1295
+ const decodedResponse = await decodePhoneSyncResponse(
1296
+ pullResponse,
1297
+ keyPair.privateKeyPem
1298
+ );
1299
+ const batchMessages = collectPhoneSyncMessages(
1300
+ decodedResponse,
1301
+ status.ownId ?? config.ownId ?? ''
1302
+ ).map((raw) => adapter.parseMessage(raw));
1303
+
1304
+ messages.push(...batchMessages);
1305
+ const nextSeqId = findPhoneSyncSequenceId(decodedResponse);
1306
+ const nextMinSeqId = findPhoneSyncMinSequenceId(decodedResponse);
1307
+ const nextTempKey = findPhoneSyncTempKey(decodedResponse);
1308
+
1309
+ if (typeof nextSeqId === 'number' && nextSeqId > fromSeqId) {
1310
+ fromSeqId = nextSeqId;
1311
+ }
1312
+
1313
+ if (typeof nextMinSeqId === 'number' && nextMinSeqId > minSeqId) {
1314
+ minSeqId = nextMinSeqId;
1315
+ }
1316
+
1317
+ if (nextTempKey !== null) {
1318
+ tempKey = nextTempKey;
1319
+ }
1320
+
1321
+ if (
1322
+ batchMessages.length > 0 &&
1323
+ !phoneSyncResponseHasMore(decodedResponse)
1324
+ ) {
1325
+ break;
1326
+ }
1327
+ } catch (error) {
1328
+ lastError = error instanceof Error ? error.message : String(error);
1329
+
1330
+ if (!isPhoneSyncApprovalPendingError(lastError)) {
1331
+ throw error;
1332
+ }
1333
+ }
1334
+ }
1335
+ } catch (error) {
1336
+ lastError = error instanceof Error ? error.message : String(error);
1337
+ } finally {
1338
+ if (requestAccepted && messages.length > 0) {
1339
+ cleaned =
1340
+ (await transferApi
1341
+ .tuturuuuCleanMobileSync?.({ publicKey })
1342
+ .then(() => true)
1343
+ .catch(() => false)) ?? false;
1344
+ } else if (requestAccepted && lastError && messages.length === 0) {
1345
+ await transferApi
1346
+ .tuturuuuCancelMobileMessages?.({ publicKey })
1347
+ .catch(() => undefined);
1348
+ }
1349
+ }
1350
+
1351
+ const dedupedMessages = dedupeSdkMessages(messages).sort(
1352
+ (a, b) => a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime()
1353
+ );
1354
+ const userMessages = dedupedMessages.filter(
1355
+ (message) =>
1356
+ decodeThreadId(message.threadId).threadType === ThreadType.User
1357
+ ).length;
1358
+ const groupMessages = dedupedMessages.length - userMessages;
1359
+ const statusValue = getPhoneSyncStatus({
1360
+ lastError,
1361
+ messages: dedupedMessages,
1362
+ pullAttempts,
1363
+ requestAccepted,
1364
+ });
1365
+
1366
+ setStatus({
1367
+ lastError:
1368
+ statusValue === 'completed'
1369
+ ? null
1370
+ : statusValue === 'completed_no_payload'
1371
+ ? 'zalo_personal_phone_sync_no_payload'
1372
+ : (lastError ?? 'zalo_personal_phone_sync_waiting_for_phone'),
1373
+ lastEventAt:
1374
+ dedupedMessages.at(-1)?.metadata.dateSent.toISOString() ??
1375
+ new Date().toISOString(),
1376
+ });
1377
+
1378
+ return {
1379
+ approvalRequested: requestAccepted,
1380
+ cleaned,
1381
+ error: statusValue === 'completed' ? null : lastError,
1382
+ groupMessages,
1383
+ messages: dedupedMessages,
1384
+ pullAttempts,
1385
+ requestAccepted,
1386
+ requestHttpError,
1387
+ requestViaHttp,
1388
+ requestViaWebSocket,
1389
+ status: statusValue,
1390
+ userMessages,
1391
+ };
1392
+ },
1393
+ stream: async (
1394
+ threadId,
1395
+ textStream: AsyncIterable<string | StreamChunk>,
1396
+ _options?: StreamOptions
1397
+ ): Promise<RawMessage<ZaloPersonalRawMessage> | null> => {
1398
+ let text = '';
1399
+
1400
+ for await (const chunk of textStream) {
1401
+ if (typeof chunk === 'string') {
1402
+ text += chunk;
1403
+ } else if (chunk.type === 'markdown_text') {
1404
+ text += chunk.text;
1405
+ }
1406
+ }
1407
+
1408
+ return adapter.postMessage(threadId, text.trim() || 'Done.');
1409
+ },
1410
+ userName: config.displayName,
1411
+ validateLogin: async () => {
1412
+ const apiInstance = await connect();
1413
+
1414
+ return setStatus({
1415
+ connected: true,
1416
+ lastError: null,
1417
+ ownId: apiInstance.getOwnId() || status.ownId,
1418
+ });
1419
+ },
1420
+ };
1421
+
1422
+ return adapter;
1423
+ }
1424
+
1425
+ function attachZaloPersonalTransferApis(
1426
+ apiInstance: API,
1427
+ config: ZaloPersonalAdapterConfig
1428
+ ) {
1429
+ const scoped = apiInstance as ZaloPersonalTransferApi;
1430
+
1431
+ if (scoped[PHONE_SYNC_TRANSFER_API_MARKER]) {
1432
+ return scoped;
1433
+ }
1434
+
1435
+ scoped.custom<unknown, ZaloPersonalPhoneSyncRequestProps>(
1436
+ 'tuturuuuRequestPhoneSync',
1437
+ async ({ props, utils }) =>
1438
+ zaloPersonalTransferGet({
1439
+ baseUrl: `${scoped.zpwServiceMap.file[0]}/api/transfer-sync-v2/request-sync`,
1440
+ params: {
1441
+ data: JSON.stringify(props.data),
1442
+ reqId: String(props.reqId),
1443
+ },
1444
+ utils,
1445
+ })
1446
+ );
1447
+ scoped.custom<unknown, ZaloPersonalPhoneSyncPullProps>(
1448
+ 'tuturuuuPullMobileMessages',
1449
+ async ({ props, utils }) =>
1450
+ zaloPersonalTransferGet({
1451
+ baseUrl: `${scoped.zpwServiceMap.file[0]}/api/message/pull_mobile_msg`,
1452
+ params: {
1453
+ from_seq_id: props.fromSeqId > 0 ? props.fromSeqId + 1 : 0,
1454
+ imei: config.imei,
1455
+ is_retry: props.isRetry,
1456
+ min_seq_id: props.minSeqId,
1457
+ pc_name: getPhoneSyncPcName(config.displayName),
1458
+ public_key: props.publicKey,
1459
+ temp_key: props.tempKey,
1460
+ },
1461
+ utils,
1462
+ })
1463
+ );
1464
+ scoped.custom<unknown, ZaloPersonalPhoneSyncPublicKeyProps>(
1465
+ 'tuturuuuCancelMobileMessages',
1466
+ async ({ props, utils }) =>
1467
+ zaloPersonalTransferGet({
1468
+ baseUrl: `${scoped.zpwServiceMap.file[0]}/api/message/cancel_pull_mobile_msg`,
1469
+ params: {
1470
+ imei: config.imei,
1471
+ pc_name: getPhoneSyncPcName(config.displayName),
1472
+ public_key: props.publicKey,
1473
+ },
1474
+ utils,
1475
+ })
1476
+ );
1477
+ scoped.custom<unknown, ZaloPersonalPhoneSyncPublicKeyProps>(
1478
+ 'tuturuuuCleanMobileSync',
1479
+ async ({ props, utils }) =>
1480
+ zaloPersonalTransferGet({
1481
+ baseUrl: `${scoped.zpwServiceMap.file[0]}/api/message/delete_snapshot_mobile_msg`,
1482
+ params: {
1483
+ imei: config.imei,
1484
+ public_key: props.publicKey,
1485
+ },
1486
+ utils,
1487
+ })
1488
+ );
1489
+ scoped.custom<unknown, ZaloPersonalPhoneSyncCrossDbProps>(
1490
+ 'tuturuuuGetCrossDb',
1491
+ async ({ props, utils }) =>
1492
+ zaloPersonalTransferGet({
1493
+ baseUrl: `${scoped.zpwServiceMap.file[0]}/api/message/get_crossdb`,
1494
+ headers:
1495
+ typeof props.retry === 'number'
1496
+ ? { nretry: String(props.retry) }
1497
+ : undefined,
1498
+ params: {
1499
+ pc_name: getPhoneSyncPcName(config.displayName),
1500
+ sync_session: props.syncSession,
1501
+ },
1502
+ utils,
1503
+ })
1504
+ );
1505
+ scoped.custom<unknown, ZaloPersonalPhoneSyncRetryProps | undefined>(
1506
+ 'tuturuuuGetBackupMsgInfo',
1507
+ async ({ props, utils }) => {
1508
+ const response = await utils.request(
1509
+ utils.makeURL(
1510
+ `${scoped.zpwServiceMap.file[0]}/api/message/get_backupmsginfo`
1511
+ ),
1512
+ {
1513
+ headers:
1514
+ typeof props?.retry === 'number'
1515
+ ? { nretry: String(props.retry) }
1516
+ : undefined,
1517
+ method: 'GET',
1518
+ }
1519
+ );
1520
+
1521
+ return utils.resolve(response, (result) =>
1522
+ normalizeZaloResponseData(result.data)
1523
+ );
1524
+ }
1525
+ );
1526
+
1527
+ Object.defineProperty(scoped, PHONE_SYNC_TRANSFER_API_MARKER, {
1528
+ enumerable: false,
1529
+ value: true,
1530
+ });
1531
+
1532
+ return scoped;
1533
+ }
1534
+
1535
+ async function zaloPersonalTransferGet({
1536
+ baseUrl,
1537
+ headers,
1538
+ params,
1539
+ utils,
1540
+ }: {
1541
+ baseUrl: string;
1542
+ headers?: Record<string, string>;
1543
+ params: Record<string, unknown>;
1544
+ utils: Parameters<Parameters<API['custom']>[1]>[0]['utils'];
1545
+ }) {
1546
+ const encryptedParams = utils.encodeAES(JSON.stringify(params));
1547
+
1548
+ if (!encryptedParams) {
1549
+ throw new Error('zalo_personal_phone_sync_encrypt_failed');
1550
+ }
1551
+
1552
+ const response = await utils.request(
1553
+ utils.makeURL(baseUrl, { params: encryptedParams }),
1554
+ {
1555
+ headers,
1556
+ method: 'GET',
1557
+ }
1558
+ );
1559
+
1560
+ return utils.resolve(response, (result) =>
1561
+ normalizeZaloResponseData(result.data)
1562
+ );
1563
+ }
1564
+
1565
+ async function generatePhoneSyncKeyPair() {
1566
+ const { generateKeyPairSync } = await import('node:crypto');
1567
+ const { privateKey, publicKey } = generateKeyPairSync('rsa', {
1568
+ modulusLength: 2048,
1569
+ privateKeyEncoding: {
1570
+ format: 'pem',
1571
+ type: 'pkcs8',
1572
+ },
1573
+ publicExponent: 0x10001,
1574
+ publicKeyEncoding: {
1575
+ format: 'der',
1576
+ type: 'spki',
1577
+ },
1578
+ });
1579
+
1580
+ return {
1581
+ privateKeyPem: privateKey,
1582
+ publicKeyBase64: Buffer.from(publicKey).toString('base64'),
1583
+ };
1584
+ }
1585
+
1586
+ function createPhoneSyncRequestId() {
1587
+ return `tuturuuu-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1588
+ }
1589
+
1590
+ function buildPhoneSyncPayload({
1591
+ config,
1592
+ publicKey,
1593
+ requestId,
1594
+ }: {
1595
+ config: ZaloPersonalAdapterConfig;
1596
+ publicKey: string;
1597
+ requestId: string;
1598
+ }) {
1599
+ return {
1600
+ app: 'tuturuuu-chat',
1601
+ imei: config.imei,
1602
+ pc_name: getPhoneSyncPcName(config.displayName),
1603
+ platform: 'web',
1604
+ public_key: publicKey,
1605
+ req_id: requestId,
1606
+ scopes: ['conversation', 'preview', 'message'],
1607
+ sync_states: ['sync-conversation', 'sync-preview', 'sync-other'],
1608
+ sync_version: 2,
1609
+ ts: Date.now(),
1610
+ };
1611
+ }
1612
+
1613
+ function getPhoneSyncPcName(displayName?: string) {
1614
+ const normalized = (displayName || 'Tuturuuu Chat')
1615
+ .replace(/\s+/g, ' ')
1616
+ .trim();
1617
+
1618
+ return normalized.slice(0, PHONE_SYNC_PC_NAME_MAX_LENGTH) || 'Tuturuuu Chat';
1619
+ }
1620
+
1621
+ function sendPhoneSyncApprovalRequest({
1622
+ apiInstance,
1623
+ payload,
1624
+ requestId,
1625
+ }: {
1626
+ apiInstance: API;
1627
+ payload: Record<string, unknown>;
1628
+ requestId: string;
1629
+ }) {
1630
+ const requestSent = sendPhoneSyncWebSocket(apiInstance, {
1631
+ cmd: ZALO_PHONE_SYNC_REQUEST_CMD,
1632
+ data: {
1633
+ data: payload,
1634
+ reqId: requestId,
1635
+ },
1636
+ subCmd: 0,
1637
+ version: 1,
1638
+ });
1639
+
1640
+ if (requestSent) {
1641
+ sendPhoneSyncMobileWakeup(apiInstance, payload);
1642
+ }
1643
+
1644
+ return requestSent;
1645
+ }
1646
+
1647
+ function sendPhoneSyncMobileWakeup(
1648
+ apiInstance: API,
1649
+ payload: Record<string, unknown>
1650
+ ) {
1651
+ sendPhoneSyncWebSocket(apiInstance, {
1652
+ cmd: ZALO_PHONE_SYNC_WAKEUP_CMD,
1653
+ data: {
1654
+ ...payload,
1655
+ reqId: createPhoneSyncRequestId(),
1656
+ },
1657
+ subCmd: 0,
1658
+ version: 1,
1659
+ });
1660
+ }
1661
+
1662
+ type PhoneSyncWebSocketPayload = {
1663
+ cmd: number;
1664
+ data: Record<string, unknown>;
1665
+ subCmd: number;
1666
+ version: number;
1667
+ };
1668
+
1669
+ function sendPhoneSyncWebSocket(
1670
+ apiInstance: API,
1671
+ payload: PhoneSyncWebSocketPayload
1672
+ ) {
1673
+ if (!isZaloListenerSocketOpen(apiInstance.listener)) return false;
1674
+
1675
+ const listenerWithSender = apiInstance.listener as API['listener'] & {
1676
+ sendWs?: (payload: PhoneSyncWebSocketPayload, requireId?: boolean) => void;
1677
+ };
1678
+
1679
+ try {
1680
+ listenerWithSender.sendWs?.(payload, false);
1681
+ return true;
1682
+ } catch {
1683
+ return false;
1684
+ }
1685
+ }
1686
+
1687
+ async function decodePhoneSyncResponse(
1688
+ value: unknown,
1689
+ privateKeyPem: string
1690
+ ): Promise<unknown> {
1691
+ const normalized = normalizeZaloResponseData(value);
1692
+ const encryptedPayload = findEncryptedPhoneSyncPayload(normalized);
1693
+
1694
+ if (!encryptedPayload) {
1695
+ return normalized;
1696
+ }
1697
+
1698
+ const decrypted = await decryptPhoneSyncPayload(
1699
+ encryptedPayload,
1700
+ privateKeyPem
1701
+ ).catch(() => null);
1702
+
1703
+ return decrypted === null ? normalized : normalizeZaloResponseData(decrypted);
1704
+ }
1705
+
1706
+ async function decryptPhoneSyncPayload(payload: string, privateKeyPem: string) {
1707
+ const { constants, privateDecrypt } = await import('node:crypto');
1708
+ const decrypted = privateDecrypt(
1709
+ {
1710
+ key: privateKeyPem,
1711
+ oaepHash: 'sha256',
1712
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
1713
+ },
1714
+ Buffer.from(payload, 'base64')
1715
+ );
1716
+
1717
+ return decrypted.toString('utf8');
1718
+ }
1719
+
1720
+ function normalizeZaloResponseData(value: unknown): unknown {
1721
+ if (typeof value !== 'string') {
1722
+ return value;
1723
+ }
1724
+
1725
+ const trimmed = value.trim();
1726
+
1727
+ if (!trimmed) {
1728
+ return value;
1729
+ }
1730
+
1731
+ try {
1732
+ return JSON.parse(trimmed);
1733
+ } catch {
1734
+ return value;
1735
+ }
1736
+ }
1737
+
1738
+ function collectPhoneSyncMessages(value: unknown, ownId: string): ZcaMessage[] {
1739
+ const messages: ZcaMessage[] = [];
1740
+ const seen = new Set<string>();
1741
+
1742
+ collectPhoneSyncMessagesRecursive({
1743
+ hint: null,
1744
+ messages,
1745
+ ownId,
1746
+ seen,
1747
+ value,
1748
+ });
1749
+
1750
+ return messages;
1751
+ }
1752
+
1753
+ function collectPhoneSyncMessagesRecursive({
1754
+ hint,
1755
+ messages,
1756
+ ownId,
1757
+ seen,
1758
+ value,
1759
+ }: {
1760
+ hint: ThreadType | null;
1761
+ messages: ZcaMessage[];
1762
+ ownId: string;
1763
+ seen: Set<string>;
1764
+ value: unknown;
1765
+ }) {
1766
+ const normalizedValue = normalizeZaloResponseData(value);
1767
+
1768
+ if (Array.isArray(normalizedValue)) {
1769
+ for (const item of normalizedValue) {
1770
+ collectPhoneSyncMessagesRecursive({
1771
+ hint,
1772
+ messages,
1773
+ ownId,
1774
+ seen,
1775
+ value: item,
1776
+ });
1777
+ }
1778
+
1779
+ return;
1780
+ }
1781
+
1782
+ if (!isRecord(normalizedValue)) {
1783
+ return;
1784
+ }
1785
+
1786
+ const directMessage = toPhoneSyncZcaMessage(normalizedValue, ownId, hint);
1787
+
1788
+ if (directMessage) {
1789
+ const key = zaloMessageUniqueKey(directMessage);
1790
+ if (!seen.has(key)) {
1791
+ seen.add(key);
1792
+ messages.push(directMessage);
1793
+ }
1794
+ }
1795
+
1796
+ for (const [key, child] of Object.entries(normalizedValue)) {
1797
+ collectPhoneSyncMessagesRecursive({
1798
+ hint: phoneSyncThreadHintFromKey(key) ?? hint,
1799
+ messages,
1800
+ ownId,
1801
+ seen,
1802
+ value: child,
1803
+ });
1804
+ }
1805
+ }
1806
+
1807
+ function toPhoneSyncZcaMessage(
1808
+ value: Record<string, unknown>,
1809
+ ownId: string,
1810
+ hint: ThreadType | null
1811
+ ): ZcaMessage | null {
1812
+ if (isRecord(value.data) && typeof value.threadId === 'string') {
1813
+ return value as unknown as ZcaMessage;
1814
+ }
1815
+
1816
+ const content = value.content ?? value.msg ?? value.message;
1817
+ const uidFrom = stringValue(value.uidFrom ?? value.fromUid ?? value.senderId);
1818
+ const idTo = stringValue(
1819
+ value.idTo ?? value.toUid ?? value.uidTo ?? value.toId ?? value.convId
1820
+ );
1821
+
1822
+ if (!uidFrom || !idTo || typeof content === 'undefined') {
1823
+ return null;
1824
+ }
1825
+
1826
+ const msgId = stringValue(value.msgId ?? value.globalMsgId ?? value.id);
1827
+ const cliMsgId = stringValue(
1828
+ value.cliMsgId ?? value.clientMsgId ?? value.cmi
1829
+ );
1830
+ const ts = stringValue(value.ts ?? value.sendDttm ?? value.time);
1831
+
1832
+ if (!msgId && !cliMsgId && !ts) {
1833
+ return null;
1834
+ }
1835
+
1836
+ const data = {
1837
+ ...value,
1838
+ cliMsgId: cliMsgId || msgId || ts || String(Date.now()),
1839
+ content,
1840
+ idTo,
1841
+ msgId: msgId || cliMsgId || ts || String(Date.now()),
1842
+ ts: ts || String(Date.now()),
1843
+ uidFrom,
1844
+ };
1845
+
1846
+ if (hint === ThreadType.Group || isLikelyZaloGroupId(idTo)) {
1847
+ return new GroupMessage(ownId, data as never) as ZcaMessage;
1848
+ }
1849
+
1850
+ return new UserMessage(ownId, data as never) as ZcaMessage;
1851
+ }
1852
+
1853
+ function phoneSyncThreadHintFromKey(key: string) {
1854
+ const normalized = key.toLowerCase();
1855
+
1856
+ if (normalized.includes('group')) {
1857
+ return ThreadType.Group;
1858
+ }
1859
+
1860
+ if (normalized === 'msgs' || normalized.includes('oneone')) {
1861
+ return ThreadType.User;
1862
+ }
1863
+
1864
+ return null;
1865
+ }
1866
+
1867
+ function phoneSyncResponseHasMore(value: unknown) {
1868
+ const more = findFirstRecordValue(value, [
1869
+ 'hasMore',
1870
+ 'has_more',
1871
+ 'more',
1872
+ 'needMore',
1873
+ 'need_more',
1874
+ ]);
1875
+
1876
+ if (typeof more === 'boolean') {
1877
+ return more;
1878
+ }
1879
+
1880
+ if (typeof more === 'number') {
1881
+ return more > 0;
1882
+ }
1883
+
1884
+ if (typeof more === 'string') {
1885
+ return more === '1' || more.toLowerCase() === 'true';
1886
+ }
1887
+
1888
+ return false;
1889
+ }
1890
+
1891
+ function findPhoneSyncSequenceId(value: unknown) {
1892
+ return findLargestNumericRecordValue(value, [
1893
+ 'lastSeqId',
1894
+ 'last_seq_id',
1895
+ 'maxSeqId',
1896
+ 'max_seq_id',
1897
+ 'seqId',
1898
+ 'seq_id',
1899
+ 'fromSeqId',
1900
+ 'from_seq_id',
1901
+ ]);
1902
+ }
1903
+
1904
+ function findPhoneSyncMinSequenceId(value: unknown) {
1905
+ return findLargestNumericRecordValue(value, ['minSeqId', 'min_seq_id']);
1906
+ }
1907
+
1908
+ function findPhoneSyncTempKey(value: unknown) {
1909
+ const tempKey = findFirstRecordValue(value, ['tempKey', 'temp_key']);
1910
+
1911
+ return typeof tempKey === 'string' ? tempKey : null;
1912
+ }
1913
+
1914
+ function findEncryptedPhoneSyncPayload(value: unknown): string | null {
1915
+ const payload = findFirstRecordValue(value, [
1916
+ 'ciphertext',
1917
+ 'cipher_text',
1918
+ 'encrypted',
1919
+ 'encryptedData',
1920
+ 'encrypted_data',
1921
+ 'encryptedMsg',
1922
+ 'encrypted_msg',
1923
+ ]);
1924
+
1925
+ return typeof payload === 'string' && payload.trim() ? payload.trim() : null;
1926
+ }
1927
+
1928
+ function findFirstRecordValue(value: unknown, keys: string[]): unknown {
1929
+ if (Array.isArray(value)) {
1930
+ for (const item of value) {
1931
+ const found = findFirstRecordValue(item, keys);
1932
+ if (typeof found !== 'undefined') return found;
1933
+ }
1934
+
1935
+ return undefined;
1936
+ }
1937
+
1938
+ if (!isRecord(value)) {
1939
+ return undefined;
1940
+ }
1941
+
1942
+ for (const key of keys) {
1943
+ if (key in value) {
1944
+ return value[key];
1945
+ }
1946
+ }
1947
+
1948
+ for (const child of Object.values(value)) {
1949
+ const found = findFirstRecordValue(child, keys);
1950
+ if (typeof found !== 'undefined') return found;
1951
+ }
1952
+
1953
+ return undefined;
1954
+ }
1955
+
1956
+ function findLargestNumericRecordValue(value: unknown, keys: string[]) {
1957
+ let largest: number | null = null;
1958
+
1959
+ visitRecordValues(value, (key, candidate) => {
1960
+ if (!keys.includes(key)) return;
1961
+ const numeric =
1962
+ typeof candidate === 'number'
1963
+ ? candidate
1964
+ : typeof candidate === 'string'
1965
+ ? Number.parseInt(candidate, 10)
1966
+ : Number.NaN;
1967
+
1968
+ if (Number.isFinite(numeric)) {
1969
+ largest = Math.max(largest ?? numeric, numeric);
1970
+ }
1971
+ });
1972
+
1973
+ return largest;
1974
+ }
1975
+
1976
+ function visitRecordValues(
1977
+ value: unknown,
1978
+ visitor: (key: string, value: unknown) => void
1979
+ ) {
1980
+ if (Array.isArray(value)) {
1981
+ for (const child of value) {
1982
+ visitRecordValues(child, visitor);
1983
+ }
1984
+
1985
+ return;
1986
+ }
1987
+
1988
+ if (!isRecord(value)) {
1989
+ return;
1990
+ }
1991
+
1992
+ for (const [key, child] of Object.entries(value)) {
1993
+ visitor(key, child);
1994
+ visitRecordValues(child, visitor);
1995
+ }
1996
+ }
1997
+
1998
+ function getPhoneSyncStatus({
1999
+ lastError,
2000
+ messages,
2001
+ pullAttempts,
2002
+ requestAccepted,
2003
+ }: {
2004
+ lastError: string | null;
2005
+ messages: Message<ZaloPersonalRawMessage>[];
2006
+ pullAttempts: number;
2007
+ requestAccepted: boolean;
2008
+ }): ZaloPersonalPhoneSyncStatus {
2009
+ if (messages.length > 0 && lastError) {
2010
+ return 'partial';
2011
+ }
2012
+
2013
+ if (messages.length > 0) {
2014
+ return 'completed';
2015
+ }
2016
+
2017
+ if (requestAccepted && pullAttempts > 0 && !lastError) {
2018
+ return 'completed_no_payload';
2019
+ }
2020
+
2021
+ if (requestAccepted && !lastError) {
2022
+ return 'waiting_for_phone';
2023
+ }
2024
+
2025
+ if (lastError && isPhoneSyncApprovalPendingError(lastError)) {
2026
+ return 'waiting_for_phone';
2027
+ }
2028
+
2029
+ return 'failed';
2030
+ }
2031
+
2032
+ function isPhoneSyncApprovalPendingError(message: string) {
2033
+ const normalized = message.toLowerCase();
2034
+
2035
+ return (
2036
+ normalized.includes('user_dont_confirm') ||
2037
+ normalized.includes('sync_request_timeout') ||
2038
+ normalized.includes('waiting_for_phone') ||
2039
+ normalized.includes('timeout')
2040
+ );
2041
+ }
2042
+
2043
+ function toSafePhoneSyncError(error: unknown) {
2044
+ const raw = error instanceof Error ? error.message : String(error);
2045
+
2046
+ return raw
2047
+ .replace(/[A-Za-z0-9+/=]{80,}/g, '[redacted]')
2048
+ .replace(/params=[^&\s]+/g, 'params=[redacted]')
2049
+ .slice(0, 240);
2050
+ }
2051
+
2052
+ function isLikelyZaloGroupId(value: string) {
2053
+ return value.startsWith('g') || value.startsWith('group');
2054
+ }
2055
+
2056
+ function extractZaloGroupIds(value: unknown) {
2057
+ const groupIds = new Set<string>();
2058
+
2059
+ collectZaloGroupIdsFromValue(value, groupIds);
2060
+
2061
+ return [...groupIds];
2062
+ }
2063
+
2064
+ function collectZaloGroupIdsFromValue(value: unknown, groupIds: Set<string>) {
2065
+ if (Array.isArray(value)) {
2066
+ for (const item of value) {
2067
+ collectZaloGroupIdsFromValue(item, groupIds);
2068
+ }
2069
+
2070
+ return;
2071
+ }
2072
+
2073
+ if (!isRecord(value)) {
2074
+ return;
2075
+ }
2076
+
2077
+ const isGroupRecord =
2078
+ value.is_group === 1 ||
2079
+ value.isGroup === true ||
2080
+ value.type === ThreadType.Group ||
2081
+ value.threadType === ThreadType.Group ||
2082
+ value.threadType === 'group';
2083
+ const candidateKeys = [
2084
+ 'conversationId',
2085
+ 'groupId',
2086
+ 'group_id',
2087
+ 'grid',
2088
+ 'id',
2089
+ 'idTo',
2090
+ 'threadId',
2091
+ 'thread_id',
2092
+ ];
2093
+
2094
+ for (const key of candidateKeys) {
2095
+ const candidate = stringValue(value[key]);
2096
+ const keySuggestsGroup = key.toLowerCase().includes('group');
2097
+
2098
+ if (
2099
+ candidate &&
2100
+ (isGroupRecord || keySuggestsGroup || isLikelyZaloGroupId(candidate))
2101
+ ) {
2102
+ groupIds.add(candidate);
2103
+ }
2104
+ }
2105
+
2106
+ for (const child of Object.values(value)) {
2107
+ collectZaloGroupIdsFromValue(child, groupIds);
2108
+ }
2109
+ }
2110
+
2111
+ function stringValue(value: unknown) {
2112
+ if (typeof value === 'string') {
2113
+ return value.trim();
2114
+ }
2115
+
2116
+ if (typeof value === 'number' && Number.isFinite(value)) {
2117
+ return String(value);
2118
+ }
2119
+
2120
+ return '';
2121
+ }
2122
+
2123
+ function delay(ms: number) {
2124
+ return new Promise((resolve) => setTimeout(resolve, ms));
2125
+ }
468
2126
 
469
2127
  function dateFromZaloTimestamp(value: string) {
470
2128
  const numeric = Number.parseInt(value, 10);
@@ -498,10 +2156,143 @@ function extractPostableText(message: AdapterPostableMessage) {
498
2156
  return String(message);
499
2157
  }
500
2158
 
2159
+ function dedupeSdkMessages(
2160
+ messages: Message<ZaloPersonalRawMessage>[]
2161
+ ): Message<ZaloPersonalRawMessage>[] {
2162
+ const seen = new Set<string>();
2163
+ const deduped: Message<ZaloPersonalRawMessage>[] = [];
2164
+
2165
+ for (const message of messages) {
2166
+ const key = `${message.threadId}:${message.id}`;
2167
+ if (seen.has(key)) continue;
2168
+
2169
+ seen.add(key);
2170
+ deduped.push(message);
2171
+ }
2172
+
2173
+ return deduped;
2174
+ }
2175
+
2176
+ function dedupeThreads(threads: ThreadInfo[]) {
2177
+ const seen = new Set<string>();
2178
+ const deduped: ThreadInfo[] = [];
2179
+
2180
+ for (const thread of threads) {
2181
+ if (seen.has(thread.id)) continue;
2182
+
2183
+ seen.add(thread.id);
2184
+ deduped.push(thread);
2185
+ }
2186
+
2187
+ return deduped;
2188
+ }
2189
+
501
2190
  function isRecord(value: unknown): value is Record<string, unknown> {
502
2191
  return typeof value === 'object' && value !== null;
503
2192
  }
504
2193
 
2194
+ function handleQrLoginEvent(
2195
+ event: LoginQRCallbackEvent,
2196
+ emit: (event: ZaloPersonalQrLoginEvent) => void
2197
+ ) {
2198
+ switch (event.type) {
2199
+ case LoginQRCallbackEventType.QRCodeGenerated:
2200
+ emit({
2201
+ actions: event.actions,
2202
+ expiresAt: new Date(Date.now() + ZALO_PERSONAL_QR_TTL_MS).toISOString(),
2203
+ qrImageDataUrl: toQrImageDataUrl(event.data.image),
2204
+ type: 'qr_generated',
2205
+ });
2206
+ break;
2207
+ case LoginQRCallbackEventType.QRCodeExpired:
2208
+ emit({
2209
+ actions: event.actions,
2210
+ type: 'qr_expired',
2211
+ });
2212
+ break;
2213
+ case LoginQRCallbackEventType.QRCodeScanned:
2214
+ emit({
2215
+ actions: event.actions,
2216
+ scannedProfile: {
2217
+ avatar: event.data.avatar || null,
2218
+ displayName: event.data.display_name || null,
2219
+ },
2220
+ type: 'qr_scanned',
2221
+ });
2222
+ break;
2223
+ case LoginQRCallbackEventType.QRCodeDeclined:
2224
+ emit({
2225
+ actions: event.actions,
2226
+ type: 'qr_declined',
2227
+ });
2228
+ break;
2229
+ case LoginQRCallbackEventType.GotLoginInfo:
2230
+ emit({ type: 'credentials_ready' });
2231
+ break;
2232
+ }
2233
+ }
2234
+
505
2235
  function isSentRaw(value: unknown): value is ZaloPersonalSentRaw {
506
2236
  return isRecord(value) && value.isSelf === true && 'response' in value;
507
2237
  }
2238
+
2239
+ function isFriendRequestRaw(
2240
+ value: unknown
2241
+ ): value is ZaloPersonalFriendRequestRaw {
2242
+ return isRecord(value) && value.kind === 'friend_request';
2243
+ }
2244
+
2245
+ function getOldestZaloMessageId(messages: ZcaMessage[]) {
2246
+ return messages
2247
+ .slice()
2248
+ .sort(
2249
+ (a, b) =>
2250
+ dateFromZaloTimestamp(a.data.ts).getTime() -
2251
+ dateFromZaloTimestamp(b.data.ts).getTime()
2252
+ )
2253
+ .at(0)?.data.msgId;
2254
+ }
2255
+
2256
+ function removeZaloListener(
2257
+ listener: API['listener'],
2258
+ event: string,
2259
+ handler: (...args: unknown[]) => void
2260
+ ) {
2261
+ const listenerWithOff = listener as typeof listener & {
2262
+ off?: (event: string, handler: (...args: unknown[]) => void) => void;
2263
+ removeListener?: (
2264
+ event: string,
2265
+ handler: (...args: unknown[]) => void
2266
+ ) => void;
2267
+ };
2268
+
2269
+ if (typeof listenerWithOff.off === 'function') {
2270
+ listenerWithOff.off(event, handler);
2271
+ } else {
2272
+ listenerWithOff.removeListener?.(event, handler);
2273
+ }
2274
+ }
2275
+
2276
+ function isZaloListenerSocketOpen(listener: API['listener']) {
2277
+ const listenerWithSocket = listener as unknown as {
2278
+ ws?: { readyState?: number } | null;
2279
+ };
2280
+
2281
+ return listenerWithSocket.ws?.readyState === ZALO_LISTENER_SOCKET_OPEN_STATE;
2282
+ }
2283
+
2284
+ function zaloMessageUniqueKey(message: ZcaMessage) {
2285
+ return [
2286
+ message.type,
2287
+ message.threadId,
2288
+ message.data.msgId || message.data.cliMsgId || message.data.ts,
2289
+ ].join(':');
2290
+ }
2291
+
2292
+ function toQrImageDataUrl(image: string) {
2293
+ const trimmed = image.trim();
2294
+
2295
+ return trimmed.startsWith('data:image/')
2296
+ ? trimmed
2297
+ : `data:image/png;base64,${trimmed}`;
2298
+ }