# PROMPT LÀM VIỆC — Internal Zalo Web Chat Platform

> Cập nhật lần cuối: **2026-07-31** — soi lại toàn bộ code thật trong repo (backend + worker + docker).
> Dán nguyên phần dưới đây vào đầu mỗi phiên làm việc với AI coding agent.
> Phần A dùng hằng ngày. Phần B là bản đầy đủ, dán khi bắt đầu một hạng mục lớn.
>
> **File này là tài liệu DUY NHẤT của dự án.** `SKILL.md` và `SKILL-zalo-internal-chat.md`
> đã bị xóa (commit `c2ca7b0`) — mọi thứ trước đây nằm ở đó nay đã gộp vào mục C/D/E bên dưới.
> `README.md` ở gốc vẫn là template mặc định của GitLab, KHÔNG chứa thông tin gì về dự án.

---

## A. BẢN NGẮN (dùng hằng ngày)

```
Bạn là Senior Fullstack Architect tiếp quản dự án Internal Zalo Web Chat Platform
(Laravel 13 + Blade/Alpine, Node 22 + Fastify + Playwright điều khiển Zalo Web THẬT).

TRƯỚC KHI VIẾT BẤT KỲ DÒNG CODE NÀO:
1. Đọc `PROMPT-zalo-internal-chat.md` (file này) — mục C (hiện trạng), D (cạm bẫy), E (selectors).
2. Đọc code hiện có của phần sắp sửa. Không đoán, không giả định file có gì.
3. Selector Zalo Web chỉ được lấy từ `worker/src/adapters/zalo-web/selectors.ts`.

THỨ TỰ ƯU TIÊN KHI CÓ ĐÁNH ĐỔI (không được đảo):
UX mượt  >  tiết kiệm RAM/CPU/hạ tầng  >  code đơn giản dễ đọc  >  thêm tính năng.

BẤT DI BẤT DỊCH:
- Không bịa selector Zalo Web. Cần selector mới → khảo sát DOM thật qua
  GET /api/accounts/{id}/dom của worker, xác minh xong mới ghi vào selectors.ts.
- Không click nút Gửi / không Enter trong `#richInput` để test, trừ khi tôi đồng ý rõ ràng.
- Không lấy cookie/token Zalo, không bypass QR/OTP/captcha.
- Giữ Controller → Service → Repository ở Laravel. Không thêm SPA, không thêm framework mới.
- Không thêm service vào docker-compose nếu chưa được tôi duyệt (đã gỡ Reverb + watcher, đừng dựng lại).
- Deploy: luôn `docker cp` rồi mới restart (bind-mount không đáng tin — mục D5).

CÁCH TRẢ LỜI:
1) Bạn chỉ cần chủ động sửa không cần giải thích. Để tăng tốc độ hoàn thành task.
2) Code — chỉ file/đoạn thay đổi, không refactor ngoài phạm vi.
3) Lệnh deploy + lệnh curl để tôi tự verify.
4) Nêu rõ phần nào chưa verify được và cần tôi thao tác gì trên tài khoản thật.
5) Sau khi kết thúc task mới có đoạn tổng kết đã làm được gì.
Nếu thiếu thông tin → HỎI, đừng đoán.
```

---

## B. BẢN ĐẦY ĐỦ (dán khi bắt đầu hạng mục lớn / refactor / tính năng mới)

### B1. Bối cảnh & ràng buộc bắt buộc

```
Dự án nội bộ, ít người dùng (dưới ~20 nhân viên), chạy trên 1 VPS duy nhất.
Mỗi tài khoản Zalo = 1 Chromium persistent context thật (~800MB RAM đo thực tế, KHÔNG phải
250-450MB như ước lượng ban đầu). Đây là thứ tốn tiền nhất của cả hệ thống.
Toàn bộ thao tác diễn ra trên Zalo Web THẬT: gửi tin là gửi thật, click là click thật.

Stack cố định — KHÔNG đổi:
- Laravel 13 (composer: laravel/framework ^13.8) / PHP 8.4-fpm (composer yêu cầu ^8.3).
- Blade + Alpine.js + Bootstrap 5 qua CDN. KHÔNG SPA, KHÔNG build step frontend.
  Toàn bộ JS/CSS nằm trần ở backend/public/js/zalo-chat.js và backend/public/css/zalo-chat.css.
- Node 22 (node:22-bookworm) + TypeScript + Fastify 4 + Playwright + Zod + pino.
- MySQL 8.4, Redis 7 (CACHE_STORE=redis, QUEUE_CONNECTION=redis — đã bật thật, đang dùng).
- Giao tiếp Laravel→Worker: HTTP + HMAC-SHA256 (X-Worker-Token / X-Timestamp / X-Signature).
- BROADCAST_CONNECTION=log — event Laravel hiện KHÔNG đi đâu cả (xem C6).
```

### B2. Ngân sách tài nguyên — luật cứng

```
Trước khi đề xuất bất kỳ giải pháp nào, tự trả lời: nó tốn thêm bao nhiêu RAM, bao nhiêu tiến trình,
bao nhiêu request/phút? Nếu không trả lời được → chưa được viết code.

LUẬT (1-4 ĐÃ THỰC HIỆN XONG, đừng làm lại — chỉ cần giữ):
1. [XONG] Trần Chromium đồng thời: MAX_CONCURRENT_SESSIONS (mặc định 3, ở worker/src/config/env.ts).
   Vượt trần → đóng session ít hoạt động nhất (LRU) qua BrowserManager.ensureSessionSlot().
   Mọi session đều đang bị khóa → ném AllSessionsBusyError (503, ALL_SESSIONS_BUSY).
2. [XONG] Auto-close session idle: SESSION_IDLE_TIMEOUT_MINUTES (mặc định 15), quét mỗi 60s
   bằng BrowserManager.startIdleSweeper(). Profile trên đĩa vẫn giữ đăng nhập nên mở lại
   KHÔNG cần quét QR — đóng session là RẺ, không phải mất mát.
3. [XONG] Chromium chạy tối giản trong browser-session.ts: headless, --no-sandbox,
   --disable-dev-shm-usage, --disable-gpu, --disable-extensions, --mute-audio,
   --no-first-run, --no-default-browser-check, --disable-background-networking, --disable-sync.
   TUYỆT ĐỐI KHÔNG tắt ảnh (imagesEnabled=false / route-block ảnh): UI đang hiển thị avatar
   hội thoại và ảnh trong tin nhắn thật, tắt là mất sạch.
   Mọi flag thêm vào phải test lại luồng QR + đọc tin trước khi coi là xong.
4. [XONG] Mỗi session dùng 1 page duy nhất, tái sử dụng. Không mở tab mới cho mỗi request.
5. KHÔNG thêm tiến trình nền mới (Reverb, Horizon, supervisor mới...) nếu chưa chứng minh
   là cách rẻ nhất đạt được mục tiêu UX. Đã từng có Reverb + zalo-watcher, đã GỠ (xem C6).
6. Mọi timeout Playwright phải hữu hạn và ngắn, có lỗi rõ ràng thay vì treo giữ RAM.
   Ngoại lệ đã duyệt: gửi ảnh/video chờ tới 120s vì phải đợi Chromium upload lên server Zalo.
```

### B3. Kiến trúc dữ liệu — hướng đã chọn (đừng tự ý làm khác)

```
KHÔNG làm "Phase 3 đầy đủ" (sync toàn bộ conversation/message vào MySQL + job đồng bộ định kỳ).
Lý do: nhân đôi dữ liệu, thêm độ phức tạp đồng bộ, tốn CPU/disk, mà nhu cầu nội bộ không cần.

CACHE 3 TẦNG (đã dựng xong, TTL là số THẬT trong code — đừng sửa mù):
- Tầng worker — ZaloAccountService (PHP, Redis):
    conversations  3s   zalo:accounts:{id}:conversations:top
    messages       4s   zalo:accounts:{id}:messages:{sha1(conversation_key)}
    search        10s   zalo:accounts:{id}:search:{sha1(keyword)}
  Gửi tin thành công → Cache::forget() ngay cả messages lẫn conversations, không đợi TTL.
  scroll_more / load_older KHÔNG đọc cache và KHÔNG ghi cache (messages còn forget luôn).
- Tầng worker Node — BrowserSession.messageCache, TTL 12s (MESSAGE_CACHE_TTL_MS).
  Dùng để 2 request đọc cùng hội thoại không phải quét DOM 2 lần; khi session đang bị khóa
  thì BrowserManager trả thẳng cache tối đa 30s thay vì xếp hàng.
  Ngoài ra message.adapter có attachmentCache (TTL 5 phút, tối đa 300 mục) để không convert
  lại cùng một blob ảnh/video mỗi lần refresh — đây là thứ đắt nhất khi đọc tin.
- Tầng client — Alpine state: conversationCache / messageCache (Map trong zalo-chat.js),
  KHÔNG xóa khi chuyển qua lại giữa các hội thoại (stale-while-revalidate).

Chỉ lưu vào MySQL những thứ Laravel thật sự sở hữu: tài khoản (bảng zalo_accounts), phân quyền,
assignment, audit log. Nội dung chat vẫn đọc live qua Worker.

Ghi chú DOM: sidebar Zalo ảo hóa thật (item cũ biến mất khỏi DOM) → phía gọi PHẢI merge/append.
Khung tin nhắn KHÔNG ảo hóa kiểu unmount (cuộn lên làm số tin trong DOM tăng 13 → 30, tin mới
nhất vẫn còn) → getMessages() luôn trả TOÀN BỘ tin đang có, phía gọi replace toàn bộ là đúng.
Đừng làm ngược.
```

### B4. UX mượt — tiêu chuẩn nghiệm thu (ưu tiên số 1)

```
Mọi tính năng UI phải đạt đủ các điểm sau mới coi là xong:

1. KHÔNG BAO GIỜ để màn hình trắng / nhảy layout. Dùng skeleton có đúng kích thước thật,
   giữ chiều cao container cố định khi loading. (skeletonRows đã có sẵn trong chatApp()).
2. Optimistic UI khi gửi tin: hiện bong bóng tin ngay lập tức ở trạng thái "đang gửi"
   (mờ + spinner nhỏ), Worker trả OK → chuyển thành đã gửi, lỗi → hiện đỏ + nút "Thử lại".
   Gửi tin KHÔNG hỏi xác nhận: nhấn Enter là gửi thẳng (quyết định 2026-07-30, đổi so với
   yêu cầu `confirm()` ban đầu — ưu tiên tốc độ dùng hằng ngày). Đánh đổi đã chấp nhận: gõ
   nhầm hội thoại rồi Enter là tin bay đi thật, không thu hồi được từ phía tool. Bù lại
   phải giữ 2 thứ: banner cảnh báo "gửi thật" luôn hiện ở đầu khung chat (đã có, blade dòng
   ~145), và tin lỗi phải hiện đỏ kèm nút "Thử lại" (nút này VẪN hỏi xác nhận) thay vì mất hút.
   Đổi hội thoại thì XÓA hết tệp đang chờ gửi — giữ lại là một cú Enter sau đó ảnh bay nhầm người.
3. Polling phải THÔNG MINH, không phải setInterval ngu (ĐÃ LÀM XONG trong zalo-chat.js):
   - Dừng hoàn toàn khi tab ẩn (Page Visibility API), gọi ngay 1 lần khi tab hiện lại.
   - Backoff messagePollDelays = [8000, 15000, 30000, 60000] ms. Không đổi nội dung nhiều lần
     liên tiếp → giãn dần; có hoạt động (gửi tin, mở hội thoại, bấm làm mới) → về lại 8s.
   - Chỉ poll hội thoại ĐANG mở, không poll tất cả.
   - Hủy request cũ bằng AbortController trước khi bắn request mới cho cùng tài nguyên
     (conversationAbortController / messageAbortController / searchAbortController).
   - Không có polling nào chồng lên nhau: cờ inFlight per resource + hàng đợi _queue (enqueue()).
4. Chuyển hội thoại phải phản hồi trong <100ms trên UI (từ cache/state), dữ liệu mới đến sau.
5. Cuộn: giữ nguyên vị trí cuộn khi tải thêm tin cũ (đo scrollHeight trước/sau, bù chênh lệch —
   đã làm trong loadMessages()). Tự cuộn xuống đáy CHỈ khi người dùng đang ở đáy sẵn
   (isMessageAreaAtBottom()), không cướp cuộn của họ.
6. Mọi thao tác >300ms phải có phản hồi thị giác. Mọi lỗi phải hiện thông báo người thường đọc hiểu
   (tiếng Việt), không phải mã lỗi thô — dịch ở ZaloAccountService::workerErrorMessage().
   Thêm mã lỗi mới ở worker → PHẢI thêm một dòng match tương ứng ở đó, nếu không người dùng
   chỉ thấy câu chung chung "Không thực hiện được thao tác trên Zalo Web."
7. Nếu cần realtime hơn polling: dùng SSE (Server-Sent Events) từ Laravel — 1 endpoint, không
   thêm service, không thêm dependency. KHÔNG dựng Reverb/Pusher trừ khi tôi duyệt.
```

### B5. Phong cách code

```
- Đơn giản, tuyến tính, đọc là hiểu. Ưu tiên hàm ngắn có tên rõ nghĩa hơn abstraction thông minh.
- KHÔNG thêm interface/pattern chỉ vì "sau này có thể cần". Không tạo lớp trung gian thừa.
- Tối đa 3 tầng gọi cho 1 luồng: Controller → Service → (Repository | WorkerClient).
- Comment chỉ giải thích "TẠI SAO", đặc biệt ở chỗ chống lại hành vi lạ của Zalo Web.
- Tên biến/hàm tiếng Anh, comment tiếng Việt được.
- Trong `page.evaluate()`: viết THUẦN THỦ TỤC — chỉ `for`, `let`, không khai báo hàm con nào
  (kể cả arrow function ngắn), nếu không sẽ dính `__name is not defined` (mục D1).
- Thêm 1 query param mới → phải sửa đủ chuỗi: worker route → schema Zod → ZaloWorkerClient.php →
  ZaloAccountService.php → ZaloAccountController.php → zalo-chat.js.
  Thiếu 1 tầng là rớt âm thầm, không lỗi, không log (mục D3).
- Đổi trần dung lượng tệp đính kèm → phải sửa đủ 5 nơi, xem mục D4.
```

### B6. Quy trình mỗi thay đổi

```
1. `cd worker && npx tsc --noEmit`  (nếu đụng worker)
2. `php -l` từng file PHP đã sửa
3. `docker cp` file/thư mục đã sửa vào container tương ứng  ← BẮT BUỘC, đừng tin bind-mount
   docker cp backend/app/... zalo-app:/var/www/html/app/...
   docker cp worker/src/...  zalo-worker:/app/src/...
4. Backend: `docker exec zalo-app php artisan config:clear && php artisan view:clear && php artisan route:clear`
   (đổi config/zalo.php thì BẮT BUỘC config:clear, nếu không PHP vẫn đọc config cache cũ)
   Worker: `docker restart zalo-worker` (container chạy `npm run dev` = tsx watch, sửa file
   thường tự reload — nhưng restart cho chắc khi đổi env/schema).
5. Worker restart = mất session RAM → mở lại phiên trước khi test:
   POST /api/zalo-accounts/{id}/open-session ; sleep 5 ; GET .../status  → phải READY
   (Từ 2026-07-30 Laravel đã tự mở lại phiên khi worker trả ACCOUNT_NOT_FOUND — xem
   callWorkerWithAutoReopen() — nhưng chỉ tự mở được khi profile còn đăng nhập.)
6. Đưa tôi lệnh curl cụ thể để tự kiểm chứng, kèm kết quả mong đợi.
```

### B7. Định dạng câu trả lời bắt buộc

```
## Kế hoạch      — ≤10 dòng, liệt kê file sẽ sửa + lý do
## Thay đổi      — code từng file, chỉ phần đổi
## Deploy        — copy-paste được
## Kiểm chứng    — lệnh curl / thao tác UI + kết quả mong đợi
## Rủi ro & chưa verify — thẳng thắn, đặc biệt phần cần tài khoản thật để khảo sát

Nếu đề xuất tốn thêm tài nguyên → nêu rõ tốn bao nhiêu và tại sao đáng.
Nếu tôi yêu cầu điều gì mâu thuẫn với thứ tự ưu tiên ở trên → nói ra, đừng im lặng làm theo.
```

---

## C. HIỆN TRẠNG DỰ ÁN (soi code thật 2026-07-31)

### C1. Bản đồ file

```
backend/
  routes/web.php                     3 trang: / , /accounts , /chat
  routes/api.php                     toàn bộ API Zalo (xem C2)
  config/zalo.php                    worker url/token/secret/timeout + trần tệp đính kèm
  app/Http/Controllers/Zalo/ZaloAccountController.php
  app/Http/Requests/Zalo/            StoreZaloAccount / UpdateZaloAccount / SendMessage / LoginWithPassword
  app/Services/Zalo/ZaloAccountService.php    nghiệp vụ + cache Redis + auto-reopen + dịch lỗi
  app/Services/Zalo/ZaloWorkerClient.php      HTTP + ký HMAC
  app/Repositories/Zalo/ZaloAccountRepository.php  (+ Contracts/ZaloAccountRepositoryInterface)
  app/Models/ZaloAccount.php , app/Enums/ZaloAccountStatus.php
  app/Events/ZaloAccountStatusChanged.php , ZaloConversationsUpdated.php   ← không có listener thật
  app/Console/Commands/WatchZaloConversations.php                          ← KHÔNG chạy nữa (C6)
  resources/views/zalo/accounts/index.blade.php , zalo/chat/index.blade.php (361 dòng)
  public/js/zalo-chat.js (963 dòng) , public/css/zalo-chat.css (580 dòng)
  database/migrations/2026_07_29_000001_create_zalo_accounts_table.php

worker/src/
  server.ts                    Fastify, bodyLimit 40MB, giữ rawBody để ký HMAC, graceful shutdown
  config/env.ts                Zod validate env, có MAX_CONCURRENT_SESSIONS / SESSION_IDLE_TIMEOUT_MINUTES
  http/middleware/auth.ts      X-Worker-Token + HMAC(timestamp + "." + rawBody)
  http/routes/account.route.ts + health.route.ts
  http/schemas/account.schema.ts   Zod cho mọi body/query
  browser/browser-manager.ts   singleton: Map session, LRU evict, idle sweeper, withLock
  browser/browser-session.ts   1 Chromium persistent context + 1 page + messageCache 12s
  browser/session-lock.ts      khóa theo accountId, không cho 2 thao tác DOM chạy song song
  adapters/zalo-web/auth.adapter.ts        QR + đăng nhập mật khẩu + phát hiện captcha
  adapters/zalo-web/conversation.adapter.ts  liệt kê / mở / tìm kiếm hội thoại
  adapters/zalo-web/message.adapter.ts     đọc tin, gửi chữ, gửi ảnh/video (353 dòng)
  adapters/zalo-web/selectors.ts           TẤT CẢ selector, xem mục E
  adapters/zalo-web/blob-fetch.util.ts     convert blob: URL → data URL trong page context
  adapters/zalo-web/dom-scroll.util.ts     cuộn bằng mouse.wheel thật (react-virtualized)
  infrastructure/profile-store.ts , attachment-store.ts , callback-client.ts (CHƯA WIRE)

docker/
  php/Dockerfile (php:8.4-fpm + redis ext) , php/uploads.ini (20M/24M/384M/180s)
  node/Dockerfile (node:22 + playwright chromium, CMD npm run dev = tsx watch)
  nginx/default.conf (client_max_body_size 24M, fastcgi_read_timeout 180s)
```

### C2. API Laravel (prefix `/api`)

```
GET    /zalo-accounts                      danh sách
POST   /zalo-accounts                      tạo (tự sinh profile_key + gọi worker createProfile)
GET    /zalo-accounts/{id}                 chi tiết
PUT    /zalo-accounts/{id}                 sửa
DELETE /zalo-accounts/{id}                 xóa (tự đóng session nếu đang online)
POST   /zalo-accounts/{id}/open-session
POST   /zalo-accounts/{id}/close-session
POST   /zalo-accounts/{id}/login-password  { phone, password } - không lưu mật khẩu ở đâu cả
GET    /zalo-accounts/{id}/status          đồng bộ status worker → DB mỗi lần gọi
GET    /zalo-accounts/{id}/screenshot      PNG, dùng hiển thị QR / màn hình captcha
GET    /zalo-accounts/{id}/conversations   ?scroll_more=1 để tải thêm hội thoại cũ
GET    /zalo-accounts/{id}/search          ?keyword=
GET    /zalo-accounts/{id}/messages        ?conversation_key=&load_older=1
POST   /zalo-accounts/{id}/messages        multipart: conversation_key, content, attachments[]
```

### C3. API Worker (mọi route trừ /api/health đều cần token + HMAC)

```
GET  /api/health
POST /api/accounts                          { account_id, profile_key }
POST /api/accounts/:id/open                 { profile_key }
POST /api/accounts/:id/close
GET  /api/accounts/:id/status
GET  /api/accounts/:id/screenshot           image/png
GET  /api/accounts/:id/dom                  text/html — ROUTE DEBUG, xem C7
GET  /api/accounts/:id/conversations        ?scroll_more=1
GET  /api/accounts/:id/search               ?keyword=
GET  /api/accounts/:id/messages             ?conversation_key=&load_older=1
POST /api/accounts/:id/messages             { conversation_key, content?, attachments?[] }
POST /api/accounts/:id/login-password       { phone, password }

Mã lỗi: SESSION_NOT_READY(409) SESSION_BUSY(409) ALL_SESSIONS_BUSY(503) ACCOUNT_NOT_FOUND(404)
CONVERSATION_NOT_FOUND(404) BROWSER_CRASHED(500) SELECTOR_NOT_FOUND(500) TIMEOUT(408)
ATTACHMENT_INPUT_NOT_FOUND(500) ATTACHMENT_SEND_FAILED(500) VALIDATION_ERROR(422) UNAUTHORIZED(401)

Trạng thái tài khoản (khớp 1-1 giữa PHP enum và worker enum):
CREATED STARTING LOGIN_REQUIRED READY BUSY RELOGIN_REQUIRED DISCONNECTED ERROR DISABLED
```

### C4. Đã làm xong và đang chạy

- Quản lý tài khoản: CRUD + mở/đóng phiên + đồng bộ trạng thái worker → DB.
- Đăng nhập: quét QR qua ảnh chụp màn hình **và** đăng nhập bằng số điện thoại + mật khẩu
  trên form thật. Zalo bắt captcha → trả `verification_required`, người dùng tự làm qua screenshot.
  Không bypass gì cả.
- Danh sách hội thoại: đọc sidebar thật, có avatar, tin cuối, thời gian; `scroll_more` tải thêm.
- Tìm kiếm bạn bè/nhóm/hội thoại bằng chính ô tìm kiếm của Zalo Web (debounce ở client).
- Đọc tin nhắn: text + ảnh + video, phân biệt in/out, có avatar người gửi, `load_older` tải thêm.
  Ảnh/video của Zalo là `blob:` URL → convert sang data URL ngay trong page context (mục D2).
- Gửi tin THẬT: chữ, ảnh/video (tối đa 5 tệp), chọn qua nút 🖼️, **dán từ clipboard**, **kéo-thả**.
  Có bộ chọn emoji 8 nhóm. Optimistic UI + nút "Thử lại" khi lỗi.
- Vòng đời session: trần đồng thời + LRU evict + auto-close idle + auto-reopen phía Laravel.
- Polling thông minh + cache 3 tầng như mô tả ở B3/B4.

### C5. Chưa làm

- **Xác thực người dùng / phân quyền**: `SendMessageRequest::authorize()` vẫn `return true`,
  không có middleware auth trên bất kỳ route nào. Ai vào được `/chat` là gửi được tin.
- **Assignment / audit log**: chưa có bảng, chưa có code (mới chỉ có ý định trong B3).
- **Badge tin chưa đọc**: chưa có selector, chưa có code (mục E).
- **RELOGIN_REQUIRED**: enum có, nhưng chưa bao giờ phát hiện được vì chưa có selector.
- **callback-client.ts**: viết rồi nhưng KHÔNG có file nào import — worker chưa hề gọi ngược
  về Laravel. Muốn dùng phải set `LARAVEL_CALLBACK_URL` + thêm endpoint nhận ở Laravel.
- **SSE**: chưa có.
- **Test**: `backend/tests/` chỉ có khung mặc định của Laravel, không có test nào cho Zalo.
  Worker không có test.

### C6. Đã gỡ — đừng dựng lại nếu chưa hỏi (2026-07-30)

Hai service `reverb` (WebSocket) và `zalo-watcher` (poll hội thoại mỗi 5s rồi broadcast) đã bị
xóa khỏi `docker-compose.yml`. Lý do: KHÔNG có client nào subscribe Echo/pusher-js nên mọi
broadcast bắn vào hư không, trong khi watcher vẫn bắt Chromium đọc DOM mỗi 5s/tài khoản vĩnh viễn.
Đo được: CPU `zalo-worker` lúc rảnh ~18% khi có watcher, ~7% khi tắt; cộng thêm ~83MB RAM cho 2
tiến trình. Trang `/chat` vốn đã tự polling thông minh nên trải nghiệm không đổi.

Code còn giữ lại trong repo (không chạy): `WatchZaloConversations.php`, event
`ZaloConversationsUpdated`, `config/reverb.php`, `config/broadcasting.php`
(đang `BROADCAST_CONNECTION=log`).

### C7. Nợ kỹ thuật đã biết

1. `GET /api/accounts/:id/dom` — route debug trả nguyên HTML trang Zalo (có thể lộ nội dung chat).
   Vẫn còn vì selectors.ts còn 4 mục chưa xác minh; xong khảo sát thì XÓA.
2. Comment ở `routes/web.php` nói dữ liệu `/chat` "hiện là mock" — SAI, đã là dữ liệu thật từ lâu.
3. `README.md` là template mặc định GitLab, chưa viết gì về dự án.
4. `.env.example` ở gốc chỉ có 2 biến worker; các biến giới hạn session/tệp đính kèm chưa liệt kê.
5. Không có xác thực người dùng (đã nêu ở C5) — đây là rủi ro lớn nhất nếu deploy ra ngoài LAN.

---

## D. CẠM BẪY ĐÃ TRẢ GIÁ (đọc trước khi sửa, đừng phát hiện lại lần nữa)

### D1. `__name is not defined` trong `page.evaluate()`
tsx/esbuild chèn helper `__name(...)` khi giữ tên hàm, nhưng helper đó không tồn tại trong
browser context khi Playwright serialize callback sang page. → Trong mọi `page.evaluate()` /
`locator.evaluate()` chỉ được viết thuần thủ tục: `for`, `let`, `if`. KHÔNG khai báo hàm con,
kể cả arrow function một dòng. Xem `dom-scroll.util.ts` làm mẫu.

### D2. Ảnh/video Zalo là `blob:` URL — Node không fetch được
Zalo render ảnh bằng `URL.createObjectURL()`, blob đó chỉ sống trong đúng tab đã tạo ra nó.
Worker không thể fetch từ ngoài. → Phải nhờ chính trình duyệt fetch giúp rồi `FileReader`
encode base64, trả ra data URL (`blob-fetch.util.ts`). Đắt → có `attachmentCache` 5 phút,
tối đa 6 ảnh/tin, timeout 5s mỗi ảnh.

### D3. Ký HMAC — hai lỗi từng làm chết toàn bộ tiếng Việt
- PHP `json_encode()` mặc định escape Unicode thành `\uXXXX`, JS `JSON.stringify()` thì không
  → chuỗi ký hai bên khác nhau → mọi tin có dấu đều "chữ ký không khớp".
  → `ZaloWorkerClient::post()` BẮT BUỘC dùng `JSON_UNESCAPED_UNICODE`.
- Worker phải ký trên **rawBody nguyên văn** (giữ lại bằng `addContentTypeParser` trong
  `server.ts`), không được `JSON.stringify(request.body)` — PHP escape `/` thành `\/`, ký tự
  này có trong MỌI chuỗi base64.
- GET không có body → cả hai phía ký với rawBody rỗng `''`.
- Middleware phải chạy ở hook `preValidation` (không phải `onRequest`) vì cần body đã parse xong.

### D4. Trần dung lượng tệp đính kèm nằm ở 5 nơi — thiếu 1 nơi là hỏng âm thầm
`config/zalo.php` là NGUỒN DUY NHẤT, nhưng các trần hạ tầng phải ≥ nó:

| Nơi | Giá trị hiện tại | Vai trò |
|---|---|---|
| `backend/config/zalo.php` | 5 tệp / ảnh 5MB / video 15MB / tổng 20MB | nguồn sự thật |
| `docker/php/uploads.ini` | `upload_max_filesize 20M`, `post_max_size 24M`, `memory_limit 384M` | PHP nuốt request rỗng nếu vượt, KHÔNG báo lỗi |
| `docker/nginx/default.conf` | `client_max_body_size 24M` | nginx cắt request trước khi tới PHP |
| `worker/src/server.ts` | `bodyLimit` 40MB | base64 phình ~33% so với tệp gốc |
| blade `/chat` → `window.ZALO_CHAT_LIMITS` | nhúng từ config | chặn ngay tại trình duyệt |

Timeout cũng phải xếp tầng: `ZaloWorkerClient` 120s cho tệp → `uploads.ini max_execution_time 180`
→ `nginx fastcgi_read_timeout 180s`. Laravel bỏ cuộc giữa chừng thì tin VẪN được gửi đi thật.

### D5. Deploy — `docker cp` trước, restart sau
`docker-compose.yml` có bind-mount `./backend:/var/www/html` và `./worker:/app`, nhưng đã từng
gặp trường hợp file trong container không đổi theo. → Cứ `docker cp` cho chắc, và nếu nghi ngờ
thì `docker exec ... cat` đúng file trong container để xác nhận trước khi kết luận "code không chạy".
Đổi `config/*.php` thì phải `php artisan config:clear`.

### D6. Cuộn danh sách ảo hóa phải dùng `mouse.wheel()` thật
Zalo dùng react-virtualized, chỉ lắng nghe sự kiện wheel/scroll tự nhiên. Set `.scrollTop` bằng JS
KHÔNG kích hoạt tải thêm. Phần tử cuộn thật có thể là **descendant** (khung tin nhắn —
`#chatView` tự nó `overflow:visible`) hoặc **ancestor** (sidebar) của selector mốc → `wheelScroll()`
dò cả hai hướng.

### D7. Worker restart = mất sạch session trong RAM
Profile trên đĩa vẫn giữ đăng nhập nên mở lại không cần QR, nhưng request đầu tiên sau restart sẽ
trả `ACCOUNT_NOT_FOUND`. Laravel đã tự xử lý bằng `callWorkerWithAutoReopen()` — chỉ retry đúng
1 lần và CHỈ khi lỗi là `ACCOUNT_NOT_FOUND` (an toàn vì lúc đó worker chưa đụng vào browser,
không có nguy cơ gửi trùng tin).

### D8. Token/secret phải khớp 3 nơi
`.env` ở gốc (cấp cho container worker qua docker-compose) và `backend/.env`
(`ZALO_WORKER_TOKEN` / `ZALO_WORKER_SECRET`). Lệch → mọi request trả `UNAUTHORIZED` → tài khoản
rơi vào `ERROR` và QR không bao giờ hiện, mà log Laravel nhìn không ra nguyên nhân.

---

## E. SELECTORS ZALO WEB

Tất cả nằm ở `worker/src/adapters/zalo-web/selectors.ts`, chia 3 nhóm: `AUTH_SELECTORS`,
`CHAT_LIST_SELECTORS`, `MESSAGE_SELECTORS`. **Không hardcode selector ở bất kỳ file nào khác.**

Đã xác minh trên DOM thật (2026-07-29 → 2026-07-31), một số điểm đáng nhớ:

- `#conversationList` vừa là danh sách hội thoại vừa là dấu hiệu "đã đăng nhập".
- Tin tự gửi: dựa vào **class `me` trên `.chat-message`** (`MESSAGE_SENT_CLASS`) chứ đừng dùng
  `[data-id="div_SentMsg_Text"]` — cái sau chỉ có ở tin dạng chữ, tin ảnh/video sẽ bị bỏ sót.
- Ảnh trong tin: selector phải để trần `img.zimg-el`, KHÔNG bọc trong `.card--group-photo`.
  Zalo dùng 2 cấu trúc khác nhau: album nhiều ảnh nằm trong `.card--group-photo`, còn tin MỘT ảnh
  nằm trong `.img-msg-v2.photo-message-v2`. Bám vào `card--group-photo` là mất trắng mọi tin một ảnh.
  (An toàn: `zimg-el` chỉ dùng cho ảnh trong tin, avatar dùng `.zavatar-container img`.)
- Mốc cuộn khung tin nhắn là `#chatViewContainer`, không phải `#chatView`.
- Nút "Gửi hình ảnh" là thẻ `<div>` chứ không phải `<button>` → phải bám
  `[data-translate-title="STR_SEND_PHOTO"]`, không dùng được `getByRole('button')`.
- Zalo KHÔNG render sẵn `<input type="file">` nào (đếm được 0 thẻ trên DOM thật) — nó chỉ tạo khi
  bấm nút đính kèm. → Đường đi CHÍNH là bắt sự kiện `filechooser` của Playwright; `FILE_INPUT`
  chỉ là dự phòng.
- Captcha sau khi submit form mật khẩu nằm trong `iframe#challenge`; `div#zcaptcha` ở document
  gốc luôn RỖNG.

**Còn 4 mục CHƯA xác minh** (cần một buổi khảo sát trên tài khoản thật):

| Hằng số | Tình trạng |
|---|---|
| `AUTH_SELECTORS.SESSION_EXPIRED_INDICATOR` | placeholder `[data-id="TODO..."]` — chưa gặp trạng thái session hết hạn |
| `CHAT_LIST_SELECTORS.CHAT_ITEM_UNREAD_BADGE` | placeholder — lúc khảo sát không có hội thoại nào chưa đọc |
| `MESSAGE_SELECTORS.MESSAGE_VIDEO_TAG` | đoán là `video`, chưa có tin video mẫu để kiểm |
| `MESSAGE_SELECTORS.ATTACHMENT_SEND_CANDIDATES` | 3 ứng viên thử lần lượt, chưa biết Zalo dựng nút nào; worker có log lại nút khớp |

---

## F. VIỆC CÒN LẠI (thứ tự đề xuất)

Mục 1–6 của roadmap cũ (polling thông minh, Redis cache, optimistic send, session lifecycle,
Chromium flags) đã XONG — xem C4. Còn lại:

1. **Xác thực + phân quyền** (C5). Đây là việc quan trọng nhất còn lại: hiện ai mở được `/chat`
   là gửi tin thật từ tài khoản công ty. Ít nhất phải có login Laravel + gắn middleware `auth`
   lên `routes/api.php` và 2 trang web, rồi mới tính tới assignment theo tài khoản.
2. **Buổi khảo sát DOM gộp 1 lần** cho 4 selector còn treo ở mục E (badge chưa đọc,
   RELOGIN_REQUIRED, thẻ video, nút gửi sau khi đính kèm). Khảo sát xong → **xóa route
   `GET /api/accounts/:id/dom`** (C7.1).
3. **Audit log gửi tin**: ai, tài khoản nào, hội thoại nào, lúc nào, có tệp không. Ghi MySQL.
   Rẻ, và là thứ duy nhất bù lại cho quyết định "Enter là gửi thẳng, không hỏi".
4. **Wire `callback-client.ts`** để worker báo ngược trạng thái (đặc biệt RELOGIN_REQUIRED)
   về Laravel → bỏ được nhịp poll trạng thái tài khoản. Phụ thuộc mục 2.
5. **Dọn nợ nhỏ**: sửa comment sai ở `routes/web.php`, viết lại `README.md` trỏ về file này,
   bổ sung đủ biến vào `.env.example`.
6. Chỉ khi 1–5 xong và vẫn thấy chậm: cân nhắc SSE thay polling. Reverb là phương án cuối.
