@softtynet/zca-js 2.2.5 → 2.2.7
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/README.md
CHANGED
|
@@ -67,7 +67,9 @@ const zalo = new Zalo({
|
|
|
67
67
|
|
|
68
68
|
## Documentation
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
- [API Documentation](https://zca-js.tdung.com)
|
|
71
|
+
- [Hướng dẫn Tích hợp: Đồng bộ Tin nhắn Đa Thiết bị (`ZDB4.0`) & Gọi Thoại / Video](docs/INTEGRATION_GUIDE_SYNC_AND_CALL.md)
|
|
72
|
+
- [Kế hoạch & Hướng dẫn Dịch ngược Zalo bằng REA (`morluto/rea`)](docs/REA_REVERSE_ENGINEERING_PLAN.md)
|
|
71
73
|
|
|
72
74
|
---
|
|
73
75
|
|
|
@@ -3,7 +3,7 @@ import { apiFactory, resolveResponse } from "../utils.js";
|
|
|
3
3
|
export var CallType;
|
|
4
4
|
(function (CallType) {
|
|
5
5
|
CallType[CallType["Audio"] = 1] = "Audio";
|
|
6
|
-
CallType[CallType["Video"] =
|
|
6
|
+
CallType[CallType["Video"] = 3] = "Video";
|
|
7
7
|
})(CallType || (CallType = {}));
|
|
8
8
|
function parseServerEndpoint(addr) {
|
|
9
9
|
if (!addr)
|
|
@@ -65,7 +65,14 @@ export const sendVoiceCallFactory = apiFactory()((api, ctx, utils) => {
|
|
|
65
65
|
const sessId = (_p = serverInfo === null || serverInfo === void 0 ? void 0 : serverInfo.sessId) !== null && _p !== void 0 ? _p : `${normalizedCalleeId}_${callId}_401`;
|
|
66
66
|
const resolvedCodec = (_q = serverInfo === null || serverInfo === void 0 ? void 0 : serverInfo.codec) !== null && _q !== void 0 ? _q : codec;
|
|
67
67
|
// Step 2: Send ringing signaling request (/api/voicecall/request, command 11301)
|
|
68
|
+
const isVideo = callType === CallType.Video;
|
|
68
69
|
const extendData = JSON.stringify({
|
|
70
|
+
callType: isVideo ? 1 : 0,
|
|
71
|
+
video: {
|
|
72
|
+
codec: isVideo ? [{ name: "h264", payload: 97 }] : [],
|
|
73
|
+
},
|
|
74
|
+
newZrtc: 1,
|
|
75
|
+
platform: 5,
|
|
69
76
|
rtcpDiff: 0,
|
|
70
77
|
rtcp: [{ ip: rtcp.ip, port: rtcp.port }],
|
|
71
78
|
rtp: [{ ip: rtp.ip, port: rtp.port }],
|
|
@@ -6,7 +6,7 @@ var utils = require('../utils.cjs');
|
|
|
6
6
|
exports.CallType = void 0;
|
|
7
7
|
(function (CallType) {
|
|
8
8
|
CallType[CallType["Audio"] = 1] = "Audio";
|
|
9
|
-
CallType[CallType["Video"] =
|
|
9
|
+
CallType[CallType["Video"] = 3] = "Video";
|
|
10
10
|
})(exports.CallType || (exports.CallType = {}));
|
|
11
11
|
function parseServerEndpoint(addr) {
|
|
12
12
|
if (!addr)
|
|
@@ -68,7 +68,14 @@ const sendVoiceCallFactory = utils.apiFactory()((api, ctx, utils$1) => {
|
|
|
68
68
|
const sessId = (_p = serverInfo === null || serverInfo === void 0 ? void 0 : serverInfo.sessId) !== null && _p !== void 0 ? _p : `${normalizedCalleeId}_${callId}_401`;
|
|
69
69
|
const resolvedCodec = (_q = serverInfo === null || serverInfo === void 0 ? void 0 : serverInfo.codec) !== null && _q !== void 0 ? _q : codec;
|
|
70
70
|
// Step 2: Send ringing signaling request (/api/voicecall/request, command 11301)
|
|
71
|
+
const isVideo = callType === exports.CallType.Video;
|
|
71
72
|
const extendData = JSON.stringify({
|
|
73
|
+
callType: isVideo ? 1 : 0,
|
|
74
|
+
video: {
|
|
75
|
+
codec: isVideo ? [{ name: "h264", payload: 97 }] : [],
|
|
76
|
+
},
|
|
77
|
+
newZrtc: 1,
|
|
78
|
+
platform: 5,
|
|
72
79
|
rtcpDiff: 0,
|
|
73
80
|
rtcp: [{ ip: rtcp.ip, port: rtcp.port }],
|
|
74
81
|
rtp: [{ ip: rtp.ip, port: rtp.port }],
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# Hướng dẫn Tích hợp: Đồng bộ Tin nhắn Đa Thiết bị (`ZDB4.0`) & Gọi Thoại / Video (`@softtynet/zca-js`)
|
|
2
|
+
|
|
3
|
+
> [!IMPORTANT]
|
|
4
|
+
> Tài liệu này hướng dẫn chi tiết cách tích hợp các tính năng nâng cấp trên nhánh `lib` của `@softtynet/zca-js` (từ phiên bản `v2.2.0+`), đã được **kiểm định thực tế 100% trên máy chủ Zalo thật và thiết bị di động thật**.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Mục lục
|
|
9
|
+
|
|
10
|
+
1. [Tổng quan Kiến trúc](#1-tổng-quan-kiến-trúc)
|
|
11
|
+
2. [Bảng tra cứu nhanh API, Sự kiện & Hàm Giải mã](#2-bảng-tra-cứu-nhanh-api-sự-kiện--hàm-giải-mã)
|
|
12
|
+
3. [Tích hợp Tầng 1: Đồng bộ bù tin nhắn khi Offline (Catch-up Sync)](#3-tích-hợp-tầng-1-đồng-bộ-bù-tin-nhắn-khi-offline-catch-up-sync)
|
|
13
|
+
4. [Tích hợp Tầng 2: Đồng bộ lịch sử sao lưu từ Điện thoại (`ZDB4.0`)](#4-tích-hợp-tầng-2-đồng-bộ-lịch-sử-sao-lưu-từ-điện-thoại-zdb40)
|
|
14
|
+
5. [Ánh xạ ID ẩn danh (`getNoisedIds` & `decryptGids`)](#5-ánh-xạ-id-ẩn-danh-getnoisedids--decryptgids)
|
|
15
|
+
6. [Tích hợp Gọi Thoại (`CallType.Audio`) & Gọi Video (`CallType.Video`)](#6-tích-hợp-gọi-thoại-calltypeaudio--gọi-video-calltypevideo)
|
|
16
|
+
7. [Các Lưu ý Quan trọng khi Vận hành Thực tế](#7-các-lưu-ý-quan-trọng-khi-vận-hành-thực-tế)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. Tổng quan Kiến trúc
|
|
21
|
+
|
|
22
|
+
Hệ thống đồng bộ và cuộc gọi của Zalo hoạt động theo 3 luồng độc lập:
|
|
23
|
+
|
|
24
|
+
```mermaid
|
|
25
|
+
flowchart TD
|
|
26
|
+
subgraph Tầng 1: Catch-up Sync (Khi mở lại App / Mất kết nối ngắn)
|
|
27
|
+
A1["api.preloadConversations(threadIdLocalMsgId)"] --> A2["Trả về messages + unreadCleared (cmd 10053)"]
|
|
28
|
+
A3["WebSocket: sự kiện cipher_key"] --> A4["api.listener.requestOldMessages(User / Group)"]
|
|
29
|
+
A4 --> A5["Sự kiện old_messages (cmd 510 / 511) + hasMore"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
subgraph Tầng 2: Mobile Backup Sync (Kéo lịch sử từ Điện thoại)
|
|
33
|
+
B1["api.requestSyncFromPhone() (cmd 12000)"] --> B2["Điện thoại hiện thông báo xác nhận"]
|
|
34
|
+
B2 --> B3["WebSocket: sync_event (user_confirm -> syncmsg_info)"]
|
|
35
|
+
B3 --> B4["api.getSyncData() tải file .db.crypt"]
|
|
36
|
+
B4 --> B5["decryptWithPrivateKey -> deriveZdbAesKey -> decryptZdbBuffer -> parseZdbHeader"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
subgraph Luồng 3: Gọi Thoại & Video (Signaling 2 bước)
|
|
40
|
+
C1["api.sendVoiceCall(calleeId, { callType })"] --> C2["Bước 1: /api/voicecall/requestcall (cmd 11300)"]
|
|
41
|
+
C2 --> C3["Bước 2: /api/voicecall/request (cmd 11301)"]
|
|
42
|
+
C3 --> C4["Đổ chuông Thoại (1) hoặc Video (3) trên máy người nhận"]
|
|
43
|
+
C4 --> C5["api.cancelVoiceCall (11305) / api.endVoiceCall (11306)"]
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 2. Bảng tra cứu nhanh API, Sự kiện & Hàm Giải mã
|
|
50
|
+
|
|
51
|
+
### 2.1. Các phương thức trên `api` (`API` instance)
|
|
52
|
+
|
|
53
|
+
| Phương thức | Command ID | Endpoint Zalo | Công dụng |
|
|
54
|
+
| :--- | :---: | :--- | :--- |
|
|
55
|
+
| [`api.preloadConversations(threadIdLocalMsgId?)`](../src/apis/preloadConversations.ts) | `10053` | `/api/preloadconvers/get-last-msgs` | Lấy danh sách tin nhắn mới nhất của các hội thoại (`messages`, `msgs`, `groupMsgs`) và danh sách đã đọc (`unreadCleared`). |
|
|
56
|
+
| [`api.requestSyncFromPhone(options?)`](../src/apis/requestSyncFromPhone.ts) | `12000` | `/api/message/pull_mobile_msg` | Tự sinh cặp khoá RSA-2048 và gửi yêu cầu đồng bộ lịch sử tin nhắn về điện thoại. Trả về `{ privateKey, publicKeyBase64, pcName }`. |
|
|
57
|
+
| [`api.pullMobile(options)`](../src/apis/pullMobile.ts) | `12000` | `/api/message/pull_mobile_msg` | API mức thấp để gửi yêu cầu đồng bộ với `public_key` tự quản lý. |
|
|
58
|
+
| [`api.cancelPullMobile(options)`](../src/apis/cancelPullMobile.ts) | `12700` | `/api/message/cancel_pull_mobile_msg` | Thu hồi thông báo yêu cầu đồng bộ trên điện thoại (khi hết thời gian chờ hoặc người dùng bấm huỷ trên Web/PC). |
|
|
59
|
+
| [`api.getSyncData(options)`](../src/apis/getSyncData.ts) | — | *(URL động từ `syncmsg_info`)* | Tải gói sao lưu `.db.crypt` do điện thoại tải lên về thư mục máy chủ (`savePath`). |
|
|
60
|
+
| [`api.getNoisedIds(options)`](../src/apis/getNoisedIds.ts) | `12390` | `/api/znoise` *(có fallback `zwid.api.zalo.me`)* | Chuyển đổi danh sách User ID (`fids`) và Group ID (`gids`) thật sang mã ẩn danh (Noised ID) dùng trong tên file SQLite của `ZDB4.0`. |
|
|
61
|
+
| [`api.decryptGids(gids)`](../src/apis/decryptGids.ts) | `12054` | `/api/gid/decrypt` | Giải mã danh sách Global ID / Noised ID về ID gốc. |
|
|
62
|
+
| [`api.sendVoiceCall(calleeId, options?)`](../src/apis/sendVoiceCall.ts) | `11300` + `11301` | `/api/voicecall/requestcall` + `/api/voicecall/request` | Khởi tạo cuộc gọi Thoại (`CallType.Audio = 1`) hoặc Video (`CallType.Video = 3`), làm đổ chuông thiết bị người nhận. |
|
|
63
|
+
| [`api.cancelVoiceCall(calleeId, callId, options?)`](../src/apis/cancelVoiceCall.ts) | `11305` | `/api/voicecall/cancel` | Huỷ cuộc gọi đang đổ chuông trước khi người nhận bắt máy. |
|
|
64
|
+
| [`api.endVoiceCall(calleeId, callId, options?)`](../src/apis/endVoiceCall.ts) | `11306` | `/api/voicecall/endcall` | Kết thúc cuộc gọi đang diễn ra. |
|
|
65
|
+
|
|
66
|
+
### 2.2. Các sự kiện WebSocket trên `api.listener`
|
|
67
|
+
|
|
68
|
+
| Tên sự kiện | WebSocket `cmd` | Tham số Callback | Mô tả |
|
|
69
|
+
| :--- | :---: | :--- | :--- |
|
|
70
|
+
| `old_messages` | `510` (User) / `511` (Group) | `(messages: Message[], type: ThreadType, hasMore?: boolean)` | Phát ra khi nhận lô tin nhắn cũ từ hàng đợi offline của Zalo. Khi `hasMore === true`, còn tin nhắn tiếp theo cần kéo tiếp. |
|
|
71
|
+
| `unread_cleared` | `504` / `524` | `(items: ClearUnread[])` | Phát ra khi người dùng đọc tin nhắn trên thiết bị khác (đồng bộ trạng thái đã đọc). |
|
|
72
|
+
| `sync_event` | `601` (`syncmsgmb` / `transfer_msg`) | `(event: SyncEvent)` | Phát ra các trạng thái đồng bộ từ điện thoại: `user_confirm`, `syncmsg_info`, `transfer_after_login`, `transfer_error`. |
|
|
73
|
+
| `call_event` | `601` (`voip`) | `(event: CallEvent)` | Phát ra tín hiệu cuộc gọi thời gian thực: `request`, `ringring`, `answer`, `cancel`, `endcall`, ... |
|
|
74
|
+
|
|
75
|
+
### 2.3. Các hàm tiện ích mật mã (`ZDB4.0` Crypto Helpers)
|
|
76
|
+
|
|
77
|
+
Được export trực tiếp từ `@softtynet/zca-js`:
|
|
78
|
+
- [`generateRSAKeyPair()`](../src/utils.ts): Sinh cặp khoá RSA-2048 (`publicKey`, `privateKey`, `publicKeyBase64`).
|
|
79
|
+
- [`decryptWithPrivateKey(privateKeyPem, encryptedBase64)`](../src/utils.ts): Giải mã `encrypted_key` bằng RSA PKCS#1 v1.5 (tự động dự phòng bằng thuật toán BigInt CRT thuần JS trên Node.js 20/22/24+).
|
|
80
|
+
- [`deriveZdbAesKey(decryptedRsaBytes)`](../src/utils.ts): Rút trích khoá AES-256 (32 bytes) từ payload RSA đã giải mã.
|
|
81
|
+
- [`decryptZdbBuffer(encryptedBuf, aesKey)`](../src/utils.ts): Giải mã toàn bộ nội dung file `.db.crypt` theo từng khối `64KB` (`0x10000` bytes) với `AES-256-CBC` và `IV = 16 bytes 0x00`.
|
|
82
|
+
- [`parseZdbHeader(zdbPlain)`](../src/utils.ts): Đọc header `"ZDB4.0"`, trích xuất danh sách toàn bộ file SQLite (`entries: [{ name, size }]`) và vị trí bắt đầu luồng nén XZ (`xzOffset`).
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 3. Tích hợp Tầng 1: Đồng bộ bù tin nhắn khi Offline (Catch-up Sync)
|
|
87
|
+
|
|
88
|
+
Khi ứng dụng của bạn vừa khởi động lại hoặc vừa khôi phục kết nối mạng, hãy kết hợp **2 bước**:
|
|
89
|
+
1. Gọi `api.preloadConversations(threadIdLocalMsgId)` qua HTTP để lấy nhanh tin nhắn cuối cùng của các hội thoại và danh sách đã đọc (`unreadCleared`).
|
|
90
|
+
2. Sau khi WebSocket phát sự kiện **`cipher_key`**, gọi `api.listener.requestOldMessages(...)` để kéo toàn bộ hàng đợi tin nhắn cá nhân (`cmd 510`) và nhóm (`cmd 511`). Nếu callback `old_messages` trả về `hasMore === true`, tiếp tục truyền `msgId` cuối cùng vào `requestOldMessages(type, lastMsgId)` để phân trang đến hết.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { Zalo, ThreadType } from "@softtynet/zca-js";
|
|
94
|
+
|
|
95
|
+
const zalo = new Zalo({ selfListen: true });
|
|
96
|
+
const api = await zalo.login(credentials);
|
|
97
|
+
|
|
98
|
+
// Bước 1: Preload tin nhắn mới nhất & trạng thái đã đọc qua HTTP (command 10053)
|
|
99
|
+
// Có thể truyền map { [threadId]: lastLocalMsgId } đã lưu trong DB của bạn, hoặc bỏ trống {}
|
|
100
|
+
const preload = await api.preloadConversations({});
|
|
101
|
+
|
|
102
|
+
for (const msg of preload.messages) {
|
|
103
|
+
console.log(`[Preload] thread=${msg.threadId} msgId=${msg.data.msgId}:`, msg.data.content);
|
|
104
|
+
}
|
|
105
|
+
for (const cleared of preload.unreadCleared) {
|
|
106
|
+
console.log(`[Preload UnreadCleared] thread=${cleared.idTo} lastMsgId=${cleared.lastMsgId} isGroup=${cleared.isGroup}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Bước 2: Lắng nghe hàng đợi tin nhắn cũ qua WebSocket (cmd 510 & 511)
|
|
110
|
+
api.listener.on("old_messages", (messages, type, hasMore) => {
|
|
111
|
+
console.log(`[OldMessages] Nhận ${messages.length} tin nhắn (${type === ThreadType.Group ? "Nhóm" : "Cá nhân"}), hasMore=${hasMore}`);
|
|
112
|
+
|
|
113
|
+
for (const msg of messages) {
|
|
114
|
+
// Lưu tin nhắn vào Database của bạn (upsert theo msg.data.msgId)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Nếu còn tin nhắn trong hàng đợi (hasMore === true), tự động kéo trang tiếp theo
|
|
118
|
+
if (hasMore && messages.length > 0) {
|
|
119
|
+
const lastMsgId = messages[messages.length - 1].data.msgId;
|
|
120
|
+
api.listener.requestOldMessages(type, lastMsgId);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Đồng bộ trạng thái đã đọc thời gian thực khi bạn đọc tin nhắn trên điện thoại
|
|
125
|
+
api.listener.on("unread_cleared", (items) => {
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
console.log(`[UnreadCleared] Đã đọc hội thoại ${item.idTo} tới msgId=${item.lastMsgId}`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// QUAN TRỌNG: Chỉ gọi requestOldMessages sau khi nhận sự kiện "cipher_key"
|
|
132
|
+
api.listener.on("cipher_key", () => {
|
|
133
|
+
api.listener.requestOldMessages(ThreadType.User); // mặc định gửi lastId = "10000000000000000"
|
|
134
|
+
api.listener.requestOldMessages(ThreadType.Group); // mặc định gửi lastId = "10000000000000000"
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
api.listener.start({ retryOnClose: true });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 4. Tích hợp Tầng 2: Đồng bộ lịch sử sao lưu từ Điện thoại (`ZDB4.0`)
|
|
143
|
+
|
|
144
|
+
Tính năng này cho phép kéo toàn bộ lịch sử tin nhắn đang nằm trong máy điện thoại của người dùng lên Web/PC (giống hệt nút **"Đồng bộ tin nhắn"** trên Zalo PC/Web).
|
|
145
|
+
|
|
146
|
+
Có **2 kịch bản kích hoạt** đều được hỗ trợ đầy đủ:
|
|
147
|
+
- **Kịch bản A (Tự động sau khi quét QR)**: Nếu lúc quét mã QR trên điện thoại, người dùng tích chọn *"Đồng bộ tin nhắn"*, Zalo sẽ bắn sự kiện `SyncEventType.TRANSFER_AFTER_LOGIN` kèm `temp_key`. Bạn chỉ cần gọi `api.requestSyncFromPhone({ tempKey: event.data.temp_key })` — điện thoại sẽ tự đẩy dữ liệu lên mà không cần hỏi lại!
|
|
148
|
+
- **Kịch bản B (Chủ động yêu cầu bất kỳ lúc nào)**: Gọi `api.requestSyncFromPhone({ pcName: "Web" })` để làm nổ thông báo *"Xác nhận đồng bộ tin nhắn"* trên điện thoại người dùng. Nếu sau 60 giây người dùng không bấm, gọi `api.cancelPullMobile(...)` để đóng thông báo trên điện thoại.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import fs from "node:fs";
|
|
152
|
+
import {
|
|
153
|
+
SyncEventType,
|
|
154
|
+
decryptWithPrivateKey,
|
|
155
|
+
deriveZdbAesKey,
|
|
156
|
+
decryptZdbBuffer,
|
|
157
|
+
parseZdbHeader,
|
|
158
|
+
} from "@softtynet/zca-js";
|
|
159
|
+
|
|
160
|
+
let activePrivateKey = "";
|
|
161
|
+
let activePublicKeyBase64 = "";
|
|
162
|
+
let activePcName = "Web";
|
|
163
|
+
let confirmTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
164
|
+
|
|
165
|
+
// 1. Lắng nghe toàn bộ tiến trình đồng bộ từ điện thoại qua sự kiện "sync_event"
|
|
166
|
+
api.listener.on("sync_event", async (event) => {
|
|
167
|
+
switch (event.type) {
|
|
168
|
+
// Kịch bản A: Người dùng tích chọn "Đồng bộ tin nhắn" ngay lúc quét QR
|
|
169
|
+
case SyncEventType.TRANSFER_AFTER_LOGIN: {
|
|
170
|
+
const session = await api.requestSyncFromPhone({
|
|
171
|
+
tempKey: event.data.temp_key,
|
|
172
|
+
pcName: "Web",
|
|
173
|
+
});
|
|
174
|
+
activePrivateKey = session.privateKey;
|
|
175
|
+
activePublicKeyBase64 = session.publicKeyBase64;
|
|
176
|
+
activePcName = session.pcName;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Người dùng vừa bấm "Đồng bộ ngay" trên điện thoại (user_action === 1)
|
|
181
|
+
case SyncEventType.USER_CONFIRM: {
|
|
182
|
+
if (confirmTimeout) clearTimeout(confirmTimeout);
|
|
183
|
+
if (event.data.user_action === 1) {
|
|
184
|
+
console.log("📱 Người dùng đã xác nhận! Đang chờ điện thoại đóng gói và tải file lên...");
|
|
185
|
+
} else {
|
|
186
|
+
console.log("⚠️ Người dùng đã từ chối đồng bộ trên điện thoại.");
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Điện thoại đã tải xong file .db.crypt lên server Zalo
|
|
192
|
+
case SyncEventType.SYNCMSG_INFO: {
|
|
193
|
+
if (confirmTimeout) clearTimeout(confirmTimeout);
|
|
194
|
+
console.log(`📦 Gói sao lưu sẵn sàng: ${event.data.file_name} (${event.data.file_size} bytes)`);
|
|
195
|
+
|
|
196
|
+
// Tải file .db.crypt về thư mục ./sync_data
|
|
197
|
+
const dl = await api.getSyncData({
|
|
198
|
+
...event.data,
|
|
199
|
+
savePath: "./sync_data",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (dl.success && dl.data && activePrivateKey) {
|
|
203
|
+
// Giải mã khoá RSA PKCS#1 v1.5 -> Rút trích khoá AES-256
|
|
204
|
+
const rawRsa = decryptWithPrivateKey(activePrivateKey, dl.data.encrypted_key);
|
|
205
|
+
const aesKey = deriveZdbAesKey(rawRsa);
|
|
206
|
+
|
|
207
|
+
// Giải mã toàn bộ container ZDB4.0 (chia từng khối 64KB với IV=0)
|
|
208
|
+
const cryptBytes = fs.readFileSync(dl.data.file_path);
|
|
209
|
+
const zdbPlain = decryptZdbBuffer(cryptBytes, aesKey);
|
|
210
|
+
|
|
211
|
+
// Đọc mục lục (TOC) các file SQLite hội thoại bên trong gói ZDB4.0
|
|
212
|
+
const toc = parseZdbHeader(zdbPlain);
|
|
213
|
+
console.log(`🎉 Giải mã thành công ${toc.entries.length} file SQLite hội thoại!`, toc.entries);
|
|
214
|
+
|
|
215
|
+
// Lưu ý: Luồng nén XZ chứa dữ liệu của các file SQLite bắt đầu tại:
|
|
216
|
+
const xzStream = zdbPlain.subarray(toc.xzOffset);
|
|
217
|
+
fs.writeFileSync(`${dl.data.file_path}.xz`, xzStream);
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case SyncEventType.TRANSFER_ERROR: {
|
|
223
|
+
console.error("❌ Lỗi đồng bộ từ điện thoại:", event.data.error_code, event.data.message);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// 2. Hàm chủ động bấm nút "Đồng bộ từ điện thoại" (Kịch bản B)
|
|
230
|
+
async function triggerManualPhoneSync() {
|
|
231
|
+
const session = await api.requestSyncFromPhone({ pcName: "Web" });
|
|
232
|
+
activePrivateKey = session.privateKey;
|
|
233
|
+
activePublicKeyBase64 = session.publicKeyBase64;
|
|
234
|
+
activePcName = session.pcName;
|
|
235
|
+
|
|
236
|
+
// Tự động thu hồi thông báo trên điện thoại nếu sau 60s người dùng không bấm
|
|
237
|
+
confirmTimeout = setTimeout(async () => {
|
|
238
|
+
await api.cancelPullMobile({
|
|
239
|
+
public_key: activePublicKeyBase64,
|
|
240
|
+
pc_name: activePcName,
|
|
241
|
+
});
|
|
242
|
+
console.log("🧹 Đã thu hồi yêu cầu đồng bộ trên điện thoại do quá thời gian chờ.");
|
|
243
|
+
}, 60_000);
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## 5. Ánh xạ ID ẩn danh (`getNoisedIds` & `decryptGids`)
|
|
250
|
+
|
|
251
|
+
Bên trong gói sao lưu `ZDB4.0`, Zalo đặt tên các file SQLite theo mẫu:
|
|
252
|
+
- Tin nhắn cá nhân: `3_<myNoisedId>_<friendNoisedId>.db`
|
|
253
|
+
- Tin nhắn nhóm: `4_<myNoisedId>_<groupNoisedId>.db`
|
|
254
|
+
|
|
255
|
+
Để biết file `.db` nào ứng với bạn bè (`uid`) hay nhóm (`groupId`) nào, sử dụng [`api.getNoisedIds`](../src/apis/getNoisedIds.ts) và [`api.decryptGids`](../src/apis/decryptGids.ts):
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// 1. Đổi danh sách UID bạn bè & Group ID thật sang Noised ID
|
|
259
|
+
const ownId = api.getOwnId();
|
|
260
|
+
const friendIds = ["5249661060655759359", "6912185345590173640"];
|
|
261
|
+
const groupIds = ["7890123456789012345"];
|
|
262
|
+
|
|
263
|
+
const noised = await api.getNoisedIds({
|
|
264
|
+
fids: [ownId, ...friendIds],
|
|
265
|
+
gids: groupIds,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const myNoisedId = noised.fids[0];
|
|
269
|
+
console.log("My Noised ID:", myNoisedId);
|
|
270
|
+
|
|
271
|
+
// Map ngược từ tên file SQLite sang Friend UID thật:
|
|
272
|
+
const sqliteFileToFriendUid = new Map<string, string>();
|
|
273
|
+
friendIds.forEach((uid, index) => {
|
|
274
|
+
const friendNoised = noised.fids[index + 1];
|
|
275
|
+
if (friendNoised) {
|
|
276
|
+
sqliteFileToFriendUid.set(`3_${myNoisedId}_${friendNoised}.db`, uid);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// 2. Giải mã trực tiếp danh sách Global ID / Noised ID qua /api/gid/decrypt
|
|
281
|
+
const decrypted = await api.decryptGids(noised.fids);
|
|
282
|
+
console.log("Decrypted mapping:", decrypted.data ?? decrypted.gids);
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 6. Tích hợp Gọi Thoại (`CallType.Audio`) & Gọi Video (`CallType.Video`)
|
|
288
|
+
|
|
289
|
+
Thư viện hỗ trợ khởi tạo cuộc gọi 2 bước chuẩn giao thức C++ `ZaloCall` của Zalo PC:
|
|
290
|
+
- **`CallType.Audio = 1`**: Cuộc gọi thoại 1-1.
|
|
291
|
+
- **`CallType.Video = 3`**: Cuộc gọi video 1-1 (tự động đính kèm cấu hình codec H.264 `payload: 97` trong `extendData`).
|
|
292
|
+
|
|
293
|
+
### 6.1. Gọi đi (Đổ chuông Thoại hoặc Video) & Tự động huỷ khi không bắt máy
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
import { CallType } from "@softtynet/zca-js";
|
|
297
|
+
|
|
298
|
+
const targetUid = "5249661060655759359"; // Luôn truyền dạng chuỗi (string) 19 chữ số
|
|
299
|
+
|
|
300
|
+
// --- A. Khởi tạo Cuộc gọi VIDEO ---
|
|
301
|
+
const videoCall = await api.sendVoiceCall(targetUid, {
|
|
302
|
+
callType: CallType.Video, // = 3
|
|
303
|
+
});
|
|
304
|
+
console.log("Đang đổ chuông Video:", {
|
|
305
|
+
callId: videoCall.callId,
|
|
306
|
+
sessId: videoCall.sessId,
|
|
307
|
+
rtpServer: videoCall.rtpServer,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Huỷ cuộc gọi Video sau 30 giây nếu người nhận chưa bắt máy
|
|
311
|
+
const ringTimer = setTimeout(async () => {
|
|
312
|
+
await api.cancelVoiceCall(targetUid, videoCall.callId, {
|
|
313
|
+
callType: CallType.Video,
|
|
314
|
+
});
|
|
315
|
+
console.log("Đã huỷ cuộc gọi Video do hết thời gian chờ.");
|
|
316
|
+
}, 30_000);
|
|
317
|
+
|
|
318
|
+
// --- B. Khởi tạo Cuộc gọi THOẠI ---
|
|
319
|
+
const audioCall = await api.sendVoiceCall(targetUid, {
|
|
320
|
+
callType: CallType.Audio, // = 1 (mặc định)
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Kết thúc cuộc gọi đang diễn ra bất kỳ lúc nào
|
|
324
|
+
await api.endVoiceCall(targetUid, audioCall.callId);
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### 6.2. Lắng nghe sự kiện Cuộc gọi thời gian thực (`call_event`)
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
import { CallEventType } from "@softtynet/zca-js";
|
|
331
|
+
|
|
332
|
+
api.listener.on("call_event", (event) => {
|
|
333
|
+
console.log(`[VoIP Signal] type=${event.type}`, event.data);
|
|
334
|
+
|
|
335
|
+
switch (event.type) {
|
|
336
|
+
case CallEventType.RINGRING:
|
|
337
|
+
console.log(`🔔 Máy của người nhận đã bắt đầu đổ chuông (callId=${event.data.callId})`);
|
|
338
|
+
break;
|
|
339
|
+
case CallEventType.ANSWER:
|
|
340
|
+
console.log(`🟢 Người nhận đã bắt máy (callId=${event.data.callId})`);
|
|
341
|
+
break;
|
|
342
|
+
case CallEventType.CANCEL:
|
|
343
|
+
case CallEventType.ENDCALL:
|
|
344
|
+
console.log(`🔴 Cuộc gọi đã kết thúc hoặc bị từ chối (callId=${event.data.callId})`);
|
|
345
|
+
break;
|
|
346
|
+
case CallEventType.INCOMING_REQUEST:
|
|
347
|
+
console.log(`📲 Có cuộc gọi đến từ UID ${event.data.callerId} (callId=${event.data.callId})`);
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## 7. Các Lưu ý Quan trọng khi Vận hành Thực tế
|
|
356
|
+
|
|
357
|
+
> [!WARNING]
|
|
358
|
+
> **1. Tuyệt đối giữ UID ở kiểu `string` (không ép kiểu `Number`)**
|
|
359
|
+
> UID của Zalo có độ dài 19 chữ số (ví dụ `"5249661060655759359"`), vượt quá giới hạn `Number.MAX_SAFE_INTEGER` (`9007199254740991`) của JavaScript. Nếu bạn ép kiểu `Number(uid)` trước khi truyền vào hàm, 3 chữ số cuối sẽ bị làm tròn thành `000` và máy chủ Zalo sẽ trả lỗi `ZaloApiError: Tham số không hợp lệ (code: 114)`.
|
|
360
|
+
|
|
361
|
+
> [!TIP]
|
|
362
|
+
> **2. Thời điểm gọi `requestOldMessages`**
|
|
363
|
+
> Luôn gọi `api.listener.requestOldMessages(...)` bên trong sự kiện `api.listener.on("cipher_key", ...)` thay vì `on("connected")`. Khi sự kiện `connected` vừa nổ, kết nối TCP/WebSocket mới thiết lập xong nhưng máy chủ Zalo chưa gửi gói `cmd=1, subCmd=1` chứa `cipher_key`.
|
|
364
|
+
|
|
365
|
+
> [!NOTE]
|
|
366
|
+
> **3. Kịch bản kiểm định nhanh trên máy của bạn**
|
|
367
|
+
> Bất kỳ lúc nào muốn kiểm tra lại toàn bộ tính năng trên tài khoản thật, bạn có thể chạy:
|
|
368
|
+
> - Kiểm định tự động (Offline/Unit): `npm run test:verify`
|
|
369
|
+
> - Kiểm định toàn diện trên Server thật (6 bước): `npm run test:live`
|
|
370
|
+
> - Chỉ test nhanh Gọi Thoại + Video tới 1 UID: `npm run test:live -- --only-call --call <UID>`
|
|
371
|
+
> - Chỉ test riêng Gọi Video tới 1 UID: `npm run test:live -- --only-call --video-only --call <UID>`
|