@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.
- package/package.json +20 -19
- package/src/chat-sdk/registry.ts +19 -1
- package/src/chat-sdk/zalo-personal.ts +2298 -0
|
@@ -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
|
+
}
|