# Zalo Bot — Cách tạo code & function

Bot Node: Express + Puppeteer. Mở Zalo Web (`https://chat.zalo.me/`), login QR một lần, giữ session, nhận HTTP rồi gửi tin vào nhóm (có @mention).

Thư mục: `scripts/zalo-bot/`

Gửi tin: chọn nhóm + @tag tên → [`zalo-bot-send-tag.md`](zalo-bot-send-tag.md).  
Chạy từ đầu (chưa login) → [`zalo-bot-run.md`](zalo-bot-run.md).

---

## 1. Ý tưởng

Zalo group chat không có API chính thức. Bot giả lập tay trên Zalo Web:

1. Chrome (Puppeteer) mở `chat.zalo.me`.
2. Lần đầu chưa login → chụp QR (`qr.png`) → quét bằng app Zalo.
3. Cookie/session lưu `zalo_user_data/` → lần sau không quét lại.
4. Express listen `127.0.0.1:3910`. Client POST `{ group, tags[], message }`.
5. Bot tìm nhóm → focus ô chat → `@` + tên (chọn mention) → gõ tin nhiều dòng (`Shift+Enter`) → `Enter` gửi **một** tin.

```
Client HTTP
    │  POST /send  +  X-Api-Key
    ▼
server.js  (Express, 1 tab Chrome sống lâu)
    │  openGroup → tagPerson[] → typeMultiline → Enter
    ▼
chat.zalo.me  (nhóm)
```

---

## 2. File

| File | Vai trò |
|---|---|
| `server.js` | Bot chính: API HTTP + Chrome sống + gửi tin có @tag |
| `index.js` | One-shot: login QR + gửi 1 tin (test / login lần đầu) |
| `ecosystem.config.cjs` | pm2 |
| `.puppeteerrc.cjs` | Cache Chrome tại `.cache/puppeteer` |
| `package.json` | Scripts npm |
| `zalo_user_data/` | Session Chrome (không commit) |
| `qr.png` | QR khi chưa login |

---

## 3. Tạo bot từ đầu

### 3.1. Init

```bash
mkdir -p scripts/zalo-bot && cd scripts/zalo-bot
npm init -y
npm install express puppeteer
```

`package.json` scripts:

```json
{
  "scripts": {
    "start": "PUPPETEER_CACHE_DIR=./.cache/puppeteer node server.js",
    "start:once": "PUPPETEER_CACHE_DIR=./.cache/puppeteer node index.js",
    "start:ui": "PUPPETEER_CACHE_DIR=./.cache/puppeteer ZALO_HEADLESS=false node server.js",
    "install-chrome": "PUPPETEER_CACHE_DIR=./.cache/puppeteer npx puppeteer browsers install chrome",
    "pm2:start": "pm2 start ecosystem.config.cjs",
    "pm2:stop": "pm2 stop zalo-bot",
    "pm2:restart": "pm2 restart zalo-bot",
    "pm2:logs": "pm2 logs zalo-bot"
  }
}
```

Cài Chrome local (server Linux thường không có GUI Chrome):

```bash
npm run install-chrome
```

`.puppeteerrc.cjs`:

```js
const { join } = require('path');
module.exports = { cacheDirectory: join(__dirname, '.cache', 'puppeteer') };
```

### 3.2. Config trong `server.js`

```js
const CONFIG = {
    port: Number(process.env.ZALO_BOT_PORT || 3910),
    apiKey: process.env.ZALO_BOT_API_KEY || 'lms-zalo-secret',
    groupName: process.env.ZALO_GROUP_NAME || 'placementtest_lcms',
    headless: process.env.ZALO_HEADLESS !== 'false',
    userDataDir: path.join(__dirname, 'zalo_user_data'),
    qrPath: path.join(__dirname, 'qr.png'),
    zaloUrl: 'https://chat.zalo.me/',
    chromePath: resolveChromePath(),
};
```

- Bind **chỉ** `127.0.0.1` — không expose ra ngoài.
- `userDataDir` = session. Xóa thư mục này = phải quét QR lại.

### 3.3. Login QR rồi chạy nền

Lần đầu cần GUI (hoặc VNC):

```bash
cd /var/www/html/lms_hocmai/scripts/zalo-bot
npm run start:ui
```

Quét QR trên cửa sổ Chrome, hoặc mở `qr.png`. Session lưu `zalo_user_data/`.

Production (headless, đã có session):

```bash
npm run pm2:start
npm run pm2:logs
```

`ecosystem.config.cjs`:

```js
module.exports = {
  apps: [{
    name: 'zalo-bot',
    script: 'server.js',
    cwd: __dirname,
    env: {
      PUPPETEER_CACHE_DIR: './.cache/puppeteer',
      ZALO_BOT_PORT: '3910',
      ZALO_BOT_API_KEY: 'lms-zalo-secret',
      ZALO_GROUP_NAME: 'placementtest_lcms',
      ZALO_HEADLESS: 'true',
    },
    autorestart: true,
    max_restarts: 20,
    restart_delay: 5000,
  }],
};
```

### 3.4. Test API

```bash
curl -s http://127.0.0.1:3910/health

curl -s -X POST http://127.0.0.1:3910/send \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: lms-zalo-secret" \
  -d '{"group":"placementtest_lcms","message":"hello","tags":["Tran Thanh Tung"]}'
```

---

## 4. API (`server.js`)

Auth: header `X-Api-Key` (hoặc `?api_key=` / body `api_key`). Function: `authMiddleware`.

| Method | Path | Auth | Mô tả |
|---|---|---|---|
| GET | `/health` | không | `{ ready, logged_in, group }` |
| POST | `/send` | có | Gửi 1 tin vào nhóm |

Body `/send`:

```json
{
  "group": "tên nhóm",
  "tags": ["Tên Zalo đúng như trong nhóm"],
  "message": "dòng 1\ndòng 2"
}
```

- `group` trống → dùng `CONFIG.groupName`.
- Request **serialize** (`sendQueue`) vì chỉ 1 tab Chrome.
- Lỗi: HTTP 500 + screenshot `debug_error.png`.

---

## 5. Function — `server.js` (bot HTTP)

Đây là file production. Mỗi request `/send` gọi chuỗi: `ensureBrowser` → `sendGroupMessageWithTags`.

### Chrome / login

| Function | Việc làm |
|---|---|
| `resolveChromePath()` | Tìm binary Chrome trong `.cache/puppeteer/.../chrome-linux64/chrome`, fallback `puppeteer.executablePath()` |
| `isLoggedIn(page)` | Đợi selector ô search / chat-list. Có → đã login |
| `captureQrCode(page)` | Screenshot phần tử QR (`.qrcode`, canvas, …) ra `qr.png`; không thấy thì chụp cả trang |
| `waitForLogin(page, timeoutMs)` | Poll `isLoggedIn` mỗi 2s, mặc định 180s |
| `ensureBrowser()` | Launch Chrome + `userDataDir`, `goto` Zalo. Đã login thì `ready=true`. Chưa thì chụp QR + `waitForLogin` |
| `boot()` | Gọi `ensureBrowser()` rồi `app.listen(port, '127.0.0.1')`. Fail login vẫn listen; `/send` lần sau thử lại |

Launch args quan trọng: `--no-sandbox`, `--disable-setuid-sandbox`, `--disable-dev-shm-usage` (chạy được trên Linux server).

### Gửi tin

| Function | Việc làm |
|---|---|
| `openGroupChat(page, groupName)` | Click ô search (`#contact-search-input` / placeholder “Tìm”), gõ tên nhóm, Enter, click conversation đầu (`.conv-item`, …) |
| `focusMessageInput(page)` | Click ô soạn `#richInput` / `div[contenteditable=true]` |
| `mentionMenuVisible(page)` | `page.evaluate`: popup mention/suggest đang hiện? |
| `clickFirstMentionSuggestion(page)` | Click item đầu trong menu @tag (class `mention` / `friend-item` / `active`) |
| `tagPerson(page, personName)` | Focus ô chat → gõ `@` → gõ tên → chọn suggestion (click hoặc ArrowDown+Enter) → space. Enter lúc này **chỉ xác nhận tag**, không gửi tin |
| `typeMultilineMessage(page, text)` | Tách `\n`. Mỗi dòng `keyboard.type`. Xuống dòng = **Shift+Enter**. Enter thường chỉ dùng 1 lần lúc gửi |
| `sendGroupMessageWithTags(page, group, tags, message)` | `openGroupChat` → `tagPerson` từng tên (unique) → `typeMultilineMessage` → `Enter` **một lần** |

Thứ tự gửi 1 tin:

```
openGroupChat
focusMessageInput
for tag in tags:  tagPerson   // @Tên → chọn menu → space
typeMultilineMessage          // Shift+Enter giữa các dòng
keyboard.press(Enter)         // gửi
```

**Tên trong `tags` phải trùng** tên hiện trong danh sách mention của nhóm (khoảng trắng, dấu). Sai tên → menu lọc sai hoặc không tag.

### HTTP

| Function | Việc làm |
|---|---|
| `authMiddleware` | So sánh key với `CONFIG.apiKey` → 401 nếu sai |
| `GET /health` | `{ success, ready, logged_in, group }` |
| `POST /send` | Validate message/group → `sendQueue.then(ensureBrowser + sendGroupMessageWithTags)` |
| SIGINT / SIGTERM | `browser.close()` |

Queue:

```js
let sendQueue = Promise.resolve();
// trong /send:
await (sendQueue = sendQueue.then(async () => { ... }));
```

Tránh 2 request cùng lúc đụng 1 page.

---

## 6. Function — `index.js` (one-shot)

Không có HTTP. Chạy xong gửi 1 tin rồi giữ process.

| Function | Việc làm |
|---|---|
| `resolveChromePath()` | Giống `server.js` |
| `isLoggedIn(page)` | Giống, timeout selector 3s |
| `captureQrCode(page)` | Giống |
| `waitForLogin(page, timeoutMs)` | Mặc định **120s** |
| `sendGroupMessage(page, groupName, messageText)` | Mở nhóm → gõ **một dòng** vào ô chat → Enter. **Không @tag**, không Shift+Enter |

Env: `ZALO_GROUP_NAME`, `ZALO_MESSAGE`, `ZALO_HEADLESS`.

```bash
npm run start:once
# hoặc
ZALO_GROUP_NAME="Tên nhóm" ZALO_MESSAGE="hello" npm run start:once
```

Dùng để test login / selector. Production dùng `server.js`.

---

## 7. Selector Zalo Web (dễ đổi UI)

Bot thử nhiều selector vì Zalo hay đổi class.

**Đã login:** `#contact-search-input`, `#waw-contact-search-input`, `input[placeholder*="Tìm"]`, `div[data-id="chat-list"]`, `.chat-list`

**QR:** `.qrcode`, `.qr-container`, `canvas`, `img[src*="qr"]`, `.login-qr`

**Conversation:** `.conv-item`, `.chat-list-item`, `[data-id="conv_item"]`

**Ô chat:** `#richInput`, `#rich-input-page-chat`, `div[contenteditable="true"]`

Selector gãy → `/send` 500, xem `debug_error.png`, cập nhật mảng selector trong function tương ứng.

---

## 8. Troubleshooting

| Hiện tượng | Xử lý |
|---|---|
| Chrome not found | `npm run install-chrome` |
| `/health` fail | pm2 chưa start / port ≠ 3910 |
| `ready: false` | Chưa login — `ZALO_HEADLESS=false npm start`, quét QR |
| Session hết | Xóa / giữ `zalo_user_data`, chạy lại `start:ui` |
| Có tin không @tag | Tên `tags` không khớp tên Zalo trong nhóm |
| Gửi lỗi | `debug_error.png` + `pm2 logs zalo-bot` |
