# Phương án tích hợp chat Zalo OA

Ngày cập nhật: 11/08/2026

Tài liệu này đề xuất cách thêm kênh chat Zalo Official Account (OA) vào ToolZalo hiện tại.
Project đang có kênh Zalo cá nhân chạy qua `worker` Node.js + `zca-js`, tin realtime được
worker đẩy về Laravel rồi lưu vào MySQL. Với Zalo OA, nên đi trực tiếp qua OA OpenAPI và
Webhook chính thức của Zalo, không đưa qua worker.

Kết luận ngắn: **thêm Zalo OA như một loại tài khoản mới trong `zalo_accounts`, dùng Laravel
nhận Webhook và gọi OA OpenAPI, chuẩn hóa payload OA về cùng shape của
`ZaloMessageIngestService` để tái sử dụng màn chat, tag, tin nhắn mẫu và AI auto-reply.**

## 1. Hiện trạng project

Các phần có thể tái sử dụng:

- `zalo_accounts`: quản lý nhiều tài khoản, phân quyền theo chủ sở hữu và nhân viên được gán.
- `zalo_conversations`: sidebar hội thoại theo `zalo_account_id` + `thread_id`.
- `zalo_messages`: kho tin nhắn, chống trùng bằng `(zalo_account_id, msg_id)`.
- `ZaloMessageIngestService`: nhận payload đã chuẩn hóa, ghi tin, cập nhật hội thoại, kích
  hoạt auto-reply.
- `ZaloAccountService::sendMessage()`: hiện gửi qua worker. Cần tách thành strategy để tài
  khoản cá nhân gửi qua worker, tài khoản OA gửi qua OpenAPI.
- UI `/chat`: đã đọc tài khoản, hội thoại, tin nhắn từ API Laravel. Nếu dữ liệu OA cùng nằm
  trong các bảng trên thì UI chỉ cần sửa nhẹ các nút/tính năng không hỗ trợ.

Giới hạn hiện tại:

- `zalo_accounts` chưa có trường phân biệt kênh cá nhân/OA.
- Token OA có vòng đời riêng: access token 25 giờ, refresh token 3 tháng và refresh token là
  token dùng một lần.
- Webhook OA đi thẳng về Laravel, không có worker làm bộ đệm. Nếu xử lý lâu hơn 2 giây, Zalo
  có thể retry hoặc vô hiệu webhook.
- OA không tương đương Zalo cá nhân: MVP nên chỉ hỗ trợ hội thoại OA - user 1:1, chưa hỗ trợ
  nhóm/bạn bè như worker hiện tại.

## 2. Điểm cần nắm từ tài liệu Zalo OA

Zalo OA OpenAPI cho phép doanh nghiệp vận hành OA qua server nội bộ. Khi có tương tác từ
người dùng hoặc thay đổi liên quan đến OA, Zalo gửi HTTP POST về Webhook URL đã đăng ký.

Xác thực và token:

- App cần được OA cấp quyền để lấy OA access token.
- OAuth v4 dùng `authorization_code` + PKCE để lấy `access_token` và `refresh_token`.
- `access_token` có hiệu lực 25 giờ.
- `refresh_token` có hiệu lực 3 tháng, chỉ dùng một lần; refresh thành công sẽ trả về cặp
  access/refresh token mới và access token cũ hết hiệu lực.
- Endpoint đổi token: `POST https://oauth.zaloapp.com/v4/oa/access_token` với header
  `secret_key`.

Webhook:

- Webhook URL nên là domain HTTPS, không nên dùng `host:port`.
- Tất cả webhook cần trả `200 OK` trong tối đa 2 giây.
- Zalo retry khi không mở được connection, theo các mốc 30 giây, 5 phút, 15 phút, 30 phút,
  1 giờ.
- Chữ ký nằm ở header `X-ZEvent-Signature`, công thức:
  `mac = sha256(appId + data + timeStamp + OAsecretKey)`.
- Event tin người dùng gửi gồm `user_send_text`, `user_send_image`, `user_send_link`,
  `user_send_audio`, `user_send_video`, `user_send_sticker`, `user_send_location`,
  `user_send_business_card`, `user_send_file`.
- Event OA gửi tin ngược lại gồm các dạng `oa_send_*`. Cần bật các event này để tin gửi từ
  OA Manager hoặc từ OpenAPI đều hiện trong ToolZalo.

Gửi tin:

- Tin tư vấn dạng văn bản dùng `POST https://openapi.zalo.me/v3.0/oa/message/cs`.
- Body có `recipient.user_id` và `message.text`; text tối đa 2.000 ký tự.
- Gửi tin trả lời trích dẫn cũng dùng endpoint `/message/cs`, thêm
  `message.quote_message_id`.
- Ảnh/file cần upload trước:
  `POST /v2.0/oa/upload/image` trả `attachment_id`, ảnh jpg/png tối đa 1MB.
  `POST /v2.0/oa/upload/file` trả `token`, file PDF/DOC/DOCX/CSV tối đa 5MB.
- Có API kiểm tra hạn mức gửi tin tới user:
  `POST https://openapi.zalo.me/v3.0/oa/quota/message`.
- Tin tư vấn yêu cầu user đã tương tác với OA. Tài liệu hiện hành nói user có tương tác trong
  7 ngày, đồng thời response/quota từ 01/01/2026 có thay đổi theo khung 48h; khi code nên
  kiểm tra quota/last interaction bằng API thay vì hard-code toàn bộ điều kiện.
- Tin giao dịch có cảnh báo chuyển đổi: từ 01/01/2026 ra mắt ZBS Template Message, dịch vụ
  Tin UID Giao dịch và UID Truyền thông cá nhân dừng cung cấp từ 01/03/2026. Nếu cần gửi tin
  ngoài ngữ cảnh CSKH, nên tách sau thành module template/ZBS riêng.

## 3. Kiến trúc đề xuất

```mermaid
sequenceDiagram
    participant Z as Zalo OA
    participant L as Laravel
    participant DB as MySQL
    participant Q as Queue
    participant UI as Web Chat

    Z->>L: POST /api/zalo-oa/webhook/{account}
    L->>L: verify X-ZEvent-Signature
    L->>DB: insert raw event / chống trùng
    L-->>Z: 200 OK trong <= 2s
    L->>Q: ProcessZaloOaWebhookJob
    Q->>DB: chuẩn hóa -> ZaloMessageIngestService
    UI->>L: đọc conversations/messages như hiện tại
    UI->>L: POST /api/zalo-accounts/{id}/messages
    L->>Z: OA OpenAPI /message/cs hoặc upload + send
```

Nguyên tắc tích hợp:

- **Không đưa OA qua worker.** Worker sinh ra để giữ phiên Zalo cá nhân bằng `zca-js`; OA đã
  có OpenAPI và Webhook chính thức, dùng trực tiếp sẽ đơn giản và ổn định hơn.
- **Dùng chung kho dữ liệu chat.** OA payload được map thành `account_id`, `thread_id`,
  `thread_type=user`, `msg_id`, `direction`, `sender_id`, `sender_name`, `content`,
  `msg_type`, `attachments`, `quote_msg_id`, `sent_at`.
- **Tách kênh gửi bằng strategy.** `ZaloAccountService::sendMessage()` chọn client theo
  `zalo_accounts.channel`: `personal` dùng `ZaloWorkerClient`, `oa` dùng `ZaloOaClient`.
- **Webhook phải nhanh.** Controller chỉ verify, log raw event, đẩy job và trả 200. Mọi việc
  gọi AI, tải file, enrich profile, ghi message nên chạy trong queue/job.
- **Idempotency là bắt buộc.** Zalo có retry; raw event và message phải chống trùng theo
  `app_id + event_name + message.msg_id + timestamp` hoặc theo `zalo_account_id + msg_id`.

## 4. Thay đổi database

### 4.1 Mở rộng `zalo_accounts`

```php
Schema::table('zalo_accounts', function (Blueprint $table) {
    $table->string('channel', 20)->default('personal')->after('id');
    $table->string('oa_id', 64)->nullable()->after('zalo_uid');
    $table->string('oa_app_id', 64)->nullable()->after('oa_id');
    $table->string('oa_name')->nullable()->after('oa_app_id');
    $table->text('oa_avatar_url')->nullable()->after('oa_name');
    $table->string('oa_webhook_secret_version', 32)->nullable();
    $table->timestamp('token_expires_at')->nullable();
});
```

Gợi ý:

- `channel`: enum logic trong PHP, giá trị `personal`/`oa`.
- `oa_id`: ID của OA, trùng với `recipient.id` trong webhook user gửi tin.
- `zalo_uid`: với kênh cá nhân vẫn là uid của nick; với OA có thể để null hoặc mirror `oa_id`
  nếu muốn lọc nhanh.
- `status`: OA sau khi token hợp lệ có thể đặt `READY`; token lỗi/cần cấp quyền lại thì
  `RELOGIN_REQUIRED` hoặc `ERROR`.

### 4.2 Bảng token OA

Nên tách token ra bảng riêng để mã hóa và xoay vòng:

```php
Schema::create('zalo_oa_tokens', function (Blueprint $table) {
    $table->id();
    $table->foreignId('zalo_account_id')->index();
    $table->text('access_token');
    $table->text('refresh_token');
    $table->timestamp('access_token_expires_at')->nullable();
    $table->timestamp('refresh_token_expires_at')->nullable();
    $table->timestamp('last_refreshed_at')->nullable();
    $table->timestamps();
});
```

Dùng cast `encrypted` của Laravel cho `access_token`, `refresh_token`.

### 4.3 Bảng raw webhook

```php
Schema::create('zalo_oa_webhook_events', function (Blueprint $table) {
    $table->id();
    $table->foreignId('zalo_account_id')->nullable()->index();
    $table->string('app_id', 64)->index();
    $table->string('oa_id', 64)->nullable()->index();
    $table->string('event_name', 80)->index();
    $table->string('message_id', 80)->nullable();
    $table->string('event_key', 191)->unique();
    $table->json('payload');
    $table->json('headers')->nullable();
    $table->timestamp('occurred_at')->nullable();
    $table->timestamp('processed_at')->nullable();
    $table->text('last_error')->nullable();
    $table->timestamps();
});
```

Lý do cần bảng này:

- Debug webhook thất bại dễ hơn.
- Retry từ Zalo không sinh tin trùng.
- Có thể replay event khi mapper lỗi.

### 4.4 Contact OA

`zalo_contacts` có thể dùng chung nếu `account_id + uid` được hiểu là user OA. Nếu cần mở
rộng, thêm các trường:

- `source`: `personal`/`oa`.
- `oa_user_id`: UID theo OA.
- `user_id_by_app`: UID theo app, dùng khi cần gọi social API.
- `last_interaction_at`.
- `is_follower`.
- `raw_profile`.

Phase 1 chưa cần lấy profile đầy đủ. Tên hội thoại có thể hiện `sender.id` cho tới khi gọi API
chi tiết user.

## 5. Component cần viết

### 5.1 Config

Thêm vào `config/zalo.php`:

```php
'oa' => [
    'app_id' => env('ZALO_OA_APP_ID'),
    'app_secret' => env('ZALO_OA_APP_SECRET'),
    'oauth_callback_url' => env('ZALO_OA_OAUTH_CALLBACK_URL'),
    'webhook_secret' => env('ZALO_OA_WEBHOOK_SECRET'),
    'api_base_url' => env('ZALO_OA_API_BASE_URL', 'https://openapi.zalo.me'),
    'oauth_base_url' => env('ZALO_OA_OAUTH_BASE_URL', 'https://oauth.zaloapp.com'),
],
```

Nếu hệ thống quản lý nhiều Zalo App, không đặt `app_id/app_secret` trong env duy nhất; tạo
bảng `zalo_oa_apps`. Nếu chỉ dùng một App cho nhiều OA, env là đủ cho MVP.

### 5.2 `ZaloOaOAuthService`

Nhiệm vụ:

- Tạo `code_verifier`, `code_challenge`, `state`.
- Tạo URL cấp quyền cho admin OA.
- Xử lý callback `GET /zalo-oa/oauth/callback?code=...&oa_id=...`.
- Gọi `POST /v4/oa/access_token` với `grant_type=authorization_code`.
- Lưu token, `oa_id`, `oa_app_id`, tên OA nếu gọi được `/v2.0/oa/getoa`.
- Refresh token chủ động trước khi hết hạn, vì refresh token chỉ dùng một lần.

Route đề xuất:

- `POST /api/zalo-oa/authorize-url`
- `GET /zalo-oa/oauth/callback`
- `POST /api/zalo-accounts/{id}/oa/refresh-token`
- `DELETE /api/zalo-accounts/{id}/oa/disconnect`

### 5.3 `ZaloOaClient`

Client dùng `Http::withHeaders(['access_token' => $token])`.

Method MVP:

- `sendText(int $accountId, string $userId, string $text, ?string $quoteMessageId = null)`.
- `sendImage(...)`: upload image trước, sau đó gọi API gửi tin tư vấn đính kèm ảnh.
- `sendFile(...)`: upload file trước, sau đó gọi API gửi tin tư vấn đính kèm file.
- `quota(string $userId)`: kiểm tra last interaction/quota trước khi auto-reply nếu cần.
- `getConversation(string $userId, int $offset = 0, int $count = 10)`: backfill nhỏ khi vừa
  kết nối OA hoặc cần đối soát.

Wrapper `withFreshToken($account, fn () => ...)`:

1. Nếu token sắp hết hạn, lock theo `zalo_account_id`.
2. Gọi refresh token.
3. Lưu cặp token mới trong transaction.
4. Retry một lần nếu API báo token hết hạn.

### 5.4 `ZaloOaWebhookController`

Route public không auth session:

```php
Route::post('zalo-oa/webhook/{account}', [ZaloOaWebhookController::class, 'handle'])
    ->name('zalo-oa.webhook');
```

Xử lý:

1. Đọc raw body đúng chuỗi gốc.
2. Parse JSON.
3. Tìm account theo `{account}` hoặc theo `recipient.id`/`sender.id` là `oa_id`.
4. Verify `X-ZEvent-Signature`.
5. Tạo `event_key`: `sha1(app_id|event_name|message.msg_id|sender.id|recipient.id|timestamp)`.
6. Insert raw event bằng `firstOrCreate`.
7. Dispatch `ProcessZaloOaWebhookEventJob`.
8. Trả `200 OK` ngay, kể cả event đã tồn tại.

Nếu signature sai thì trả `401` và log cảnh báo. Không xử lý event signature sai.

### 5.5 `ProcessZaloOaWebhookEventJob`

Map event:

| Event | Direction | Thread | Ghi chú |
|---|---|---|---|
| `user_send_*` | `in` | `sender.id` | user gửi vào OA |
| `oa_send_*` | `out` | `recipient.id` | OA gửi ra user |
| `user_received_message` | none | user id | cập nhật delivery nếu sau này có cột status |
| `user_seen_message` | none | user id | cập nhật seen/read nếu sau này có cột status |
| `user_reacted_message` | event | user id | có thể map vào `zalo_message_reactions` sau MVP |

Payload chuẩn hóa ví dụ:

```php
[
    'account_id' => $account->id,
    'msg_id' => $payload['message']['msg_id'],
    'thread_id' => $userId,
    'thread_type' => 'user',
    'direction' => 'in',
    'sender_id' => $payload['sender']['id'],
    'sender_name' => $profileName,
    'content' => $payload['message']['text'] ?? '',
    'msg_type' => $mappedType,
    'attachments' => $attachments,
    'quote_msg_id' => $payload['message']['quote_msg_id'] ?? null,
    'sent_at' => Carbon::createFromTimestampMs((int) $payload['timestamp'])->toIso8601String(),
]
```

Sau đó gọi:

```php
app(ZaloMessageIngestService::class)->ingest([$normalized]);
```

Tránh auto-reply lặp:

- `oa_send_*` là `direction=out`, `ZaloMessageIngestService` hiện chỉ trigger auto-reply với
  tin `in`, nên an toàn.
- Khi ToolZalo gửi tin qua OpenAPI, Zalo có thể gửi lại webhook `oa_send_*`; unique
  `(zalo_account_id, msg_id)` sẽ chống trùng.

## 6. Điều chỉnh UI/API hiện tại

MVP nên giữ endpoint cũ:

- UI vẫn gọi `/api/zalo-accounts`.
- UI vẫn gọi `/api/zalo-accounts/{id}/conversations`.
- UI vẫn gọi `/api/zalo-accounts/{id}/messages`.
- UI vẫn gửi tin qua `/api/zalo-accounts/{id}/messages`.

Cần thêm metadata trong response account:

```json
{
  "id": 12,
  "channel": "oa",
  "name": "OA Công ty",
  "status": "READY",
  "capabilities": {
    "groups": false,
    "stickers": false,
    "reactions": false,
    "quote": true,
    "attachments": ["image", "file"]
  }
}
```

UI dựa trên `capabilities` để ẩn tính năng không có với OA:

- Tạo/sửa nhóm, danh bạ bạn bè, gửi danh thiếp, thẻ ngân hàng: ẩn với `channel=oa`.
- Reaction/sticker: phase sau.
- Composer text: bật.
- Reply quote: bật nếu message có `msg_id`.
- Attachment: phase 1 có thể chỉ bật image/file sau khi viết upload OA.

## 7. Lộ trình triển khai

### Phase 1: MVP nhận và gửi text

Mục tiêu: OA nhận tin user, hiện trong chat, nhân viên và AI gửi text trả lời.

Việc cần làm:

- Thêm `channel`, `oa_id`, token metadata vào DB.
- Tạo flow kết nối OA bằng API Explorer trước để nhanh: admin paste `refresh_token`, hệ thống
  refresh ra `access_token`. OAuth PKCE full làm ở phase 2.
- Tạo `ZaloOaClient::sendText()`.
- Tạo `ZaloOaWebhookController` + verify signature.
- Tạo `ProcessZaloOaWebhookEventJob` map `user_send_text`, `oa_send_text`.
- Sửa `ZaloAccountService::sendMessage()` để dispatch theo channel.
- Test feature: signature đúng/sai, event trùng, ingest `user_send_text`, ingest
  `oa_send_text`, send text OA, auto-reply với `user_send_text`.

### Phase 2: OAuth và vận hành token

Mục tiêu: kết nối OA từ UI, không cần paste token tay.

Việc cần làm:

- `ZaloOaOAuthService` với PKCE.
- Màn hình kết nối OA trong Settings/Accounts: nút "Kết nối Zalo OA".
- Callback tạo/cập nhật `zalo_accounts` channel `oa`.
- Token encrypted cast, refresh bằng scheduler/queue.
- Lock khi refresh để tránh hai request dùng chung refresh token một lần.
- Health/status cho OA: token còn hạn, webhook đã nhận event gần nhất, lỗi gần nhất.

### Phase 3: Attachment và profile

Mục tiêu: chat vận hành được như CSKH thật.

Việc cần làm:

- Map `user_send_image`, `user_send_file`, `user_send_sticker`, `user_send_location`,
  `user_send_audio`, `user_send_video` vào attachments chung của UI.
- Gửi ảnh/file từ composer qua API upload OA.
- Lưu `attachment_id`/`token` trả về để trace.
- Lấy profile user OA để hiện tên/avatar.
- Backfill nhỏ bằng API `oa/conversation` tối đa 10 tin/request khi vừa kết nối OA.

### Phase 4: Trạng thái, quota, template

Mục tiêu: giảm lỗi gửi tin và sẵn sàng cho tin ngoài CSKH.

Việc cần làm:

- Xử lý `user_received_message`, `user_seen_message` để lưu delivery/read status.
- Gọi `quota/message` trước khi auto-reply nếu hội thoại ngoài khung tư vấn.
- Thêm cảnh báo trên UI khi không đủ điều kiện gửi tin.
- Nếu cần tin giao dịch/marketing, thiết kế module riêng cho ZBS Template Message, không trộn
  vào composer chat thường.

## 8. Rủi ro và quyết định cần chốt

Rủi ro kỹ thuật:

- **Webhook timeout**: phải trả 200 nhanh, mọi xử lý đưa vào queue.
- **Token refresh dùng một lần**: bắt buộc có lock/transaction, nếu không sẽ vô hiệu refresh
  token và phải cấp quyền lại.
- **Quy định gửi tin thay đổi theo thời gian**: không hard-code mọi hạn mức; ưu tiên API quota.
- **File/ảnh OA giới hạn khác Zalo cá nhân**: UI phải dùng capability theo account.
- **Tin nhắn từ OA Manager**: nếu admin trả lời bằng oa.zalo.me, ToolZalo chỉ thấy được nếu
  bật event `oa_send_*`.
- **Bảo mật webhook**: không được bỏ qua `X-ZEvent-Signature`; route public cần rate limit và
  log signature fail.

Quyết định sản phẩm cần chốt:

- Một Zalo App kết nối nhiều OA hay mỗi khách hàng một App riêng?
- Phase 1 chỉ cần text hay cần ảnh/file ngay?
- Auto-reply OA có cần kiểm tra quota trước mỗi lần gửi không?
- Có cần đồng bộ tin lịch sử OA khi vừa kết nối không? API hội thoại chỉ lấy tối đa 10
  tin/request, nên nên coi là đối soát nhỏ, không phải migration lịch sử đầy đủ.

## 9. Gợi ý thứ tự code

1. Migration + model casts cho `channel` và token OA.
2. `ZaloOaClient` với fake HTTP tests.
3. `ZaloOaWebhookVerifier`.
4. `ZaloOaWebhookController` lưu raw event và trả 200.
5. `ProcessZaloOaWebhookEventJob` map text in/out vào `ZaloMessageIngestService`.
6. Sửa `ZaloAccountService::sendMessage()` chọn worker/OA theo `channel`.
7. Thêm capability vào API accounts và ẩn nút UI không hỗ trợ.
8. Feature tests cho webhook, send text, duplicate, auto-reply.
9. Sau khi MVP chạy, làm OAuth PKCE full và upload attachment.

## 10. Tài liệu tham khảo

- Zalo OA OpenAPI overview:
  https://docs.zaloplatforms.com/docs/OA/bat-dau/kham-pha
- Xác thực và ủy quyền OAuth v4:
  https://docs.zaloplatforms.com/docs/OA/bat-dau/xac-thuc-va-uy-quyen-cho-ung-dung-new
- Nhóm quyền OA API:
  https://docs.zaloplatforms.com/docs/OA/bat-dau/gioi-thieu-official-account-api-va-cac-nhom-quyen
- Webhook overview:
  https://docs.zaloplatforms.com/docs/OA/webhook/tong-quan
- Webhook user gửi tin:
  https://docs.zaloplatforms.com/docs/OA/webhook/tin-nhan/su-kien-nguoi-dung-gui-tin-nhan
- Gửi tin tư vấn text:
  https://docs.zaloplatforms.com/docs/OA/tin-nhan/tin-tu-van/gui-tin-tu-van-dang-van-ban
- Gửi tin tư vấn trích dẫn:
  https://stc-developers.zdn.vn/docs/v2/official-account/tin-nhan/tin-tu-van/gui-tin-tu-van-trich-dan?lang=vi
- Kiểm tra hạn mức gửi tin:
  https://docs.zaloplatforms.com/docs/OA/tin-nhan/quan-ly-tin-nhan/kiem-tra-han-muc-gui-tin-nhan
- Upload hình ảnh:
  https://docs.zaloplatforms.com/docs/OA/tin-nhan/quan-ly-tin-nhan/upload-hinh-anh
- Upload file:
  https://docs.zaloplatforms.com/docs/OA/tin-nhan/quan-ly-tin-nhan/upload-file
- Gửi tin giao dịch và lưu ý ZBS Template Message 2026:
  https://docs.zaloplatforms.com/docs/OA/tin-nhan/tin-giao-dich/gui-tin-giao-dich
