# Chuyển worker từ Playwright sang giao thức Zalo (zca-js)

Tài liệu này mô tả **toàn bộ luồng hệ thống sau khi chuyển đổi**, và **trạng thái công việc
tính đến 01/08/2026**.

Mục tiêu của việc chuyển đổi: chạy được **~100 tài khoản Zalo** trên một server, thay vì
trần thực tế 3–8 tài khoản của kiến trúc cũ.

---

## 1. Vì sao phải đổi

Kiến trúc cũ: **1 tài khoản Zalo = 1 trình duyệt Chromium**. Đo trên máy hiện tại:

| Hạng mục | Bản cũ (Playwright) | Bản mới (zca-js) |
|---|---|---|
| RAM mỗi tài khoản | ~800 MB | ~10–30 MB *(chưa đo thực tế, xem mục 8)* |
| Image worker | **3,6 GB** | **267 MB** ✅ đã đo |
| node_modules | 75 MB + Chromium ~1,5 GB | 11 MB |
| Nhận tin mới | quét DOM mỗi 2 giây, mỗi tài khoản 1 lời gọi tuần tự | WebSocket đẩy tới |
| Trần tài khoản | `MAX_CONCURRENT_SESSIONS=3` | không còn trần |

Hai lỗi chí mạng của bản cũ khi mở rộng:

1. **`MAX_CONCURRENT_SESSIONS=3` khiến 97/100 tài khoản im lặng không hoạt động.**
   Vòng quét auto-reply lọc `status = READY`, mà tài khoản không có phiên Chromium sống thì
   không READY → bị loại khỏi vòng quét. Không lỗi, không log, bot chỉ đơn giản là câm.
2. **Vòng quét là O(số tài khoản) lời gọi tuần tự**, mỗi lời gọi là một lần đọc DOM thật.
   Với 100 tài khoản, một vòng "2 giây" mất hàng phút.

Cả hai đều không sửa được bằng chỉnh tham số — phải bỏ Chromium khỏi đường chạy chính.

---

## 2. Kiến trúc: trước và sau

### Trước

```
Người dùng → Laravel → (hỏi) → Worker → Chromium đọc DOM Zalo Web → trả về
                                   ↑
                     auto-reply quét mỗi 2s, mỗi tài khoản 1 lần
```

Zalo Web là **nguồn sự thật**. Muốn biết có tin mới thì phải đi hỏi.

### Sau

```
Zalo ──WebSocket──→ Worker ──HTTP theo lô──→ Laravel ──→ MySQL
                                                 │
                                                 └─→ auto-reply (kích hoạt ngay tại lúc ghi tin)

Người dùng → Laravel → MySQL (đọc thẳng, không qua Worker)
Người dùng → Laravel → Worker → Zalo   (chỉ khi GỬI tin)
```

**MySQL là nguồn sự thật.** Worker chỉ còn hai việc: giữ WebSocket sống, và gửi tin đi.

Đây là thay đổi lớn nhất và là lý do phải tạo bảng mới: zca-js **không có API danh sách hội
thoại gần đây** (sidebar Zalo Web dựng ở phía client). Hệ thống buộc phải tự làm kho tin nhắn.

**Lịch sử cũ KHÔNG lấy lại được — kể cả nhóm.** Đây là kết luận sau khi đo, không phải suy
đoán; xem mục 3.5 để biết đã loại trừ những đường nào. Thứ duy nhất còn chạy là
`listener.requestOldMessages()` (gói `cmd 510/511, subCmd 1`), nhưng nó chỉ trả **hàng đợi
tin chưa giao cho client này**, không phải lịch sử tuỳ ý.

Hệ quả thực tế: **kho tin nhắn chỉ có những gì hệ thống nhận được từ lúc bật trở đi.** Vì thế
`zalo_messages` là dữ liệu không tái tạo được, và mọi thứ quanh nó — hàng đợi của worker,
`zalo_uid` để không mất khi xoá tài khoản — đều tồn tại vì lý do đó.

---

## 3. Các luồng chi tiết

### 3.1 Đăng nhập tài khoản

```mermaid
sequenceDiagram
    participant UI as Giao diện
    participant L as Laravel
    participant W as Worker
    participant Z as Zalo

    UI->>L: POST /api/zalo-accounts/{id}/open-session
    L->>W: POST /api/accounts/{id}/open
    alt Đã có credential trên đĩa
        W->>Z: login(imei + cookie + userAgent)
        Z-->>W: OK
        W->>W: mở WebSocket, trạng thái READY
        W->>L: POST /api/worker/status (READY)
    else Chưa có, hoặc credential hết hạn
        W->>Z: loginQR()
        Z-->>W: ảnh QR (PNG base64)
        W-->>L: LOGIN_REQUIRED (trả về NGAY, không chờ quét)
        UI->>L: GET .../screenshot (poll)
        L->>W: GET /api/accounts/{id}/screenshot
        W-->>UI: ảnh QR
        Note over Z: người dùng quét bằng điện thoại
        Z-->>W: GotLoginInfo { imei, cookie, userAgent }
        W->>W: lưu credential ra đĩa, mở WebSocket
        W->>L: POST /api/worker/status (READY)
    end
```

Điểm quan trọng:

- **Không còn trình duyệt nào**, kể cả ở bước quét QR. zca-js tự sinh mã QR.
- Endpoint `/screenshot` **giữ nguyên đường dẫn cũ** nên giao diện hiện tại hiển thị QR
  không phải sửa gì — trước đây nó là ảnh chụp màn hình Chromium, giờ là ảnh QR do Zalo
  trả về thẳng. Vẫn là PNG.
- Credential (`imei` + `cookie` + `userAgent`) là **một tệp JSON vài KB**, thay cho thư mục
  profile Chromium vài trăm MB. Nằm ở volume `worker-credentials`.
- **Worker khởi động lại sẽ tự đăng nhập lại toàn bộ tài khoản** (`AUTO_RESTORE_SESSIONS`,
  mặc định 5 tài khoản song song). Bắt buộc với 100 tài khoản.

### 3.2 Nhận tin nhắn

```mermaid
sequenceDiagram
    participant Z as Zalo
    participant W as Worker
    participant L as Laravel
    participant DB as MySQL
    participant Q as Queue

    Z->>W: WebSocket: tin nhắn mới
    W->>W: chuẩn hoá (msg_id, ts, direction, đính kèm)
    W->>W: đưa vào hàng đợi trong bộ nhớ
    Note over W: gom lô: đủ 50 tin HOẶC mỗi 500ms
    W->>L: POST /api/worker/messages (ký HMAC)
    L->>DB: upsert zalo_messages (unique: account_id + msg_id)
    L->>DB: cập nhật zalo_conversations (preview, thời gian, chưa đọc)
    L->>Q: dispatch RunAutoReplyJob (nếu hội thoại bật auto-reply)
```

Điểm quan trọng:

- **Đây là mắt xích dễ mất dữ liệu nhất.** Lượt đồng bộ (mục 3.5) chỉ xin lại được một lô
  tin gần đây, nên tin rơi ở bước này và trôi ra ngoài lô đó là mất. Vì thế:
  - Laravel chết → worker **giữ tin trong hàng đợi** và thử lại với backoff (1s → 30s).
  - Hàng đợi tràn (`MESSAGE_QUEUE_MAX`, mặc định 10.000) → bỏ tin **cũ nhất** và ghi log lỗi.
  - Worker tắt → `drain()` cố xả hết hàng đợi trước khi thoát.
  - Số tin đang chờ hiện ở `/api/health` (`queued_messages`) để giám sát.
- Chống trùng dựa vào **unique index `(zalo_account_id, msg_id)`**. Worker gửi lại nguyên lô
  khi Laravel trả lỗi, và `selfListen` làm tin tự gửi vọng về — cả hai đều va vào index này.
- Endpoint nhận tin **luôn trả 200** kể cả khi vài tin lỗi. Trả lỗi sẽ khiến worker gửi lại
  nguyên lô và các tin đã ghi thành công bị xử lý lại.
- **Tên hội thoại: danh bạ Zalo luôn thắng, tin nhắn chỉ là phương án chống cháy.**
  `ZaloAccountService::enrichConversations()` **ghi đè** `display_name` bằng tên từ danh bạ ở
  mỗi lượt đọc danh sách; chỉ khi tra không ra mới giữ tên suy từ tin nhắn. Hai bẫy thật đã
  dính, đều do tin lấy tên trực tiếp từ `sender_name`:
  - **Nhóm**: tin nhắn nhóm chỉ mang tên *người gửi*, không hề mang tên nhóm. Cả 3 nhóm trên
    tài khoản thật bị đặt tên theo người vô tình nhắn đầu tiên —
    `[Vietec] Tập huấn HanoiCheck` hiện thành `Billbee`. Nhóm cũng đổi tên được mà Zalo không
    báo sự kiện về đây, nên phải đọc lại mới biết.
  - **Chat 1-1**: Zalo có **hai** tên cho mỗi người. `zaloName` là tên họ tự đặt, `displayName`
    là tên *người dùng này* nhìn thấy (tên gợi nhớ nếu đã đặt, không thì rơi về `zaloName`).
    Tin nhắn mang `zaloName`, danh bạ mang `displayName`. Đo trên tài khoản thật: uid
    `8714356276248958579` có `zaloName="Liễu Dương"` nhưng `displayName="Em Bé"` — app Zalo
    hiện "Em Bé", nên chỗ này phải hiện y hệt.

  Vì thế ingest **không** đặt tên cho hội thoại nhóm (để trống, chờ `getGroupInfo`), còn
  chat 1-1 vẫn đặt tạm từ `sender_name` để có tên ngay, rồi bị danh bạ ghi đè sau.
- **Tên tự đặt nằm ở cột riêng `custom_name`**, không phải `display_name`. Người dùng bấm đúp
  vào tên ở đầu khung chat để sửa (`PATCH /api/zalo-accounts/{id}/conversations`). Vì
  `display_name` bị đồng bộ đè ở mỗi lượt đọc danh sách như trên, tên đặt tay vào đó sẽ sống
  đúng tới nhịp poll kế tiếp. Thứ tự ưu tiên khi hiển thị: `custom_name` → `display_name` →
  `thread_id`; xoá `custom_name` là rơi lại tên Zalo, không mất gì.

### 3.3 Gửi tin nhắn

```
UI → Laravel → tra thread_type từ zalo_conversations → Worker → zca-js sendMessage → Zalo
                                                                        ↓
                                            tin vọng về qua WebSocket (selfListen) → ghi DB
```

- `conversation_key` giờ là **thread_id thật của Zalo**, không còn là tên hiển thị.
- Phải kèm `thread_type` (`user`/`group`) — cùng một id có thể là người hoặc nhóm, gửi sai
  loại là Zalo từ chối.
- Đính kèm gửi thẳng dạng **Buffer**, không ghi tệp tạm ra đĩa như bản cũ.
  zca-js bắt buộc phải biết `width`/`height` của ảnh nhưng không tự đọc được → có
  `image-size.util.ts` đọc header PNG/JPEG/GIF/WebP/BMP.
- Gửi kèm `quote` thì zca-js đổi sang endpoint `/quote` của Zalo — xem mục 7e.

### 3.4 Auto-reply

**Trước:** tiến trình `ai:auto-reply` chạy vòng lặp vô hạn, mỗi 2 giây quét toàn bộ hội thoại
đang bật, băm nội dung để đoán có tin mới không (vì tin đọc từ DOM không có id lẫn thời gian).

**Sau:** không còn vòng quét nào. Khi `ZaloMessageIngestService` ghi xong một tin ĐẾN, nó gọi
`AutoReplyService::onIncomingMessage()` → kiểm tra hội thoại có bật auto-reply không → giành
quyền xử lý bằng một `UPDATE` có điều kiện trên `last_msg_id` (chống hai tiến trình php-fpm
cùng đẩy job) → `dispatch(RunAutoReplyJob)`.

Toàn bộ máy móc băm chữ ký, cache chữ ký danh sách, chuẩn hoá tên tiếng Việt,
`looksLikeCustomerMessage()`… đã **xoá hết** — chúng chỉ tồn tại vì tin từ DOM không có id.

### 3.5 Đồng bộ lịch sử tin nhắn

```
worker (WebSocket) --cmd 510/511 subCmd 1--> Zalo
Zalo --old_messages--> worker --is_backfill=true--> Laravel --> MySQL
```

**Nó làm được gì, và KHÔNG làm được gì** (đo trên tài khoản thật, 01/08/2026):

Lượt xin này trả về **hàng đợi tin chưa giao cho client này**, không phải lịch sử tuỳ ý. Tài
khoản vừa nối liên tục nhiều giờ thì hàng đợi rỗng, bấm bao nhiêu lần cũng trả 0; tài khoản
mới khôi phục phiên thì nhận được vài chục tin tồn. Nói cách khác: nó **vá đúng lỗ hổng mất
tin khi worker chết**, nhưng **không dựng lại được hội thoại mà máy này chưa từng nhận**.

Đừng đặt kỳ vọng "bấm Đồng bộ là ra lịch sử cũ" — không có đường nào làm được việc đó.

### Những đường đã dò và loại trừ (01/08/2026)

Đừng thử lại nếu chưa có thông tin mới — cả bốn đều đã đo trên tài khoản thật:

| Đường | Kết quả |
|---|---|
| `requestOldMessages` (`cmd 510/511`) | Chỉ trả hàng đợi tin chưa giao. Cạn rồi thì luôn 0. |
| Tuỳ chọn "đồng bộ tin nhắn" trên điện thoại | Đã hiện được khi thử `ZALO_QR_MODE=web` (xem `qr-login-mode.ts`) và người dùng đã bấm đồng ý. Nhưng bắt log **toàn bộ** khung WebSocket sau đó cho thấy chỉ có `cmd 1/510/511/621`, khung lớn nhất 410 byte — **không có dữ liệu nào tới**. Vì chế độ này có thể làm QR bị Zalo báo lỗi khi kiểm tra session, mặc định production đã quay về `ZALO_QR_MODE=pc`. |
| API lịch sử chat 1-1 qua HTTP | `chat[0]/api/message/{history,getmsgs,gethistory,sync}` × 3 kiểu tham số → **tất cả 404**. |
| `getGroupChatHistory` (lịch sử nhóm) | **404.** Đối chứng trên đúng host đó: `getGroupInfo`, `getAllGroups`, `getGroupMembersInfo` đều 200 → không phải lỗi xác thực hay dựng URL, mà endpoint `group/history` đã bị Zalo gỡ. zca-js 2.1.2 là bản mới nhất (17/03/2026) nên không có bản vá ngược dòng. |

Bản vá log khung WebSocket vẫn nằm ở `worker/patches/zca-js+2.1.2.patch`, mặc định tắt, bật
lại bằng `ZALO_WS_DEBUG=1` khi cần dò tiếp. Lưu ý nó in **nội dung tin nhắn** ra log.

Chạy ở hai thời điểm:

- **Tự động**, một lần cho mỗi phiên, ngay khi WebSocket vừa `connected` (phải chờ tới lúc
  này: gói xin lịch sử đi qua chính ws đó). Cờ `hasRequestedOldMessages` chặn việc xin lại
  mỗi lần rớt mạng nối lại.
- **Thủ công**, khi người dùng bấm *Đồng bộ tin nhắn*:
  `POST /api/zalo-accounts/{id}/sync` → `POST /api/accounts/{id}/sync` của worker.

**Bất đối xứng phải nhớ:** cả hai endpoint trả 200 nghĩa là *đã gửi yêu cầu*, không phải *đã
có dữ liệu*. Tin về sau đó qua WebSocket rồi mới chảy ngược về `/api/worker/messages`. Giao
diện vì thế tải lại danh sách hội thoại ở các mốc 2s/6s/12s chứ không phải một lần.

Tin của lượt đồng bộ mang cờ `is_backfill` và bị đối xử khác tin mới ở hai chỗ, cả hai đều
là lỗi thật nếu bỏ qua:

- **Không kích hoạt auto-reply.** Một lượt đồng bộ kéo về cả trăm tin cũ; tính chúng là tin
  mới thì agent trả lời lại loạt câu hỏi từ tuần trước — mà tin gửi rồi không thu hồi được.
- **Không thắp huy hiệu chưa đọc.** Hội thoại **chưa từng được mở** ở đây (`last_read_at`
  rỗng) được đặt mốc đọc bằng tin mới nhất của lô: người dùng đã đọc đống đó trên điện thoại
  rồi. Hội thoại đã mở thì **không đụng vào** — mốc đọc của người thật đáng tin hơn, và bấm
  đồng bộ thủ công không được phép xoá số chưa đọc đang chờ xử lý.

### 3.6 Danh tính tài khoản: `zalo_uid`, không phải `zalo_accounts.id`

`zalo_accounts.id` là số tự tăng của riêng hệ thống này. Mọi bảng dữ liệu đều
`cascadeOnDelete` theo `zalo_account_id`, nên **xoá cứng một tài khoản là quét sạch toàn bộ
hội thoại, tin nhắn và danh bạ của nó** — tạo lại thì được một id mới, rỗng trơn, dù vẫn là
cùng một người dùng Zalo.

Cách xử lý:

- `zalo_accounts.zalo_uid` giữ id do chính Zalo cấp (`getOwnId`), điền khi worker báo trạng
  thái kèm `own_id` — chỉ biết được **sau** khi đăng nhập xong, nên không điền lúc tạo được.
- Xoá tài khoản là **xoá mềm**, và gọi `DELETE /api/accounts/{id}` của worker để nó **quên
  credential**. Chỉ `closeSession` thì tệp credential ở lại, lần worker khởi động sau nó vẫn
  khôi phục phiên cho một tài khoản Laravel không còn tồn tại: phiên Zalo bị chiếm vô ích, và
  nếu người dùng đã tạo lại tài khoản thì **hai phiên cùng một tài khoản Zalo đá nhau bằng mã
  đóng 3000**.
- Đăng nhập bằng đúng tài khoản Zalo cũ → `adoptHistoryFromDeletedAccount()` đổi chủ toàn bộ
  dữ liệu của bản ghi đã xoá mềm sang bản ghi mới bằng `UPDATE zalo_account_id`, rồi xoá hẳn
  bản ghi cũ. Đổi chủ chứ không sao chép: `zalo_messages` trỏ vào `zalo_conversation_id`, giữ
  nguyên id hội thoại thì mọi liên kết còn nguyên.
- Bảng nào chống trùng theo cặp `(tài khoản, khoá)` thì phải dọn bản ghi đụng nhau trước khi
  `UPDATE`, nếu không sẽ chết vì unique index. Bản của tài khoản **mới** được giữ.
- Bước nhận lịch sử chạy ở **mọi** lần báo trạng thái, không gắn với việc uid có đổi hay
  không, và lỗi của nó được bắt lại chứ không kéo sập callback. Uid ghi trước, nhận lịch sử
  chạy sau: buộc hai việc vào nhau thì một lần hỏng giữa chừng là uid đã lưu, lần sau guard
  "uid không đổi" chặn luôn và lịch sử kẹt vĩnh viễn ở bản ghi cũ. Đã dính đúng lỗi này.

> **Test chạy SQLite, production chạy MySQL** (`phpunit.xml` đặt `DB_CONNECTION=sqlite`).
> Chỗ này từng giấu một lỗi thật: SQLite cho phép subquery trỏ vào chính bảng đang `DELETE`,
> MySQL cấm (lỗi 1093). Test xanh nhưng máy thật nổ. Với thay đổi động tới SQL thô, hãy chạy
> thêm trên MySQL:
> ```
> docker exec zalo-mysql mysql -uroot -proot -e \
>   "CREATE DATABASE IF NOT EXISTS zalo_chat_test; GRANT ALL ON zalo_chat_test.* TO 'zalo'@'%';"
> docker exec -e DB_CONNECTION=mysql -e DB_DATABASE=zalo_chat_test zalo-app php vendor/bin/phpunit
> ```

Ingest cũng lọc bỏ tin của tài khoản không còn tồn tại — credential mồ côi từng làm hỏng
**nguyên lô**, kéo theo cả tin của những tài khoản đang chạy bình thường trong cùng lô đó.

### 3.7 Danh bạ

Danh sách hội thoại dựng từ bảng tin nhắn nên người **chưa từng nhắn tin** là vô hình. Màn
Danh bạ (biểu tượng sổ danh bạ ở `account-rail`) lấy thẳng từ Zalo:

```
Laravel --GET /api/accounts/{id}/contacts--> worker --getAllFriends + getAllGroups--> Zalo
```

- Kết quả được cache 10 phút (`ZaloContactService::directory()`) và đổ luôn vào
  `zalo_contacts`, nên phần tra ảnh đại diện dùng chung được hưởng.
- Lọc theo từ khoá chạy **tại trình duyệt**, bỏ dấu hai bên — gõ "hung" ra "Hưng". Mỗi phím
  gõ mà một request thì không kham nổi.
- Bấm vào một liên hệ → `POST /api/zalo-accounts/{id}/contacts/open`. Bắt buộc đi qua đây chứ
  không dựng hội thoại ở phía trình duyệt: `sendMessage` suy `thread_type` từ bảng
  `zalo_conversations` và mặc định `'user'` khi không thấy — nhắn cho một **nhóm** chưa có bản
  ghi sẽ bị Zalo từ chối vì sai loại thread.

### 3.8 Hội thoại ẩn ("trò chuyện ẩn" của Zalo)

Zalo cho phép ẩn một cuộc trò chuyện sau mã PIN. Vấn đề: **tin nhắn của hội thoại ẩn về qua
WebSocket y hệt tin thường** — không cờ, không dấu hiệu nào trong bản thân tin nhắn. Đã kiểm
chứng trên tài khoản thật (người tên "Pt 다", thread `8239336708917554116`): ở mức dữ liệu tin
nhắn, hội thoại ẩn không phân biệt được với hội thoại bình thường.

Đường duy nhất biết được:

```
Laravel --GET /api/accounts/{id}/hidden-conversations--> worker --getHiddenConversations--> Zalo
```

- Zalo **không đẩy sự kiện** nào khi người dùng ẩn/bỏ ẩn, nên phải hỏi lại theo nhịp.
  `ZaloHiddenConversationService` cache 60 giây → giao diện poll 8 giây vẫn chỉ tốn một vòng
  gọi Zalo mỗi phút. 60 giây cũng chính là độ trễ tối đa từ lúc ẩn trên điện thoại tới lúc
  biến mất ở đây.
- Kết quả áp vào cột `zalo_conversations.is_hidden`. Phải là cột thật chứ không lọc trong
  PHP: sidebar phân trang bằng SQL, lọc sau `LIMIT` thì một trang toàn hội thoại ẩn sẽ trả
  về rỗng dù bên dưới còn hội thoại thường.
- **Hỏi hỏng thì giữ nguyên cờ cũ.** Coi lỗi worker là "không có gì bị ẩn" sẽ làm mọi hội
  thoại riêng tư bật ra giữa màn hình đúng lúc hệ thống đang trục trặc.
- Mã PIN Zalo trả kèm bị bỏ ngay tại worker — không log, không chuyển về Laravel.
- Đường ghi tin nhắn (`ZaloMessageIngestService`) chỉ **đọc cache**, không gọi Zalo: mỗi lô
  tin mà thêm một vòng HTTP thì luồng ingest tắc. Cache được nạp sẵn khi phiên vào `READY`
  (callback `/api/worker/status`), nên tin đầu tiên của một người bị ẩn cũng đã ẩn sẵn chứ
  không loé lên sidebar rồi mới biến mất.
- Ẩn **khác** xóa: tin nhắn vẫn được ghi xuống đầy đủ, bỏ ẩn bên Zalo là hội thoại quay lại
  nguyên vẹn.
- Bịt hết các cửa: danh sách, tìm kiếm, đọc tin (`hidden: true` để giao diện đóng lặng lẽ),
  và **màn Danh bạ** — người bị ẩn vẫn nằm trong danh bạ Zalo, không lọc thì bấm một cái là
  hội thoại ẩn mở toang.
- AI tự trả lời **không chạy** trong hội thoại ẩn: người trực không nhìn thấy hội thoại đó ở
  đâu cả, nên agent nhắn trong đó là thứ không ai giám sát được.

---

## 4. Dữ liệu mới

| Bảng | Vai trò |
|---|---|
| `zalo_conversations` | Danh sách hội thoại. Unique `(zalo_account_id, thread_id)`. Giữ sẵn preview + thời gian tin cuối để sidebar không phải join. |
| `zalo_messages` | Kho tin nhắn. Unique `(zalo_account_id, msg_id)` — chốt chặn chống trùng duy nhất. Thêm `quote_payload` / `quote_preview` cho tính năng trả lời (mục 7e). |
| `ai_auto_replies` | Thêm `thread_id`, `thread_type`, `last_msg_id`. |

**Thay đổi định danh:** hội thoại trước đây được khoá bằng **tên hiển thị**, giờ khoá bằng
**thread_id**. Đổi tên hội thoại không còn làm mất cấu hình.

**Hệ quả:** các cấu hình auto-reply cũ đã bị **tắt tự động** trong migration
(`2026_08_01_000003`), vì không có cách tin cậy nào suy ra thread_id từ tên. Dữ liệu vẫn còn
để biết trước đây đã bật cho hội thoại nào — người dùng phải bật lại trên giao diện.

---

## 5. Hợp đồng API Worker: cũ → mới

| Đường dẫn | Trạng thái |
|---|---|
| `POST /api/accounts` | giữ (profile_key thành tuỳ chọn, không dùng) |
| `POST /api/accounts/{id}/open` | giữ — nay là đăng nhập giao thức hoặc mở luồng QR |
| `POST /api/accounts/{id}/close` | giữ |
| `GET /api/accounts/{id}/status` | giữ (thêm `own_id`, `display_name`, `credential_exists`) |
| `GET /api/accounts/{id}/screenshot` | giữ — nay trả ảnh QR |
| `POST /api/accounts/{id}/messages` | giữ — thêm `thread_type`, và `quote` để trả lời một tin |
| `POST /api/accounts/{id}/relogin` | **mới** — buộc quét QR lại |
| `DELETE /api/accounts/{id}` | **mới** — xoá phiên + credential |
| `GET /api/accounts/{id}/qr` | **mới** — tên rõ nghĩa của `/screenshot` |
| `GET /api/accounts/{id}/contacts` | **mới** — danh bạ + nhóm (tên, ảnh đại diện) |
| `POST /api/accounts/{id}/sync` | **mới** — xin Zalo gửi lại lịch sử tin nhắn |
| `GET /api/accounts/{id}/groups/{threadId}/history` | **mới** — lịch sử nhóm |
| `GET /api/sessions` | **mới** — trạng thái từng phiên, để giám sát |
| `GET /api/accounts/{id}/conversations` | **410** — Laravel dựng từ DB |
| `GET /api/accounts/{id}/messages` | **410** — đọc từ DB |
| `GET /api/accounts/{id}/search` | **410** — tìm trên DB |
| `POST /api/accounts/{id}/login-password` | **410** — zca-js chỉ có QR |
| `GET /api/accounts/{id}/dom` | **410** — không còn trình duyệt |

Các endpoint đã gỡ trả **410 kèm lý do cụ thể**, không phải 404 — để người vận hành biết đây
là thay đổi có chủ ý.

**Chiều mới Worker → Laravel** (xác thực bằng token + HMAC, middleware `worker`):

- `POST /api/worker/messages` — lô tin nhắn
- `POST /api/worker/status` — đổi trạng thái tài khoản

---

## 6. ĐÃ LÀM XONG

### 6.1 Kiểm chứng thư viện ✅

- `zca-js@2.1.2`, MIT, 62 bản, cập nhật tới 17/03/2026, GitHub `RFS-ADRENO/zca-js`
- Dependencies: `ws`, `tough-cookie`, `crypto-js`, `pako` — **không có** puppeteer/playwright
- **Đã sinh mã QR thật từ server Zalo**, hiển thị trong terminal, không process browser nào
- `data.image` xác nhận là base64 PNG thuần → endpoint `/screenshot` phục vụ được nguyên trạng

### 6.2 PoC độc lập ✅ — `poc/zca/`

`login.mjs` (quét QR), `listen.mjs` (nghe nhiều tài khoản + đo RAM), `list.mjs` (thăm dò API),
`send.mjs` (gửi thử), `README.md`. Chạy tách rời, không đụng hệ thống đang chạy.
**Chưa chạy với tài khoản thật** — cần bạn quét QR.

### 6.3 Worker viết lại hoàn toàn ✅

Đã xoá: `browser/`, `adapters/zalo-web/`, `application/`, `profile-store.ts`,
`attachment-store.ts`.

Đã viết mới: `zalo/credential-store.ts`, `zalo/zalo-session.ts`, `zalo/session-manager.ts`,
`zalo/message-mapper.ts`, `zalo/image-size.util.ts`,
`infrastructure/message-forwarder.ts`, cùng `server.ts`, routes, schemas, env, errors, types.

Kiểm chứng đã chạy:

- `tsc --noEmit` → **0 lỗi**; `tsc` build → **0 lỗi**
- Server khởi động thật: `/api/health` trả đúng; thiếu token → **401**; route đã gỡ → **410**
  kèm lý do; chữ ký HMAC khớp hai chiều
- `image-size.util.ts` kiểm chứng trên **5 ảnh thật, 4 định dạng** (PNG/JPEG/GIF/BMP) — đúng hết
- Image Docker: **3,6 GB → 267 MB** (đã build và đo)

> Đã xử lý một lỗi đóng gói của zca-js: `index.d.ts` chứa `export * from "./dist"` — re-export
> thư mục không kèm phần mở rộng, `moduleResolution: NodeNext` không phân giải được. Khắc phục
> bằng `paths` trong `tsconfig.json` trỏ thẳng vào `dist/index.d.ts`. Chỉ ảnh hưởng lúc biên
> dịch, khi chạy Node vẫn dùng exports map bình thường.

### 6.4 Backend ✅ (phần PHP)

- 3 migration — **đã chạy thành công** trên database hiện tại
- Models: `ZaloConversation`, `ZaloMessage` (mới), `AiAutoReply` (cập nhật)
- `VerifyWorkerRequest` middleware — token + HMAC + chống phát lại (chênh đồng hồ 300s)
- `ZaloMessageIngestService` — ghi lô, gom theo hội thoại, upsert, cập nhật preview, kích hoạt auto-reply
- `ZaloWorkerCallbackController` — nhận tin + nhận đổi trạng thái
- `AutoReplyService` — viết lại theo hướng sự kiện, bỏ toàn bộ vòng quét
- `ZaloAccountService` — `getConversations`/`searchConversations`/`getMessages` đọc **DB**;
  `sendMessage` kèm `thread_type`; `relogin` thay `loginWithPassword`
- `ZaloWorkerClient`, `RunAutoReplyJob`, `routes/api.php`, `bootstrap/app.php` — cập nhật
- Đã xoá: `WatchAutoReplies.php`, `WatchZaloConversations.php`, `LoginWithPasswordRequest.php`
- `php -l` toàn bộ `app/ routes/ database/ bootstrap/` → **0 lỗi cú pháp**

---

## 7. Cập nhật sau lượt code tiếp theo — 01/08/2026

| # | Việc | Trạng thái |
|---|---|---|
| 1 | **Giao diện: đổi khoá hội thoại từ tên sang `thread_id`** | ✅ Đã sửa `public/js/zalo-chat.js` và `index.blade.php`: UI lưu `selectedConversationKey` là `thread_id`; tên chỉ dùng để hiển thị. |
| 2 | **`docker-compose.yml`** | ✅ Đã bỏ `autoreply`, `PROFILE_BASE_PATH`, `MAX_CONCURRENT_SESSIONS`, `worker-profiles`; thêm `worker-credentials`, `LARAVEL_MESSAGE_URL`, `LARAVEL_CALLBACK_URL`, token/secret và cấu hình batch. |
| 3 | **Test đầu-cuối đường ống ingest** | ✅ Đã thêm feature test POST `/api/worker/messages` có HMAC, kiểm tra ghi `zalo_conversations`, `zalo_messages` và dispatch `RunAutoReplyJob`. |
| 4 | **`docs/deploy.md`** | ✅ Đã viết lại theo kiến trúc zca-js, 6 container, credential volume và không còn bước cài `node_modules` worker vào thư mục mount. |
| 5 | **Kiểm tra `AiAgentReplyService`** | ✅ Đã đọc lại: service nhận `conversationKey` và truyền nguyên giá trị đó vào `getMessages`/`sendMessage`; với UI/external mới, giá trị là `thread_id`. |
| 6 | **`ExternalToolchatController`** | ✅ Đã xác nhận controller đi qua `ZaloAccountService`; contract giữ tên `conversation_key`, nhưng nội dung phải là `thread_id`. |
| 7 | **Dọn tài khoản test `id=8`** | ✅ Đã xoá cùng hội thoại/tin nhắn test. Database sạch: 0 tài khoản, 0 hội thoại, 0 tin. |

Đã sửa thêm một lỗi phát hiện khi kiểm tra: `callback-client.ts` trước đó gửi trạng thái bằng
`Authorization: Bearer`, trong khi Laravel middleware `worker` chờ `X-Worker-Token` + HMAC.
Hiện cả `/api/worker/status` và `/api/worker/messages` đều dùng cùng cơ chế ký.

Kiểm chứng sau lượt này:

- `php artisan test --filter='AutoReplyTest|RunAutoReplyJobTest|WorkerMessageIngestTest'` → **12 pass**
- `php artisan test` → **18 pass**
- `php -l` toàn bộ `backend/app`, `routes`, `database`, `bootstrap` → **0 lỗi cú pháp**
- `docker compose config --quiet` → **pass**
- `docker compose build worker` → **pass**, bao gồm bước `npm run build`/`tsc`

---

## 7b. Lượt rà soát cuối — 01/08/2026

### Một lỗi thật đã tìm ra và sửa

**Số tin chưa đọc bị thổi phồng mỗi lần worker gửi lại lô.** Worker gửi lại nguyên lô mỗi khi
Laravel trả lỗi giữa chừng. Unique index chặn được tin trùng ở bảng `zalo_messages`, nhưng
`refreshConversationSummary()` lại **cộng dồn** `unread_count` theo số tin trong lô — nên gửi
lại 3 lần thì số chưa đọc thành 6 thay vì 2. Phát hiện bằng test đầu-cuối thật, không phải
bằng đọc code.

Cách sửa: **tính lại số chưa đọc từ database** thay vì cộng dồn, dựa trên mốc `last_read_at`
mới thêm (migration `2026_08_01_000004`).

Sửa xong lộ tiếp lỗi thứ hai: `last_read_at = now()` là sai, vì dấu thời gian do **Zalo** cấp
và có thể đi trước đồng hồ server — lúc đó tin vừa đọc lại bị đếm là chưa đọc. Mốc đọc phải là
`sent_at` của tin mới nhất trong hội thoại.

Đã thêm test hồi quy `test_gui_lai_cung_mot_lo_khong_lam_phong_so_chua_doc` phủ cả 4 trạng
thái: nhận tin → mở hội thoại → tin mới → mình trả lời.

### Tối ưu Docker phía PHP (phần còn thiếu của yêu cầu ban đầu)

| Việc | Kết quả |
|---|---|
| `backend/.dockerignore` | Trước đây **không có** → `vendor/` (89 MB) + `.git` + log chui vào image mỗi lần build |
| `docker/php/Dockerfile` → Alpine multi-stage | **866 MB → 206 MB** ✅ đã build và đo |
| `app` + `queue` dùng chung `toolchat-php:latest` | Trước build 2–3 lần cùng một image |
| OPcache cấu hình production | `validate_timestamps=0`, `memory_consumption=256` |
| MySQL: `performance-schema=OFF`, buffer pool 512M | **475 MB → 230 MB** |
| Redis: `maxmemory 512mb --maxmemory-policy noeviction` | `noeviction` chứ không phải `lru`: Redis vừa là cache vừa là hàng đợi job, `lru` sẽ âm thầm xoá job đang chờ |
| Đóng cổng 3307/6379/3500 khỏi host | Chỉ còn 8080 ra ngoài |
| `mem_limit` cho mọi service | Một container chạy loạn không kéo sập MySQL |
| `queue` chạy 2 bản sao | 1 tiến trình không kham nổi 100 tài khoản; job chủ yếu chờ API AI |

> Lưu ý đã xử lý: `validate_timestamps=0` cộng với bind-mount mã nguồn sẽ khiến **sửa code
> không có tác dụng** cho tới khi restart. Đã thêm `docker/php/opcache-mounted-source.ini`
> mount đè cho `app`/`queue` để bật lại kiểm tra (2 giây một lần).

### Số đo thực tế sau khi dựng lại toàn bộ

RAM lúc hệ thống rảnh, **chưa có tài khoản Zalo nào**:

| Container | Trước | Sau |
|---|---|---|
| worker | 890 MB | **26 MB** |
| mysql | 475 MB | **230 MB** |
| app | 48 MB | 39 MB |
| queue | 43 MB (1 bản) | 66 MB (2 bản) |
| redis + nginx | 20 MB | 18 MB |
| autoreply | 46 MB | *(đã gỡ)* |
| **Tổng** | **~1.522 MB** | **~379 MB** |

Image: `toolchat-php` 206 MB + `toolchat-worker` 267 MB = **473 MB**
(trước: 866 MB × 3 + 3,6 GB ≈ **6,2 GB**).

### Kiểm chứng cuối

- `docker compose config --quiet` → pass
- `docker compose build app` → pass; extension đủ (`pdo_mysql mbstring exif pcntl bcmath gd zip redis` + OPcache), `vendor/autoload.php` nạp được
- Dựng lại toàn bộ stack → **7/7 container running**, worker `healthy`, web HTTP 200
- **Test đầu-cuối thật qua HTTP + HMAC**: `POST /api/worker/messages` → ghi DB đúng;
  chữ ký sai → **401**; gửi lại 3 lần → vẫn 2 tin, 2 chưa đọc (không nhân bản)
- `php artisan test` → **14/14 pass (44 assertions)** trên stack vừa dựng lại
- Đã gỡ container `autoreply` mồ côi còn sót lại từ kiến trúc cũ

**Trạng thái tổng thể: hệ thống đã chạy đầu-cuối được trên pipeline mới và đã qua test thật.**
Phần còn cần xác nhận bằng tài khoản Zalo thật: quét QR, nhận WebSocket thực tế, đo RAM mỗi
tài khoản và theo dõi độ ổn định kết nối dài hạn.

### Có thể dọn thêm (chưa làm — cần bạn quyết định)

Các image cũ của kiến trúc Playwright vẫn còn chiếm chỗ. Xoá được ~14 GB:

```bash
docker image rm toolchat-app toolchat-queue toolchat-autoreply toolchat-reverb \
                toolchat-zalo-watcher toolchat-worker:v2 toolchat-php:v2
docker builder prune          # ~9,7 GB cache build
```

---

## 7c. Ba lỗi giao diện phát hiện khi chạy thật — 01/08/2026

### 1. Huy hiệu chưa đọc không bao giờ hiện

Hai đường độc lập cùng xoá số chưa đọc trước khi người trực kịp nhìn thấy:

- `ZaloAccountService::getMessages()` **luôn** đánh dấu đã đọc như một tác dụng phụ, mà
  `AiAgentReplyService` cũng gọi chính hàm đó để đọc hội thoại lúc soạn câu trả lời. Agent
  đọc hộ máy, không phải người xem. Giờ có tham số `markRead`, **mặc định tắt**; chỉ
  `ZaloAccountController::messages` (giao diện người dùng) bật nó.
- `countUnread()` coi mọi tin ĐI là bằng chứng "hội thoại đã được xem". Đúng với người thật
  trả lời bằng điện thoại, nhưng agent trả lời tự động **ngay khi tin vừa tới**, nên mọi hội
  thoại bật AI đều về 0 chưa đọc trong vòng vài giây. Tin do agent gửi giờ được ghi vào bảng
  `zalo_agent_sent_messages` (migration `2026_08_01_000006`) và bị loại khỏi phép tính.

> Vì sao là bảng riêng chứ không phải một cột của `zalo_messages`: lúc gửi xong, bản ghi tin
> nhắn có thể chưa tồn tại — tin vọng về qua WebSocket theo lô, sớm muộn không chắc.

### 2. Không có ảnh đại diện người gửi

`avatar_url` của `zalo_conversations` **chưa bao giờ được ghi** và `senderAvatarUrl` trả về
cứng `null`. Tin nhắn zca-js đẩy về chỉ có `uidFrom` + `dName`, không kèm ảnh.

- Worker: thêm `POST /api/accounts/:id/profiles` (`getUserInfo` + `getGroupInfo` theo lô).
  Khác `/contacts` ở chỗ tra được cả người **ngoài danh bạ** — người lạ nhắn tới và thành
  viên nhóm, đúng những người cần ảnh nhất.
- Laravel: bảng cache `zalo_contacts` (migration `2026_08_01_000005`) + `ZaloContactService`.
  Chỉ hỏi id chưa biết hoặc đã cũ (7 ngày với ảnh đã có, 12 giờ với id Zalo trả rỗng), mọi
  lỗi đều nuốt — thiếu ảnh thì rơi về chữ cái đầu, không được làm hỏng việc đọc tin.

### 3. Người gửi gửi ảnh thì không xem được

Giao diện đọc `att.dataUrl`, nhưng khoá đó **chỉ có ở tin vừa gửi từ máy này** (blob cục bộ).
Tin nhận về từ Zalo mang `url`/`thumb_url` → `src="undefined"` → ảnh vỡ. Đã thêm
`attachmentUrl()` đọc `dataUrl → url → thumb_url`, kèm:

- rơi về ảnh thu nhỏ rồi về một liên kết bấm được khi ảnh hỏng (liên kết CDN Zalo có hạn dùng)
- `referrerpolicy="no-referrer"` cho mọi ảnh từ miền Zalo
- hiển thị được cả tệp/âm thanh thay vì một ô trống
- sticker: hiển thị **đúng ảnh sticker**, không còn là bong bóng rỗng

**Riêng sticker** cần thêm một vòng: Zalo gửi sticker bằng **id**, không kèm đường dẫn ảnh.
`message-mapper.ts` moi id ra (thử `id`/`stickerId`/`sticker_id`/`uri` và cả chuỗi
`[^cateId.stickerId^]` vì Zalo không thống nhất khoá giữa các client), rồi `zalo-session.ts`
tra `getStickersDetail` và cache cả đời tiến trình — sticker là tài nguyên tĩnh dùng chung,
hai người gửi cùng một sticker chỉ tốn một lời gọi. API lỗi thì rơi về khuôn đường dẫn
`.../emoticon/sticker/webpc?eid={id}&size=130`; không moi được id thì mới hiện nhãn `[Sticker]`.

Kiểm chứng trên stack đang chạy với tài khoản Zalo thật: ảnh đại diện lấy về đúng, tin ảnh có
`url` mở được (HTTP 200, `Access-Control-Allow-Origin: *`), và chuỗi nhận tin → agent đọc →
người mở cho ra `unread = 1 → 1 → 0`. `php artisan test` → **20 pass**.

---

## 7d. Bật nhiều tài khoản cùng lúc trên một màn hình — 02/08/2026

Trước đây một lúc chỉ xem được **một** tài khoản; trả lời khách của tài khoản khác là phải
chuyển tài khoản, chờ tải lại danh sách. Giờ cột trái là **ô tích chọn**: tích bao nhiêu tài
khoản thì hội thoại và tin nhắn của bấy nhiêu tài khoản đổ chung về một danh sách.

**Làm hoàn toàn ở giao diện, backend không đổi một dòng.** Giao diện gọi song song
`/api/zalo-accounts/{id}/conversations` cho từng tài khoản đang bật rồi trộn kết quả. Lý do
không thêm một endpoint gộp: mỗi tài khoản là một phiên Zalo riêng và hỏng riêng — gộp ở
server thì một tài khoản rớt phiên kéo cả màn hình chết theo, còn gọi rời thì chỉ hiện
"không tải được hội thoại của: Beta" và ba tài khoản còn lại vẫn chạy.

Ba điểm phải cẩn thận, đều là chỗ dễ **gửi tin nhầm danh nghĩa** — tin Zalo bay đi là không
thu hồi được:

1. **Định danh hội thoại phải kèm id tài khoản** (`conversationIdentity` → `"2::a1"`). Cùng
   một người bạn có thể nằm trong danh sách của hai tài khoản cùng lúc; thiếu tiền tố này
   thì hai hàng đó đè lên nhau thành một và bấm vào là mở nhầm phiên.
2. **`selectedConversationAccountId` tách khỏi tài khoản đang xem danh bạ.** Mọi lời gọi
   đọc tin / gửi tin / auto-reply / đổi tên đều đi theo id của hội thoại đang mở, không theo
   một "tài khoản hiện hành" chung.
3. **`selectConversation` chỉ nhận object hội thoại, không nhận chuỗi `thread_id`** nữa: một
   `thread_id` trần không nói lên được nó thuộc tài khoản nào.

Những thứ **không** gộp, có chủ ý:

- **Danh bạ** vẫn theo một tài khoản (chọn bằng dãy chip ở màn danh bạ). Gộp lại thì cùng một
  người hiện mấy dòng giống hệt nhau mà không biết mở bằng tài khoản nào.
- **Nút "Đồng bộ tin nhắn"** thì ngược lại: bấm một cái là xin lịch sử cho **tất cả** tài
  khoản đang bật.

Danh sách trộn xếp theo `last_message_at` giảm dần (trước đây dựa thẳng vào thứ tự server
trả về). Hội thoại vừa mở từ danh bạ chưa có tin nào nên không có mốc thời gian — đánh dấu
`_openedAt` để nó nằm trên đầu thay vì rơi xuống đáy.

Lựa chọn tài khoản nhớ ở `localStorage`, không phải ở server: đây là thói quen của từng máy
(máy ở nhà xem một tài khoản, máy trực chat bật cả năm), không phải cấu hình hệ thống.

Kiểm chứng: `php artisan test` → **61 pass** (backend không đổi), thêm một harness Node chạy
thẳng `zalo-chat.js` với `fetch` giả để soát phần trộn — thứ tự theo thời gian, hai tài khoản
cùng `thread_id` không đè nhau, mở hội thoại của tài khoản 2 thì không có request nào bay
sang tài khoản 1, tắt tài khoản thì hội thoại của nó biến mất và khung chat đóng lại.

---

## 7e. Trả lời (trích dẫn) tin nhắn — 02/08/2026

Chuột phải vào một bong bóng tin → menu **Trả lời / Copy tin nhắn**. Bấm "Trả lời" thì trên ô
soạn hiện thanh trích dẫn (tên người + đoạn chữ, có nút ×), gửi xong tin hiện ra bên Zalo đúng
dạng trả lời như trên app.

**Điểm cốt lõi: Zalo KHÔNG cho trích dẫn bằng mỗi `msgId`.** `api.sendMessage` đòi lại nguyên
cụm thô của tin được trả lời — `content` (nguyên bản, chưa tách đính kèm), `msgType`,
`propertyExt`, `uidFrom`, `msgId`, `cliMsgId`, `ts`, `ttl` — để dựng params
`qmsgOwner/qmsgId/qmsgCliId/qmsgType/qmsgTs/qmsg/qmsgAttach/qmsgTTL` rồi gọi endpoint `/quote`.

Mà zca-js **không đọc lại được lịch sử** (mục 3.5): không giữ cụm đó ngay lúc tin tới thì
vĩnh viễn hết đường trả lời tin đó. Dạng đã chuẩn hoá cũng không dựng ngược lại được —
`content` của ảnh/file đã bị tách sang `attachments`, `ts` đã đổi sang ISO. Vì thế:

```
Zalo → worker (normalizeMessage giữ nguyên bản thô) → zalo_messages.quote_payload
                                                                ↓
UI bấm "Trả lời" → POST /messages kèm reply_to_msg_id → Laravel tra quote_payload
                                                                ↓
                                          worker → api.sendMessage({ msg, quote }) → /quote
```

**Hai cột mới ở `zalo_messages`** (migration `2026_08_02_000001`):

| Cột | Vai trò |
|---|---|
| `quote_payload` | Bản thô để về sau trích dẫn **chính tin này**. Giữ nguyên tên khoá camelCase của Zalo — cụm này chỉ đi xuyên qua hệ thống rồi quay lại Zalo. |
| `quote_preview` | Tên người + đoạn chữ của tin **được** tin này trích dẫn, theo lời Zalo. Chỉ để hiển thị, và chỉ dùng khi tin gốc không có trong kho. |

Cột `quote_msg_id` đã có từ trước vẫn là thứ nói "tin này trả lời tin nào" — đủ để hiển thị,
không đủ để gửi.

**Những chỗ đã cân nhắc và chốt:**

- **Tin cũ không trả lời được.** Tin ghi trước 02/08/2026 không có `quote_payload`; API trả
  `canQuote: false` và giao diện làm mờ mục "Trả lời" ngay trong menu, kèm lời giải thích ở
  `title`. Nếu vẫn cố gửi (gọi thẳng API), Laravel trả 422 và **không** gọi worker — gửi lặng
  lẽ thành tin thường thì người trực tưởng đã trả lời đúng tin, còn khách nhận một câu trả lời
  không biết đang trả lời cho cái gì.
- **Khối trích dẫn trong bong bóng** ưu tiên đọc bản ghi thật trong kho (có tên đã chuẩn hoá,
  biết cả đính kèm); chỉ rơi về `quote_preview` khi tin gốc cũ hơn ngày hệ thống bắt đầu lưu.
- **`group.poll` và `webchat` dạng object** bị zca-js từ chối trích dẫn → worker trả
  `quote_payload = null` ngay từ đầu, để giao diện mờ nút thay vì để người dùng gõ xong một
  đoạn dài rồi mới nhận lỗi từ Zalo.

**Một lỗi giao diện tìm ra khi chạy thật (và đã có sẵn từ trước):** `class="d-flex"` của
Bootstrap là `display:flex !important`, đè luôn `display:none` mà `x-show` đặt inline — thanh
trả lời không bao giờ ẩn đi được. **Khay tệp đính kèm (`.attachment-tray`) dính đúng lỗi này
từ trước**, nên vẫn hiện thành một ô trắng rỗng ngay cả khi chưa chọn tệp nào. Cả hai đã
chuyển phần `display/gap/margin` vào tệp CSS thường.

Kiểm chứng: `php artisan test` → **67 pass** (6 test mới ở `ReplyMessageTest`), `npm run
typecheck` ở worker sạch, cộng một lượt chạy Chrome thật với API giả để soát giao diện — menu
chuột phải, mục "Trả lời" mờ đúng với tin cũ, thanh trích dẫn hiện/ẩn đúng lúc, và request gửi
đi có kèm `reply_to_msg_id`.

---

## 7f. Đăng nhập và phân quyền — 02/08/2026

Trước lần này hệ thống **không có đăng nhập**: ai mở được đường dẫn là dùng được mọi tài
khoản Zalo. Giờ có hai vai cố định:

| | Nhân viên | Quản trị viên |
|---|---|---|
| Màn chat, tài khoản Zalo | chỉ tài khoản **do chính mình tạo** | tất cả |
| Trang AI Agent, trang Nhân viên | không | có |
| Chọn agent để bật AI tự trả lời | có (cho hội thoại của mình) | có |

Đăng nhập bằng **số điện thoại + mật khẩu** (không phải email): đây là công cụ trực chat nội
bộ, nhân viên có số điện thoại chứ chưa chắc có email công ty. Không có màn tự đăng ký, không
có email khôi phục - admin tạo tài khoản và đặt lại mật khẩu hộ.

**Lần cài đặt đầu tiên** (con gà - quả trứng: tạo người dùng thì phải đăng nhập bằng admin):

```bash
php artisan migrate
php artisan user:create-admin --name="Sếp" --phone=0912345678 --password=matkhau123
```

Lệnh này còn nhận luôn những tài khoản Zalo chưa có chủ về cho admin đầu tiên. Migration
`2026_08_02_000003` cũng làm việc đó nếu lúc chạy migrate đã có sẵn người dùng.

**Ranh giới phân quyền nằm gọn ở ba chỗ** - sửa quyền thì sửa ở đây, đừng rải `if` ra
controller:

1. `ZaloAccount::scopeVisibleTo()` — lọc danh sách.
2. `EnsureZaloAccountAccess` (middleware `zalo.account`) — chặn từng tài khoản lẻ. Gắn cho cả
   nhóm `zalo-accounts/{id}/...` vì **mọi** thao tác đụng tới một tài khoản (đọc tin, GỬI TIN,
   mở phiên, xoá, bật AI) đều đi qua đó. Bỏ sót một route là nhân viên nhắn tin được dưới danh
   nghĩa tài khoản của người khác, mà tin Zalo bay đi thì không thu hồi được.
3. `ExternalToolchatController::canUseAccount()` — cùng ranh giới đó cho API `external/*`.

**Ba quyết định đáng ghi lại:**

- **Nhóm `api` được gắn thêm session** (`bootstrap/app.php`) để `fetch` từ ba trang blade biết
  ai đang gọi. **Không** thêm `VerifyCsrfToken`: phần JS hiện có gọi `fetch` trần, không gửi
  token. Chốt chặn giả mạo từ trang ngoài đang dựa vào `SESSION_SAME_SITE=lax` (mặc định của
  Laravel) — đổi giá trị đó thành `none` là mở toang cửa này.
- **Xoá nhân viên KHÔNG xoá tài khoản Zalo của họ** (khoá ngoại `nullOnDelete`): xoá theo là
  mất trắng lịch sử tin nhắn, mà zca-js không đọc lại được lịch sử. Tài khoản thành "không có
  chủ" và chỉ admin còn nhìn thấy, để bàn giao lại.
- **Màn cấp quyền OAuth (`/oauth/authorize`) giờ đòi đăng nhập.** Token sinh ra ở đó được gắn
  với người bấm "Đồng ý", và `external/*` dựa vào đúng người đó. **Hệ quả cần biết:** token
  ChatGPT/Codex cấp TRƯỚC lần này không gắn với ai nên không còn thấy tài khoản nào — phải nối
  lại ChatGPT một lần. Thà cụt còn hơn để một token cũ mở cửa vào tài khoản của cả công ty.

Hai chốt chặn để hệ thống không rơi vào cảnh không còn ai quản trị: không tự hạ vai của chính
mình, và không hạ vai/xoá người admin cuối cùng.

Kiểm chứng: `php artisan test` → **94 pass** (thêm `AuthenticationTest`, `AccountOwnershipTest`,
`UserManagementTest`, `ExternalApiOwnershipTest`), cộng một lượt chạy Chrome thật với API giả
cho màn đăng nhập và trang nhân viên.

---

## 8. Rủi ro và điều chưa biết

### Chưa đo được (cần bạn quét QR bằng tài khoản thật)

- **RAM thật mỗi tài khoản.** Con số 10–30 MB là ước lượng từ kiến trúc, chưa phải số đo.
  Chạy `poc/zca/listen.mjs` sẽ ra số thật.
- **Độ trễ đẩy tin** qua WebSocket.
- **Độ ổn định kết nối dài hạn** — cần chạy `listen.mjs` vài giờ để xem có rụng kết nối
  (`closed code=3000/3003`) hay rò rỉ bộ nhớ không.

### Rủi ro đã biết

- **`zca-js` là thư viện phân tích ngược, không chính thức.** Dùng nó đi ngược điều khoản sử
  dụng của Zalo. **Rủi ro khoá tài khoản là có thật** và tăng theo số tài khoản chạy chung một
  IP — với 100 tài khoản đây là rủi ro kinh doanh đáng kể, không phải rủi ro kỹ thuật.
  `Options.agent` của zca-js hỗ trợ proxy riêng từng tài khoản, đó là hướng giảm thiểu.
- **Bản mới nhất của zca-js là tháng 3/2026** (~4,5 tháng trước). Zalo đổi giao thức là gãy,
  và phụ thuộc vào việc tác giả còn duy trì.
- **Mã đóng 3000 (`DuplicateConnection`) / 3003 (`KickConnection`)**: tài khoản được đăng nhập
  ở nơi khác sẽ bị cắt phiên và **phải quét QR lại**. Với 100 tài khoản dùng chung với nhân
  viên trên điện thoại, đây sẽ là việc vận hành thường xuyên. Hệ thống đã báo rõ lý do lên
  `last_error` của tài khoản.
- **Lịch sử cũ không lấy lại được — kể cả nhóm.** Đã dò và loại trừ bốn đường, xem bảng ở
  mục 3.5. Kho tin nhắn chỉ có những gì hệ thống nhận được từ lúc bật trở đi, nên
  `zalo_messages` là dữ liệu **không tái tạo được**: mất là mất hẳn.
- **`getGroupChatHistory` của zca-js đã chết** (Zalo gỡ `group/history`, trả 404). Nếu sau này
  Zalo mở lại hoặc zca-js ra bản mới, đây là chỗ đáng thử trước — mã gọi vẫn còn ở
  `SessionManager.getGroupHistory`, chỉ chưa nối vào giao diện.
- **`worker-credentials` là tài sản nhạy cảm nhất của hệ thống.** Ai có thư mục đó là đăng
  nhập được vào toàn bộ tài khoản Zalo. Đã đưa vào `.gitignore`/`.dockerignore`, đã che
  `cookie`/`imei` trong log. Cần backup riêng và không truyền qua kênh không mã hoá.

---

## 9. Thứ tự đề xuất cho bước tiếp theo

1. Chạy compose thật trên máy/server: `docker compose up -d --build`
2. Quét QR 2–3 tài khoản thật để kiểm tra nhận tin, gửi tin, callback trạng thái
3. Theo dõi `/api/health` của worker, đặc biệt `queued_messages`
4. Đo RAM thực tế mỗi tài khoản bằng `docker stats` hoặc PoC `poc/zca/listen.mjs`
5. Nếu xác nhận tài khoản `id=8` là dữ liệu rác, xoá sau khi backup hoặc có lệnh rõ ràng
