# Hướng dẫn triển khai — Đồng bộ tin nhắn cũ qua `pull_mobile_msg`

> **Nền tảng:** `zca-js@2.1.2`
> **Mục tiêu:** Lần đầu người dùng quét QR đăng nhập, hệ thống kéo được toàn bộ hội thoại + tin nhắn cũ (kể cả chat 1-1) từ điện thoại về DB riêng.
> **Ngày biên soạn:** 10/08/2026

---

## 0. Tóm tắt điều hành

| Hạng mục | Kết luận |
|---|---|
| Phương án có khả thi không? | **Có, đã triển khai chạy được trong worker Docker/Linux** |
| Đã có sẵn bao nhiêu? | Handshake, websocket event, download stream, giải khoá RSA, decode ZDB4, parse SQLite, import về Laravel |
| Decoder hiện tại | Thuần JS/Node + `xzcat`, không phụ thuộc Zalo PC/native trên VPS |
| Điểm cần theo dõi | Tốc độ import backfill lớn, schema Zalo đổi, credential tài khoản hết hạn |
| Rủi ro lớn nhất | Zalo đổi/gỡ luồng bất kỳ lúc nào (đã có tiền lệ: `/api/group/history` đang 404) |
| Khuyến nghị | Giữ listener 24/7 làm nguồn chính; backfill dùng cho onboarding hoặc repair khi cần |

**Trạng thái hiện tại ngày 10/08/2026:** account `1` đã decode được file sync lớn `618` thread / `337267` tin bằng decoder JS/Linux. Lỗi tràn queue khi import hàng trăm nghìn tin đã được sửa bằng cơ chế backpressure: backfill đẩy từng batch và chờ Laravel nhận xong, không nhồi ồ ạt vào queue 10k như trước.

---

## 0.1. Các file đang gánh luồng production

| File | Vai trò |
|---|---|
| `worker/src/zalo/mobile-sync.ts` | Điều phối `pull_mobile_msg`: sinh RSA, chờ xác nhận điện thoại, tải `.db.crypt`, decode, parse, import, lưu state |
| `worker/src/zalo/db-crypt.ts` | Decoder thuần JS/Linux cho `.db.crypt`: AES-CBC chunk 64KiB, container `ZDB4.0`, giải nén XZ ra nhiều SQLite DB |
| `worker/src/zalo/sync-db-parser.ts` | Đọc SQLite `ChatContent`, parse BinNet payload ảnh/video/sticker/file, chuẩn hoá về `NormalizedMessage` |
| `worker/src/zalo/noise-id-resolver.ts` | Map plain id trong file sync sang noise id runtime để thread/conversation khớp UI |
| `worker/src/infrastructure/message-forwarder.ts` | Queue gửi tin về Laravel; có đường `enqueueBackfill()` để đồng bộ chậm, không drop tin |
| `worker/src/zalo/native-decoder-client.ts` | Fallback optional nếu cấu hình native bridge; mặc định VPS Linux không cần dùng |
| `docker/node/Dockerfile` | Cài `xz` để có `xzcat` trong worker runtime |

---

## 1. Bối cảnh kỹ thuật — vì sao phải đi đường này

Zalo lưu lịch sử hội thoại theo hai cơ chế khác nhau:

| Loại | Nơi lưu | API lấy được không? |
|---|---|---|
| **Group** | Server Zalo | Có `api.getGroupChatHistory()` từ v2.1.0 — **nhưng đang trả 404** (xem Mục 1.1) |
| **Chat 1-1 (DM)** | **Trên thiết bị**, không phải cloud | Không có API. Chỉ lấy được qua luồng đồng bộ từ điện thoại |

Đây chính là lý do khi bạn cài Zalo PC/Web lần đầu, app hiện màn "Đồng bộ tin nhắn từ điện thoại" và bạn phải cầm máy bấm xác nhận. Cơ chế đó tên nội bộ là **`pull_mobile_msg`**, và đó là toàn bộ nội dung tài liệu này.

### 1.1. Trạng thái `getGroupChatHistory` (để bạn không mất công)

Hàm này **có tồn tại** trong 2.1.2 tại `src/apis/getGroupChatHistory.ts`, gọi vào `${zpwServiceMap.group[0]}/api/group/history`.

- Được thêm từ **v2.1.0** (kiểm chứng: tarball npm 2.0.1 → 2.0.5 không có, 2.1.0 → 2.1.2 có)
- **Không xuất hiện trên trang docs** `zca-js.tdung.com` → nhiều người tưởng bị gỡ
- Hiện **trả HTTP 404**, hai issue đang mở:
  - [#356](https://github.com/RFS-ADRENO/zca-js/issues/356) — báo lỗi 404
  - [#367](https://github.com/RFS-ADRENO/zca-js/issues/367) — phân tích nguyên nhân: cùng host `zpwServiceMap.group[0]`, `getGroupInfo` → `/api/group/getmg-v2` chạy tốt, còn history dùng path **không có hậu tố version** nên nghi Zalo đã dời sang endpoint versioned mới

→ Nếu chỉ cần group, cách nhanh nhất là bắt endpoint mới bằng DevTools rồi gắn qua `api.custom()`. Còn muốn DM thì bắt buộc đi tiếp tài liệu này.

---

## 2. Nguồn tài liệu và code — địa chỉ chính xác

**Không có tài liệu chính thức nào cho luồng này.** Trang docs không đề cập. Toàn bộ "tài liệu" là mô tả PR + comment trong code. Danh sách đầy đủ nguồn:

### 2.1. Repo và PR

| Thứ | Địa chỉ |
|---|---|
| Repo gốc | `https://github.com/RFS-ADRENO/zca-js` |
| Commit `main` khi biên soạn | `9479746040524a805cf5e9d4f5a5a8156384d3a7` (26/06/2026) |
| **PR nguồn** | `https://github.com/RFS-ADRENO/zca-js/pull/269` |
| Tiêu đề PR | *"Sync data feature for zalo api. unfinished work"* — tác giả `RodrickSia` |
| Fork chứa code | `https://github.com/RodrickSia/zca-js` |
| Branch | `feat/sync` |
| Commit HEAD của branch | `29d01c4732391cf1b68a6630cd715199ef46348a` (19/02/2026) |
| **Merge-base với main** | `6f0c2f98e6437844498151551cb7232909bdcae3` |

> ⚠️ **Branch đứng từ 19/02/2026, main đã đi tới 26/06/2026 — cách nhau hơn 4 tháng.**
> Tuyệt đối **không merge nguyên branch**. Chỉ cherry-pick các file ở Mục 4.

### 2.2. Lệnh lấy code

```bash
# Repo nền, đúng bản 2.1.2
git clone https://github.com/RFS-ADRENO/zca-js.git
cd zca-js

# Kéo branch có code sync về làm tham chiếu
git remote add sync https://github.com/RodrickSia/zca-js.git
git fetch sync feat/sync

# Xem đúng những gì PR đã đổi (không lẫn commit của main)
git diff --stat 6f0c2f98e6437844498151551cb7232909bdcae3 sync/feat/sync

# Xem diff của từng file
git diff 6f0c2f98e6437844498151551cb7232909bdcae3 sync/feat/sync -- src/utils.ts
```

Kết quả `--stat` phải ra đúng 12 file / 512 dòng thêm:

```
 .gitignore              |   2 +-
 bun.lock                |   1 +
 examples/sync.ts        |  37 ++++
 qr.png                  | Bin
 src/apis.ts             |   6 ++
 src/apis/getSyncData.ts | 179 +++++++
 src/apis/listen.ts      |  20 ++-
 src/apis/pullMobile.ts  |  62 ++++
 src/models/SyncData.ts  |  37 ++++
 src/models/SyncEvent.ts | 107 +++++
 src/models/index.ts     |   1 +
 src/utils.ts            |  69 ++-
```

### 2.3. Issue liên quan nên đọc

| Issue | Nội dung | Vì sao đáng đọc |
|---|---|---|
| [#269](https://github.com/RFS-ADRENO/zca-js/pull/269) | PR gốc | Mô tả luồng + review comment của maintainer |
| [#270](https://github.com/RFS-ADRENO/zca-js/issues/270) | *"Password Raw để đọc CSDL SQLite ZaloPC"* | Người này làm **đúng việc của bạn** nhưng đi đường crack DB Zalo PC. Xác nhận DB Zalo có đặt password → gợi ý mạnh về SQLCipher |
| [#129](https://github.com/RFS-ADRENO/zca-js/issues/129) | *"Đồng bộ tin nhắn cũ từ cloud"* | Bối cảnh, còn mở |
| [#219](https://github.com/RFS-ADRENO/zca-js/issues/219) | *"Đồng bộ tin nhắn cũ"* | Đã đóng, cùng câu hỏi |
| [#77](https://github.com/RFS-ADRENO/zca-js/issues/77) | In tin nhắn cũ của group | Cũ, đã đóng |
| [#335](https://github.com/RFS-ADRENO/zca-js/discussions/335) | Thảo luận nguy cơ bị ban | Đọc trước khi cắm vào tài khoản thật |

### 2.4. Dự án tham khảo cách người khác làm

| Dự án | Điểm đáng học |
|---|---|
| `github.com/darkamenosa/openzca` | Ghi tin nhắn vào SQLite cục bộ. README nói thẳng: *DM sync chỉ là best-effort, zca-js expose DM cũ qua user-message stream chứ không theo đúng thread* → đây là mô tả giới hạn của cách "listener-only" |
| `github.com/locphamnguyen/ZaloCRM` | CRM đa tài khoản, changelog ghi "đồng bộ tin nhắn: lấy 50 tin cũ", có mở rộng zca-js |
| `n8n-nodes-zalo-nnt` (npm) | Đã bọc `getGroupChatHistory` kèm phân trang |

---

## 3. Bản đồ code — file nào làm gì

```
┌─ src/apis/pullMobile.ts ────────────────────────────────┐
│  Gửi public key lên Zalo, kích hoạt yêu cầu đồng bộ.    │
│  GET ${zpwServiceMap.file[0]}/api/message/pull_mobile_msg│
│  ⚠ Response RỖNG, không chứa dữ liệu. Dữ liệu về qua WS │
└─────────────────────────────────────────────────────────┘
                            │
┌─ src/apis/listen.ts ───────▼─────────────────────────────┐
│  Trong khối cmd == 601, bắt control.content.act_type     │
│  == "syncmsgmb" → emit event "sync_event"                │
└─────────────────────────────────────────────────────────┘
                            │
┌─ src/models/SyncEvent.ts ──▼─────────────────────────────┐
│  3 loại event: user_confirm / syncmsg_info / transfer_error│
└─────────────────────────────────────────────────────────┘
                            │
┌─ src/apis/getSyncData.ts ──▼─────────────────────────────┐
│  Tải file từ event.data.url (kèm cookie), lưu .db.crypt  │
│  Có 2 chế độ: buffer (mặc định) và returnStream          │
└─────────────────────────────────────────────────────────┘
                            │
┌─ src/utils.ts ─────────────▼─────────────────────────────┐
│  generateRSAKeyPair()      → RSA-2048, SPKI/PKCS8 PEM    │
│  decryptWithPrivateKey()   → PKCS1 → Buffer 32 bytes     │
│                              (= khoá AES-256)            │
└─────────────────────────────────────────────────────────┘
                            │
                  ╔═════════▼═════════╗
                  ║  ĐÃ TRIỂN KHAI    ║
                  ║  ZDB4 → SQLite    ║
                  ╚═══════════════════╝
                            │
┌─ worker/src/zalo/db-crypt.ts ───────────────────────────┐
│  AES-CBC key=upper_hex(aesKey)[0..32], IV zero           │
│  Reset IV mỗi chunk 64KiB                                │
│  Parse container ZDB4.0, giải nén XZ ra nhiều SQLite DB  │
└─────────────────────────────────────────────────────────┘
                            │
┌─ worker/src/zalo/sync-db-parser.ts ─────────────────────┐
│  Đọc ChatContent, parse BinNet payload, chuẩn hoá tin    │
│  ảnh/video/sticker/file/quote/mention/sender             │
└─────────────────────────────────────────────────────────┘
                            │
┌─ worker/src/infrastructure/message-forwarder.ts ────────┐
│  enqueueBackfill() đẩy từ từ theo batch, chờ Laravel     │
│  xả queue trước khi import tiếp để không mất tin         │
└─────────────────────────────────────────────────────────┘
```

### 3.1. Cấu trúc event (trích `src/models/SyncEvent.ts`)

```ts
enum SyncEventType {
  USER_CONFIRM   = "user_confirm",    // người dùng vừa bấm xác nhận trên điện thoại
  SYNCMSG_INFO   = "syncmsg_info",    // ← event chứa dữ liệu cần lấy
  TRANSFER_ERROR = "transfer_error",
  UNKNOWN        = "unknown",
}
```

Payload của `SYNCMSG_INFO` (`TSyncEventSyncMsgInfo`):

| Field | Kiểu | Ý nghĩa |
|---|---|---|
| `url` | string | Link tải file DB đã mã hoá |
| `encrypted_key` | string | **Khoá AES-256 đã bọc RSA bằng public key của bạn** |
| `file_name` | string | Tên file |
| `file_size` | number | Kích thước — dùng validate download đủ chưa |
| `checksum_code` | string | Hash để kiểm tra toàn vẹn |
| `from_seq_id` | number | Con trỏ đồng bộ — **lưu lại để sync bù lần sau** |
| `is_full_transfer` | number | `1` = toàn bộ lịch sử, `0` = chỉ phần tăng thêm |
| `db_info` | `{ backup_db: { msg_total, msg_thread }, db_format }` | **Vàng ròng**: `msg_total` là số tin kỳ vọng → dùng làm oracle kiểm chứng decoder/parser |
| `device_type` / `device_name` / `client_version` | | Thiết bị nguồn |
| `uid`, `time`, `client_time`, `trigger_reason`, `public_key` | | Metadata |

`TRANSFER_ERROR` có `can_retry`, `error_code`, `error_msg` — bắt buộc xử lý.

---

## 4. Port code sync vào `zca-js@2.1.2`

### 4.1. Nguyên tắc

Diff của PR chứa **lẫn lộn 2 loại thay đổi**:

1. Thay đổi thật của tính năng sync
2. Thay đổi **chỉ do chạy Prettier** (thêm dấu phẩy cuối, xuống dòng)

**Chỉ port loại 1.** Port loại 2 sẽ tạo conflict vô nghĩa với main hiện tại.

### 4.2. Bốn file COPY NGUYÊN, không sửa

```bash
git checkout sync/feat/sync -- src/apis/pullMobile.ts
git checkout sync/feat/sync -- src/apis/getSyncData.ts
git checkout sync/feat/sync -- src/models/SyncEvent.ts
git checkout sync/feat/sync -- src/models/SyncData.ts
```

Đã kiểm chứng tương thích với 2.1.2:
- `pullMobile.ts` dùng `ctx.API_VERSION` và `ctx.API_TYPE` → **vẫn tồn tại** trong `src/context.ts` (dòng 190–191)
- `pullMobile.ts` dùng `api.zpwServiceMap.file[0]` → main vẫn dùng base này ở `changeAccountAvatar.ts`, `forwardMessage.ts`
- Chữ ký `apiFactory` / `utils.resolve` / `utils.encodeAES` không đổi

### 4.3. `src/models/index.ts` — thêm 1 dòng

Nối vào cuối file (sau `export * from "./Sticker.js";`):

```ts
export * from "./SyncData.js";
```

### 4.4. `src/apis.ts` — thêm 6 dòng

| Vị trí | Dòng thêm |
|---|---|
| Sau `import { getStickersDetailFactory } ...` | `import { getSyncDataFactory } from "./apis/getSyncData.js";` |
| Sau `import { lockPollFactory } ...` | `import { pullMobileFactory } from "./apis/pullMobile.js";` |
| Trong `class API`, sau `public getStickersDetail: ...` | `public getSyncData: ReturnType<typeof getSyncDataFactory>;` |
| Trong `class API`, sau `public lockPoll: ...` | `public pullMobile: ReturnType<typeof pullMobileFactory>;` |
| Trong constructor, sau `this.getStickersDetail = ...` | `this.getSyncData = getSyncDataFactory(ctx, this);` |
| Trong constructor, sau `this.lockPoll = ...` | `this.pullMobile = pullMobileFactory(ctx, this);` |

### 4.5. `src/apis/listen.ts` — 3 chỗ

Diff gốc có 5 hunk nhưng **2 hunk chỉ là dấu phẩy cuối do Prettier** — bỏ qua. Ba chỗ thật:

**(a)** Thêm import, sau dòng import `DeliveredMessage`:

```ts
import { initializeSyncEvent, type SyncEvent, type TSyncEvent } from "../models/SyncEvent.js";
```

**(b)** Trong `interface ListenerEvents`, sau `cipher_key: [key: string];`:

```ts
sync_event: [data: SyncEvent];
```

**(c)** Điểm chèn quan trọng nhất. Trong 2.1.2, khối `cmd == 601` có chuỗi `if / else if` theo `control.content.act_type`:

- dòng ~296: `act_type == "file_done"`
- dòng ~307: `act_type == "group"`
- dòng ~327: `act_type == "fr"`  ← nhánh friend event
- dòng ~356: dấu `}` đóng nhánh `"fr"`  ← **chèn ngay đây**

Thay `}` đóng nhánh `"fr"` (ngay sau `this.emit("friend_event", friendEvent);`) bằng:

```ts
                        } else if (control.content.act_type == "syncmsgmb") {
                            const syncEventData: TSyncEvent =
                                typeof control.content.data == "string"
                                    ? JSON.parse(control.content.data)
                                    : control.content.data;
                            const syncEvent = initializeSyncEvent(
                                syncEventData,
                                control.content.act,
                                control.content.act_type,
                            );
                            this.emit("sync_event", syncEvent);
                        }
```

> Dùng **anchor text** `this.emit("friend_event", friendEvent);` để định vị thay vì tin vào số dòng — số dòng sẽ trôi khi main cập nhật.

### 4.6. `src/utils.ts` — chỉ port 1 hunk trong 3

Diff `utils.ts` có 3 hunk:

| Hunk | Nội dung | Port? |
|---|---|---|
| ~dòng 288 | Format lại khối `isBun ? ... : ...` | ❌ Prettier, bỏ |
| ~dòng 302 | Format lại `setCookie` xuống dòng | ❌ Prettier, bỏ |
| **~dòng 744** | **Thêm `generateRSAKeyPair()` + `decryptWithPrivateKey()`** | ✅ **Port** |

Chèn 2 hàm ngay sau `validatePin()`, trước comment `Converts a hex color code...`. Nội dung lấy nguyên từ:

```bash
git diff 6f0c2f98e6437844498151551cb7232909bdcae3 sync/feat/sync -- src/utils.ts
```

Tóm tắt để bạn biết mình đang port gì:

```ts
generateRSAKeyPair()
// → RSA 2048 bit, publicKey SPKI/PEM, privateKey PKCS8/PEM
// → trả { publicKeyBase64, privateKeyBase64, publicKey, privateKey }
// → publicKeyBase64 đã strip header/footer/whitespace — đây là thứ gửi lên Zalo

decryptWithPrivateKey(privateKey, encryptedBase64)
// → crypto.privateDecrypt, padding RSA_PKCS1_PADDING
// → THROW nếu kết quả ≠ 32 bytes
// → 32 bytes đó chính là khoá AES-256
```

### 4.7. Xuất ra `src/index.ts` (khuyến nghị)

Để consumer import được type, thêm:

```ts
export * from "./models/SyncEvent.js";
export type { GetSyncDataParams } from "./apis/getSyncData.js";
export type { PullMobileParams, PullMobileResponse } from "./apis/pullMobile.js";
```

### 4.8. Quản lý fork

**Không sửa trong `node_modules`.** Chọn một trong hai:

```jsonc
// Cách 1 — trỏ thẳng vào fork của bạn
"dependencies": {
  "zca-js": "github:<your-org>/zca-js#v2.1.2-sync"
}
```

```bash
# Cách 2 — patch-package
npm i -D patch-package
# sửa trong node_modules/zca-js rồi:
npx patch-package zca-js
# thêm "postinstall": "patch-package" vào scripts
```

Cách 1 tốt hơn nếu bạn định đóng góp ngược lại PR #269.

### 4.9. Kiểm chứng port xong

```bash
npm run build     # hoặc bun run build
node -e "const {Zalo}=require('./dist'); console.log('ok')"
```

TypeScript phải compile sạch. `api.pullMobile` và `api.getSyncData` phải có gợi ý kiểu.

---

## 5. Decoder `.db.crypt` hiện tại

Decoder production nằm ở `worker/src/zalo/db-crypt.ts`. Không cần Zalo PC, không cần tunnel về Mac, chạy được trong Docker Linux/VPS nếu image có `xzcat`.

### 5.1. Công thức giải mã đã kiểm chứng

File `SYNCMSG_INFO` trả về không phải SQLite trực tiếp. Nó là container `ZDB4.0` đã mã hoá và nén:

```
.db.crypt
  └─ AES-256-CBC
       key = ASCII(UPPERCASE_HEX(aesKey)).slice(0, 32)
       iv  = 16 byte zero
       chunk = 64 KiB, reset IV ở đầu mỗi chunk
       padding = off
       └─ ZDB4.0 container
            ├─ header + danh sách file .db theo thread
            └─ XZ stream
                 └─ nhiều SQLite DB ChatContent
```

Điểm dễ sai nhất là **chunk 64KiB**. Nếu decrypt liên tục cả file, header đầu vẫn ra `ZDB4.0` nhưng XZ sẽ báo `Compressed data is corrupt` với file lớn. Đây là lý do account `29` file nhỏ từng chạy được, còn account `1` file lớn fail cho tới khi sửa chunk reset.

### 5.2. Validate download trước khi decode

`mobile-sync.ts` tải bằng `api.getSyncData({ returnStream: true })`, ghi thẳng ra đĩa rồi kiểm:

| Check | Nguồn |
|---|---|
| Kích thước file | `SYNCMSG_INFO.file_size` |
| MD5 file mã hoá | `SYNCMSG_INFO.checksum_code` |
| Metadata kỳ vọng | `db_info.origin_db` / `db_info.backup_db` |

Nếu CDN trả 404 ngay sau `syncmsg_info`, worker retry cùng URL theo delay ngắn. Đây là hành vi bình thường: Zalo có thể bắn metadata trước khi file thật sự sẵn sàng.

### 5.3. Parse ZDB4

`parseZdb4Container()` đọc:

- magic `ZDB4.0`
- offset cuối vùng hash/header
- checksum XXHash32 nội bộ
- số file SQLite
- từng entry `{ name, size }`
- XZ magic `fd 37 7a 58 5a 00`

Checksum XXHash32 hiện chỉ dùng như diagnostic, không hard-fail. Lý do: Zalo PC native vẫn chấp nhận một số file lớn có checksum nội bộ lệch, trong khi XZ + SQLite là validation thực tế chắc hơn.

### 5.4. Giải nén stream để tránh OOM

Với backup lớn, tổng SQLite sau giải nén có thể vài trăm MB. Worker không gom tất cả vào RAM mà:

1. spawn `xzcat`
2. đọc stdout theo chunk
3. cắt theo size từng entry trong ZDB4
4. ghi ra file tạm `tmp/<syncRunId>-zdb-<index>-<name>.db`
5. import xong DB nào xoá DB đó

Image worker phải cài `xz`:

```dockerfile
RUN apk add --no-cache dumb-init xz
```

### 5.5. Fallback native bridge

`MOBILE_SYNC_NATIVE_DECODER_URL` hiện mặc định rỗng. Trên VPS Linux không cần native bridge.

Nếu sau này Zalo đổi format làm JS decoder không đọc được, worker sẽ fallback sang native bridge nếu biến này được cấu hình. Khi không có bridge, worker lưu artifact vào:

```
/app/storage/zalo-mobile-sync/failed/<accountId>/*.db.crypt
/app/storage/zalo-mobile-sync/failed/<accountId>/*.json
```

File `.json` có `aes_key_hex`, `db_info`, diagnostics để probe lại.

---

## 6. Parser SQLite và chuẩn hoá tin nhắn

Parser production nằm ở `worker/src/zalo/sync-db-parser.ts`.

### 6.1. Mỗi SQLite DB là một thread

ZDB4 bung ra nhiều file `.db`, thường tên file chứa id hội thoại. `inferChatContentThreadFromDbName()` suy ra:

| DB name | Thread |
|---|---|
| `<plainUserId>.db` | Chat 1-1 |
| `<plainGroupId>.db` | Group |

Sau đó `resolveNoiseIds()` map plain id sang noise id runtime để conversation trong UI không hiện toàn số lạ và khớp với tin realtime.

### 6.2. Bảng chính

File sync hiện dùng schema `ChatContent`. Parser đọc các message row, chuẩn hoá về `NormalizedMessage` giống listener realtime:

- `msg_id`
- `cli_msg_id`
- `thread_id`
- `thread_type`
- `direction`
- `sender_id`
- `sender_name`
- `content`
- `msg_type`
- `attachments`
- `quote_payload`
- `quote_preview`
- `mentions`
- `sent_at`
- `is_backfill = true`

### 6.3. BinNet payload

Nội dung media/attachment trong DB sync có phần payload BinNet. Parser JS đã xử lý best-effort:

| Loại | Kết quả |
|---|---|
| Ảnh | Lấy URL ảnh, gồm cả URL `.jxl` cũ |
| Video | Lấy URL video/thumb nếu payload có |
| Sticker | Lấy sticker id/url nếu có |
| File | Lấy tên file/url nếu có |
| Mention/quote | Giữ thông tin đủ để UI hiển thị và gửi quote về sau |

Nếu cấu hình native bridge, `parseBinNetWithNativeBridge()` có thể bổ sung kết quả parse. Mặc định JS parser là đường chính.

### 6.4. Tên người gửi trong group

Tin group trong DB sync có thể thiếu tên hoặc chỉ có id số. Luồng hiện tại bổ sung bằng:

1. thông tin sender trong row/payload nếu có;
2. dữ liệu mention/display name trong BinNet nếu có;
3. profile/thread mapping qua `resolveNoiseIds()`;
4. fallback cuối cùng mới là id.

Mục tiêu: cùng một avatar/người gửi không lúc hiện `An Phuc`, lúc hiện `296255707`.

---

## 7. Luồng production hiện tại

### 7.1. Luồng đầy đủ

```
1. Session Zalo READY, WebSocket đang nghe `sync_event`
2. User bấm đồng bộ tin cũ trên UI
3. MobileSyncCoordinator sinh RSA keypair tạm
4. api.pullMobile({
     public_key,
     from_seq_id: lastSeqId,
     is_retry: 0,
     min_seq_id: 0
   })
5. UI chờ người dùng mở Zalo điện thoại và bấm xác nhận
6. USER_CONFIRM  → status `confirmed`
7. SYNCMSG_INFO  → status `downloading`
8. Tải `.db.crypt` bằng stream, validate size + MD5
9. decryptWithPrivateKey(privateKey, encrypted_key) → AES key 32 bytes
10. decodeDbCryptToDirectory()
      AES-CBC chunk 64KiB → ZDB4.0 → XZ → nhiều SQLite DB
11. Với từng DB:
      infer thread id
      resolve plain id → noise id
      parse BinNet
      extract ChatContent messages
      enqueueBackfill() từng tin
12. messageForwarder đẩy batch về Laravel và chờ khi queue cao
13. Flush nốt batch cuối
14. Lưu `from_seq_id` / `lastSyncedAt` / `lastImportedCount`
15. Xoá file tạm và private key
16. Status `done`
```

Quan trọng: state sync chỉ được lưu **sau khi flush xong batch cuối**. Không lưu mốc trước khi Laravel nhận, vì nếu worker restart giữa chừng thì lượt sau sẽ tưởng đã sync đủ.

### 7.2. Năm thứ bắt buộc xử lý

**(1) Bước xác nhận thủ công — không tự động hoá được.**
`USER_CONFIRM` chỉ đến khi người dùng chạm vào điện thoại. Onboarding phải có màn chờ, timeout (khuyến nghị 120s), nút thử lại. Thiết kế UX quanh giới hạn này **ngay từ đầu**, đừng phát hiện lúc demo cho khách.

**(2) `TRANSFER_ERROR`.**
```ts
if (event.type === SyncEventType.TRANSFER_ERROR) {
  const { can_retry, error_code, error_msg } = event.data;
  if (can_retry) await api.pullMobile({ public_key, is_retry: 1, from_seq_id: lastSeq });
  else showUserError(error_msg);
}
```

**(3) Full vs incremental.**
Lần đầu **phải** có `is_full_transfer === 1`. Nếu Zalo trả `0`, thử lại với `from_seq_id: 0, min_seq_id: 0`. Lưu `from_seq_id` cuối cùng — đó là cách bạn sync bù khi bot offline vài ngày.

Hiện worker log warning nếu lượt đầu `lastSeqId === 0` mà Zalo trả `is_full_transfer !== 1`, nhưng vẫn import tiếp vì có dữ liệu còn hơn bỏ trắng. Khi cần test full lại, xoá state tương ứng trong `/app/storage/zalo-mobile-sync/state/<accountId>.json`.

**(4) Chống trùng ở Laravel.**
```sql
CREATE UNIQUE INDEX ux_msg ON messages(account_id, msg_id);
INSERT INTO messages (...) VALUES (...) ON CONFLICT DO NOTHING;
```
Không dedup bằng nội dung + timestamp — tin nhắn giống hệt nhau trong group là chuyện bình thường.

**(5) Dùng stream và backpressure, không dùng buffer/firehose.**
`getSyncData` mặc định gom cả file vào RAM qua `downloadFile()`. Tài khoản chạy vài năm có thể ra file vài trăm MB; nhân với số tài khoản onboard song song là OOM.
```ts
const res = await api.getSyncData({ ...event.data, returnStream: true });
// res.stream.stream là ReadableStream → pipe thẳng qua decipher → ghi đĩa
```

Sau khi parse, không gọi `dispatchMessage()` ồ ạt. Backfill phải đi qua:

```ts
await messageForwarder.enqueueBackfill(accountId, message);
```

`enqueueBackfill()` sẽ:

- chờ queue xuống dưới high watermark;
- gửi khi đủ `MESSAGE_BATCH_SIZE`;
- `await flush()` để Laravel nhận xong;
- log `Tạm dừng import tin nhắn cũ để chờ Laravel xả hàng đợi` nếu cần nghỉ.

Tin realtime vẫn dùng `enqueue()` nhanh như cũ để không làm chậm trả lời khách đang chat.

### 7.3. Queue/backpressure

Trước khi sửa backpressure, một lượt account `1` import `337267` tin trong vài giây và làm queue 10k tràn, log drop hơn `326k` tin. Đây là lỗi mất dữ liệu.

Thiết kế hiện tại:

| Luồng | API | Hành vi |
|---|---|---|
| Realtime WebSocket | `messageForwarder.enqueue()` | Nhanh, ưu tiên tin mới; vẫn có trần queue để bảo vệ RAM |
| Mobile sync/backfill | `messageForwarder.enqueueBackfill()` | Chậm có kiểm soát; chờ Laravel xả, không drop vì số lượng lớn |

Các biến môi trường liên quan:

| Biến | Mặc định | Ý nghĩa |
|---|---:|---|
| `ZALO_MESSAGE_BATCH_SIZE` | `50` | Số tin mỗi POST `/api/worker/messages` |
| `ZALO_MESSAGE_FLUSH_INTERVAL_MS` | `500` | Chu kỳ flush realtime |
| `ZALO_MESSAGE_QUEUE_MAX` | `10000` | Trần queue bảo vệ RAM |

Nếu import quá chậm nhưng Laravel/MySQL chịu được tải cao hơn, tăng `ZALO_MESSAGE_BATCH_SIZE` trước. Không tăng queue max để che lỗi, vì queue lớn chỉ làm mất dữ liệu muộn hơn và ăn RAM hơn.

### 7.4. Vòng đời khoá và file

| Tài sản | Quy tắc |
|---|---|
| `privateKey` | Chỉ giữ trong RAM hoặc store tạm có TTL. Xoá sau khi import xong |
| `.db.crypt` | Xoá **ngay** sau khi giải mã |
| `.db` đã giải | Xoá **ngay** sau khi import. Không để trên đĩa |
| DB của bạn | Mã hoá at-rest |

Khi cần debug decoder, bật `MOBILE_SYNC_KEEP_FAILED_ARTIFACTS=true` để giữ file fail trong volume worker. Chỉ giữ artifact ở môi trường dev/staging; file này chứa lịch sử hội thoại.

---

## 8. Nền bắt buộc: listener 24/7

Backfill đã chạy được, nhưng vẫn không thay thế listener realtime:

```
listener 24/7  →  ghi mọi message vào DB riêng của bạn
```

Từ thời điểm cắm bot, bạn **sở hữu** lịch sử, không phải đi xin Zalo mỗi lần. Backfill dùng để lấp dữ liệu trước khi bot được cắm, hoặc repair sau khi mất phiên lâu.

Kiến trúc đúng:
- **Đường găng:** listener + DB riêng → sản phẩm chạy được ngay
- **Cộng thêm:** backfill qua `pull_mobile_msg` ở màn onboarding/repair

Nếu một ngày Zalo đổi/gỡ `pull_mobile_msg`, sản phẩm vẫn giữ được dữ liệu từ thời điểm listener đã chạy.

---

## 9. Danh sách rủi ro

| Mức | Rủi ro | Giảm thiểu |
|---|---|---|
| 🔴 Cao | Zalo đổi/gỡ `pull_mobile_msg` | Không đặt lên đường găng. Có Mục 8 làm nền |
| 🔴 Cao | PR #269 không bao giờ được merge, bạn ôm fork mồ côi | Giữ patch `zca-js` nhỏ, tách decoder trong code worker để không phụ thuộc upstream merge |
| 🔴 Cao | File sync chứa **toàn bộ** hội thoại, gồm tin nhắn của bên thứ ba chưa từng đồng ý với sản phẩm của bạn | Xoá file ngay sau import, mã hoá at-rest, có đường xoá theo yêu cầu, ghi rõ trong điều khoản. Rủi ro pháp lý ở đây lớn hơn rủi ro bị khoá acc |
| 🟠 TB | Zalo đổi format ZDB/BinNet | Lưu artifact fail ở dev/staging, dùng `probe-dbcrypt.mjs`, so với native Zalo PC nếu cần |
| 🟠 TB | Laravel/MySQL nhận chậm khi import vài trăm nghìn tin | Backpressure `enqueueBackfill()`, tăng batch size có kiểm soát, theo dõi queue |
| 🟠 TB | Tài khoản bị vô hiệu hoá | Dùng tài khoản phụ. Đọc discussion #335. Không cắm vào tài khoản đang chạy business thật |
| 🟡 Thấp | OOM khi nhiều tài khoản sync song song | `returnStream: true`, XZ stream ra file, giới hạn số tài khoản sync cùng lúc |
| 🟡 Thấp | Credential hết hạn | Worker báo `RELOGIN_REQUIRED`; người dùng quét QR lại |

---

## 10. Checklist vận hành/test hiện tại

**Trước khi sync**
- [ ] Account ở trạng thái READY; nếu `RELOGIN_REQUIRED` thì quét QR lại
- [ ] Worker image có `xzcat`: `docker compose exec worker command -v xzcat`
- [ ] Laravel endpoint `/api/worker/messages` trả 200
- [ ] Bảng message có unique theo account + msg id để upsert idempotent
- [ ] Nếu muốn full lại từ đầu, xoá state `/app/storage/zalo-mobile-sync/state/<accountId>.json`

**Trong lúc sync**
- [ ] UI báo `waiting_confirm` rồi `confirmed`
- [ ] Log có `Đã gửi yêu cầu đồng bộ tin nhắn cũ`
- [ ] Nếu CDN 404 lần đầu, worker retry cùng URL
- [ ] Log decoder là `zdb4 aes-256-cbc ... chunk=64k xz-stream`
- [ ] Không còn log `Chưa decode được file đồng bộ tin nhắn cũ`
- [ ] Không còn log `Hàng đợi tin nhắn tràn - đã bỏ tin cũ nhất`
- [ ] Nếu Laravel chậm, có thể thấy log `Tạm dừng import tin nhắn cũ để chờ Laravel xả hàng đợi`

**Sau khi sync**
- [ ] Log `Đã nhập xong lô đồng bộ tin nhắn cũ bằng decoder ZDB`
- [ ] `imported` xấp xỉ `db_info.backup_db.msg_total`
- [ ] `databaseCount` xấp xỉ `db_info.backup_db.msg_thread`
- [ ] Health `queued_messages` về 0 sau khi xả xong
- [ ] Tin group hiện tên người gửi, không fallback id nếu profile có thể resolve
- [ ] Ảnh/video/file cũ hiển thị được, gồm URL đuôi `.jxl`

---

## 11. Phụ lục — tra cứu nhanh

### Endpoint

```
POST/GET  ${zpwServiceMap.file[0]}/api/message/pull_mobile_msg
  params (đã encodeAES):
    pc_name       = "Web"      // cố định, không đổi
    public_key    = <base64 SPKI đã strip header>
    from_seq_id   = 0
    is_retry      = 0
    min_seq_id    = 0
    temp_key      = ""
    imei          = ctx.imei
  query: zpw_ver, zpw_type, params, nretry=0
  ⚠ Response rỗng — dữ liệu thật về qua websocket
```

### Websocket

```
cmd == 601  →  control.content.act_type == "syncmsgmb"
            →  control.content.act ∈ { user_confirm, syncmsg_info, transfer_error }
            →  emit "sync_event"
```

### Lệnh hay dùng

```bash
# Xem diff PR sạch, không lẫn commit main
git diff 6f0c2f98e6437844498151551cb7232909bdcae3 sync/feat/sync -- <file>

# Kiểm tra magic bytes
xxd -l 32 file.db.crypt

# Xác nhận file đã giải là SQLite
file decrypted.db
sqlite3 decrypted.db "PRAGMA integrity_check;"

# Kiểm tra version zca-js đang dùng
npm ls zca-js

# Theo dõi luồng đồng bộ trong worker
docker logs -f zalo-worker | grep -i "đồng bộ\|sync\|zdb\|queue\|hàng đợi"

# Lọc kết quả cuối một lượt sync
docker logs --since 30m zalo-worker 2>&1 \
  | grep -E "Đã nhập xong|Chưa decode|Đồng bộ tin nhắn cũ thất bại|Hàng đợi tin nhắn tràn"

# Kiểm tra queue worker
docker compose exec -T worker node -e \
  "fetch('http://127.0.0.1:3500/api/health').then(r=>r.text()).then(console.log)"

# Kiểm tra state sync theo account
docker compose exec -T worker sh -lc \
  'find /app/storage/zalo-mobile-sync/state -type f -maxdepth 2 -print -exec cat {} \;'
```

### Ma trận version `getGroupChatHistory`

| Version | Có hàm |
|---|---|
| 2.0.1 – 2.0.5 | ❌ |
| 2.1.0 – 2.1.2 | ✅ (nhưng endpoint đang 404) |

---

*Tài liệu dựa trên khảo sát trực tiếp source `zca-js@2.1.2` (commit `9479746`), branch `RodrickSia:feat/sync` (commit `29d01c4`) và triển khai thực tế trong worker Docker ngày 10/08/2026. Trạng thái upstream có thể đã thay đổi — kiểm tra lại PR #269 và issue #356/#367 khi nâng version.*
