@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.
- package/package.json +5 -5
- package/src/chat-sdk/registry.ts +19 -1
- package/src/chat-sdk/zalo-personal.ts +1832 -41
|
@@ -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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
+
}
|