@tuturuuu/ai 0.0.12 → 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.
@@ -0,0 +1,2298 @@
1
+ import {
2
+ type Adapter,
3
+ type AdapterPostableMessage,
4
+ type ChatInstance,
5
+ type FetchOptions,
6
+ type FetchResult,
7
+ type FormattedContent,
8
+ Message,
9
+ parseMarkdown,
10
+ type RawMessage,
11
+ type StreamChunk,
12
+ type StreamOptions,
13
+ stringifyMarkdown,
14
+ type ThreadInfo,
15
+ type UserInfo,
16
+ } from 'chat';
17
+ import {
18
+ type API,
19
+ type Credentials,
20
+ type FriendEvent,
21
+ FriendEventType,
22
+ GroupMessage,
23
+ type LoginQRCallbackEvent,
24
+ LoginQRCallbackEventType,
25
+ ThreadType,
26
+ UserMessage,
27
+ Zalo,
28
+ type Message as ZcaMessage,
29
+ } from 'zca-js';
30
+
31
+ export interface ZaloPersonalAdapterConfig {
32
+ channelId: string;
33
+ cookieJson: string;
34
+ displayName: string;
35
+ imei: string;
36
+ language?: string;
37
+ ownId?: string | null;
38
+ userAgent: string;
39
+ }
40
+
41
+ export interface ZaloPersonalStatus {
42
+ connected: boolean;
43
+ lastError: string | null;
44
+ lastEventAt: string | null;
45
+ ownId: string | null;
46
+ running: boolean;
47
+ startedAt: string | null;
48
+ }
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
+
107
+ export interface ZaloPersonalThreadRef {
108
+ externalThreadId: string;
109
+ threadType: ThreadType;
110
+ }
111
+
112
+ export interface ZaloPersonalSentRaw {
113
+ externalThreadId: string;
114
+ id: string;
115
+ isSelf: true;
116
+ response: unknown;
117
+ text: string;
118
+ threadId: string;
119
+ threadType: ThreadType;
120
+ ts: number;
121
+ }
122
+
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
+ }
191
+
192
+ export type ZaloPersonalAdapter = Adapter<
193
+ ZaloPersonalThreadRef,
194
+ ZaloPersonalRawMessage
195
+ > & {
196
+ getPersonalStatus(): ZaloPersonalStatus;
197
+ startPersonalListener(): Promise<ZaloPersonalStatus>;
198
+ stopPersonalListener(): Promise<ZaloPersonalStatus>;
199
+ syncPersonalHistory(
200
+ options?: ZaloPersonalHistorySyncOptions
201
+ ): Promise<ZaloPersonalHistorySyncResult>;
202
+ syncPersonalPhoneHistory(
203
+ options?: ZaloPersonalPhoneSyncOptions
204
+ ): Promise<ZaloPersonalPhoneSyncResult>;
205
+ validateLogin(): Promise<ZaloPersonalStatus>;
206
+ };
207
+
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
+ }
275
+
276
+ export function parseZaloPersonalCookieJson(
277
+ cookieJson: string
278
+ ): Credentials['cookie'] {
279
+ const parsed = JSON.parse(cookieJson) as unknown;
280
+
281
+ if (Array.isArray(parsed)) {
282
+ return parsed as Credentials['cookie'];
283
+ }
284
+
285
+ if (isRecord(parsed) && Array.isArray(parsed.cookies)) {
286
+ return parsed as Credentials['cookie'];
287
+ }
288
+
289
+ throw new Error('zalo_personal_cookie_json_invalid');
290
+ }
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
+
349
+ export function createZaloPersonalAdapter(
350
+ config: ZaloPersonalAdapterConfig
351
+ ): ZaloPersonalAdapter {
352
+ let api: API | null = null;
353
+ let chat: ChatInstance | null = null;
354
+ let listenersAttached = false;
355
+ let status: ZaloPersonalStatus = {
356
+ connected: false,
357
+ lastError: null,
358
+ lastEventAt: null,
359
+ ownId: config.ownId?.trim() || null,
360
+ running: false,
361
+ startedAt: null,
362
+ };
363
+ const groupProfileCache = new Map<string, ZaloPersonalThreadProfile | null>();
364
+ const userProfileCache = new Map<string, ZaloPersonalThreadProfile | null>();
365
+
366
+ function setStatus(update: Partial<ZaloPersonalStatus>) {
367
+ status = { ...status, ...update };
368
+ return status;
369
+ }
370
+
371
+ function encodeThreadId(ref: ZaloPersonalThreadRef) {
372
+ return [
373
+ THREAD_ID_PREFIX,
374
+ config.channelId,
375
+ ref.threadType === ThreadType.Group ? 'group' : 'user',
376
+ ref.externalThreadId,
377
+ ].join(':');
378
+ }
379
+
380
+ function decodeThreadId(threadId: string): ZaloPersonalThreadRef {
381
+ const [prefix, channelId, rawType, ...idParts] = threadId.split(':');
382
+
383
+ if (
384
+ prefix !== THREAD_ID_PREFIX ||
385
+ channelId !== config.channelId ||
386
+ !rawType ||
387
+ idParts.length === 0
388
+ ) {
389
+ return {
390
+ externalThreadId: threadId,
391
+ threadType: ThreadType.User,
392
+ };
393
+ }
394
+
395
+ return {
396
+ externalThreadId: idParts.join(':'),
397
+ threadType: rawType === 'group' ? ThreadType.Group : ThreadType.User,
398
+ };
399
+ }
400
+
401
+ function messageThreadId(raw: ZcaMessage) {
402
+ return encodeThreadId({
403
+ externalThreadId: raw.threadId,
404
+ threadType: raw.type,
405
+ });
406
+ }
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
+
429
+ async function connect() {
430
+ if (api) return api;
431
+
432
+ try {
433
+ if (!config.cookieJson.trim() || !config.imei.trim()) {
434
+ throw new Error('zalo_personal_credentials_missing');
435
+ }
436
+
437
+ const zalo = new Zalo();
438
+ api = await zalo.login({
439
+ cookie: parseZaloPersonalCookieJson(config.cookieJson),
440
+ imei: config.imei,
441
+ language: config.language,
442
+ userAgent: config.userAgent,
443
+ });
444
+
445
+ const ownId = api.getOwnId();
446
+ setStatus({
447
+ connected: true,
448
+ lastError: null,
449
+ ownId: ownId || status.ownId,
450
+ });
451
+
452
+ return api;
453
+ } catch (error) {
454
+ setStatus({
455
+ connected: false,
456
+ lastError: error instanceof Error ? error.message : String(error),
457
+ running: false,
458
+ });
459
+ throw error;
460
+ }
461
+ }
462
+
463
+ function attachListeners(apiInstance: API) {
464
+ if (listenersAttached) return;
465
+
466
+ apiInstance.listener.on('connected', () => {
467
+ setStatus({
468
+ connected: true,
469
+ lastError: null,
470
+ running: true,
471
+ });
472
+ });
473
+ apiInstance.listener.on('disconnected', (_code, reason) => {
474
+ setStatus({
475
+ connected: false,
476
+ lastError: reason || null,
477
+ running: false,
478
+ });
479
+ });
480
+ apiInstance.listener.on('closed', (_code, reason) => {
481
+ setStatus({
482
+ connected: false,
483
+ lastError: reason || null,
484
+ running: false,
485
+ });
486
+ });
487
+ apiInstance.listener.on('error', (error) => {
488
+ setStatus({
489
+ lastError: error instanceof Error ? error.message : String(error),
490
+ });
491
+ });
492
+ apiInstance.listener.on('message', (message) => {
493
+ void handleIncomingMessage(message);
494
+ });
495
+ apiInstance.listener.on('friend_event', (event) => {
496
+ void handleFriendEvent(event);
497
+ });
498
+
499
+ listenersAttached = true;
500
+ }
501
+
502
+ async function handleIncomingMessage(raw: ZcaMessage) {
503
+ if (raw.isSelf || typeof raw.data.content !== 'string') {
504
+ return;
505
+ }
506
+
507
+ const sdkMessage = adapter.parseMessage(raw);
508
+ setStatus({ lastEventAt: new Date().toISOString() });
509
+
510
+ await chat?.processMessage(adapter, sdkMessage.threadId, sdkMessage, {
511
+ waitUntil: (task) => {
512
+ void task.catch((error) => {
513
+ setStatus({
514
+ lastError: error instanceof Error ? error.message : String(error),
515
+ });
516
+ });
517
+ },
518
+ });
519
+ }
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
+
887
+ function unsupported(feature: string) {
888
+ return new Error(`zalo_personal_${feature}_unsupported`);
889
+ }
890
+
891
+ const adapter: ZaloPersonalAdapter = {
892
+ addReaction: async () => {
893
+ throw unsupported('reaction');
894
+ },
895
+ channelIdFromThreadId: (_threadId) => config.channelId,
896
+ decodeThreadId,
897
+ deleteMessage: async () => {
898
+ throw unsupported('delete');
899
+ },
900
+ disconnect: async () => {
901
+ api?.listener.stop();
902
+ api = null;
903
+ listenersAttached = false;
904
+ setStatus({
905
+ connected: false,
906
+ running: false,
907
+ });
908
+ },
909
+ editMessage: async () => {
910
+ throw unsupported('edit');
911
+ },
912
+ encodeThreadId,
913
+ fetchMessages: async (
914
+ threadId,
915
+ options?: FetchOptions
916
+ ): Promise<FetchResult<ZaloPersonalRawMessage>> => {
917
+ const apiInstance = await connect();
918
+ const thread = decodeThreadId(threadId);
919
+
920
+ if (thread.threadType !== ThreadType.Group) {
921
+ return { messages: [] };
922
+ }
923
+
924
+ const result = await apiInstance.getGroupChatHistory(
925
+ thread.externalThreadId,
926
+ options?.limit ?? 50
927
+ );
928
+
929
+ const messages = result.groupMsgs
930
+ .map((message) => adapter.parseMessage(message))
931
+ .sort(
932
+ (a, b) =>
933
+ a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime()
934
+ );
935
+
936
+ return { messages };
937
+ },
938
+ fetchThread: async (threadId): Promise<ThreadInfo> => {
939
+ const apiInstance = await connect();
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);
957
+
958
+ return {
959
+ fullName: profile?.title ?? userId,
960
+ isBot: false,
961
+ userId,
962
+ userName: profile?.title ?? userId,
963
+ };
964
+ },
965
+ handleWebhook: async () =>
966
+ Response.json(
967
+ { error: 'Personal Zalo channels use a listener, not webhooks.' },
968
+ { status: 404 }
969
+ ),
970
+ initialize: async (instance) => {
971
+ chat = instance;
972
+ },
973
+ isDM: (threadId) => decodeThreadId(threadId).threadType === ThreadType.User,
974
+ lockScope: 'thread',
975
+ name: 'zalo',
976
+ openDM: async (userId) =>
977
+ encodeThreadId({
978
+ externalThreadId: userId,
979
+ threadType: ThreadType.User,
980
+ }),
981
+ parseMessage: (raw) => {
982
+ if (isSentRaw(raw)) {
983
+ return new Message<ZaloPersonalRawMessage>({
984
+ attachments: [],
985
+ author: {
986
+ fullName: config.displayName,
987
+ isBot: true,
988
+ isMe: true,
989
+ userId: status.ownId ?? 'zalo-personal-self',
990
+ userName: config.displayName,
991
+ },
992
+ formatted: parseMarkdown(raw.text),
993
+ id: raw.id,
994
+ metadata: {
995
+ dateSent: new Date(raw.ts),
996
+ edited: false,
997
+ },
998
+ raw,
999
+ text: raw.text,
1000
+ threadId: raw.threadId,
1001
+ });
1002
+ }
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
+
1026
+ const text =
1027
+ typeof raw.data.content === 'string'
1028
+ ? raw.data.content
1029
+ : '[Unsupported Zalo message]';
1030
+ const authorId =
1031
+ raw.data.uidFrom || raw.data.userId || (raw.isSelf ? status.ownId : '');
1032
+
1033
+ return new Message<ZaloPersonalRawMessage>({
1034
+ attachments: [],
1035
+ author: {
1036
+ fullName: raw.data.dName || authorId || 'Zalo user',
1037
+ isBot: raw.isSelf,
1038
+ isMe: raw.isSelf,
1039
+ userId: authorId || raw.threadId,
1040
+ userName: raw.data.dName || authorId || raw.threadId,
1041
+ },
1042
+ formatted: parseMarkdown(text),
1043
+ id: raw.data.msgId || raw.data.cliMsgId || `${raw.data.ts}`,
1044
+ metadata: {
1045
+ dateSent: dateFromZaloTimestamp(raw.data.ts),
1046
+ edited: false,
1047
+ },
1048
+ raw,
1049
+ text,
1050
+ threadId: messageThreadId(raw),
1051
+ });
1052
+ },
1053
+ postMessage: async (threadId, message) => {
1054
+ const apiInstance = await connect();
1055
+ const thread = decodeThreadId(threadId);
1056
+ const text = extractPostableText(message);
1057
+ const response = await apiInstance.sendMessage(
1058
+ { msg: text },
1059
+ thread.externalThreadId,
1060
+ thread.threadType
1061
+ );
1062
+ const id = String(
1063
+ response.message?.msgId ??
1064
+ response.attachment.at(0)?.msgId ??
1065
+ Date.now()
1066
+ );
1067
+
1068
+ return {
1069
+ id,
1070
+ raw: {
1071
+ externalThreadId: thread.externalThreadId,
1072
+ id,
1073
+ isSelf: true,
1074
+ response,
1075
+ text,
1076
+ threadId,
1077
+ threadType: thread.threadType,
1078
+ ts: Date.now(),
1079
+ },
1080
+ threadId,
1081
+ };
1082
+ },
1083
+ removeReaction: async () => {
1084
+ throw unsupported('reaction');
1085
+ },
1086
+ renderFormatted: (content: FormattedContent) => stringifyMarkdown(content),
1087
+ startPersonalListener: async () => {
1088
+ const apiInstance = await connect();
1089
+ attachListeners(apiInstance);
1090
+ if (status.running && isZaloListenerSocketOpen(apiInstance.listener)) {
1091
+ return status;
1092
+ }
1093
+ apiInstance.listener.start({ retryOnClose: true });
1094
+
1095
+ return setStatus({
1096
+ connected: true,
1097
+ lastError: null,
1098
+ running: true,
1099
+ startedAt: status.startedAt ?? new Date().toISOString(),
1100
+ });
1101
+ },
1102
+ startTyping: async (threadId) => {
1103
+ const apiInstance = await connect();
1104
+ const thread = decodeThreadId(threadId);
1105
+ await apiInstance.sendTypingEvent(
1106
+ thread.externalThreadId,
1107
+ thread.threadType
1108
+ );
1109
+ },
1110
+ stopPersonalListener: async () => {
1111
+ api?.listener.stop();
1112
+
1113
+ return setStatus({
1114
+ connected: false,
1115
+ running: false,
1116
+ });
1117
+ },
1118
+ syncPersonalHistory: async (options = {}) => {
1119
+ const apiInstance = await connect();
1120
+
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);
1143
+ }
1144
+
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();
1188
+
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,
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
+ };
1213
+ },
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>[] = [];
1239
+
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
+ }
2126
+
2127
+ function dateFromZaloTimestamp(value: string) {
2128
+ const numeric = Number.parseInt(value, 10);
2129
+
2130
+ if (!Number.isFinite(numeric)) {
2131
+ return new Date();
2132
+ }
2133
+
2134
+ return new Date(numeric < 1_000_000_000_000 ? numeric * 1000 : numeric);
2135
+ }
2136
+
2137
+ function extractPostableText(message: AdapterPostableMessage) {
2138
+ if (typeof message === 'string') {
2139
+ return message;
2140
+ }
2141
+
2142
+ if (isRecord(message)) {
2143
+ if (typeof message.markdown === 'string') {
2144
+ return message.markdown;
2145
+ }
2146
+
2147
+ if (typeof message.text === 'string') {
2148
+ return message.text;
2149
+ }
2150
+
2151
+ if (isRecord(message.ast)) {
2152
+ return stringifyMarkdown(message.ast as unknown as FormattedContent);
2153
+ }
2154
+ }
2155
+
2156
+ return String(message);
2157
+ }
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
+
2190
+ function isRecord(value: unknown): value is Record<string, unknown> {
2191
+ return typeof value === 'object' && value !== null;
2192
+ }
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
+
2235
+ function isSentRaw(value: unknown): value is ZaloPersonalSentRaw {
2236
+ return isRecord(value) && value.isSelf === true && 'response' in value;
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
+ }