# Rà soát phương án tích hợp Zalo OA + bản triển khai tinh gọn

Ngày: 11/08/2026. Đối chiếu với code thật trên nhánh `develop`.
Đọc kèm [tich-hop-zalo-oa.md](tich-hop-zalo-oa.md).

## 0. Kết luận ngắn

Kiến trúc của plan gốc **đúng**: không đưa OA qua worker, dùng chung `zalo_accounts` /
`zalo_conversations` / `zalo_messages`, chuẩn hoá payload về `ZaloMessageIngestService`. Giữ
nguyên hướng này.

Nhưng phần **khối lượng** của Phase 1 nặng hơn mức cần, và plan **thiếu 3 điểm va chạm với
luồng đang chạy** — đây mới là chỗ dễ làm hỏng hệ thống hiện tại chứ không phải phần OA:

1. Tài khoản OA sẽ khiến màn chat gọi worker vô ích **ở mỗi nhịp poll** (mục 1.1).
2. Nút trả lời trích dẫn sẽ **luôn báo lỗi** với tin OA nếu rẽ nhánh sai chỗ (mục 1.2).
3. Tạo tài khoản OA bằng luồng cũ sẽ **tạo profile rác trên worker** (mục 1.3).

Bản tinh gọn: **1 migration (5 cột, không bảng mới), 3 file mới, 7 chỗ sửa nhỏ**, so với
3 migration + 2 bảng mới + ~8 file của plan gốc.

## 1. Ba điểm va chạm plan chưa nhắc tới

### 1.1 OA account làm màn chat gọi worker mỗi lần poll (nghiêm trọng nhất)

`getConversations()` gọi `hiddenService->syncIfStale()` cho **mọi** tài khoản:

- [ZaloAccountService.php:399](../backend/app/Services/Zalo/ZaloAccountService.php#L399)
- [ZaloHiddenConversationService.php:44-70](../backend/app/Services/Zalo/ZaloHiddenConversationService.php#L44-L70)

`sync()` chỉ `Cache::put` **khi hỏi worker thành công**; hỏng thì trả `null` và không đệm gì.
Tài khoản OA không tồn tại ở worker → lần nào cũng hỏng → **không bao giờ có cache** → mỗi
lượt poll danh sách hội thoại là một request HTTP tới worker + một dòng log `Log::info`.
Màn chat poll liên tục ([conversations.js:821-832](../backend/public/js/zalo-chat/conversations.js#L821-L832)),
nhân với số nhân viên đang mở tab.

Tương tự nhưng nhẹ hơn (vì có negative cache 12h,
[ZaloContactService.php:373-384](../backend/app/Services/Zalo/ZaloContactService.php#L373-L384)):

- `enrichConversations()` → `contactService->resolve()` → `fetchFromWorker` ([ZaloAccountService.php:469](../backend/app/Services/Zalo/ZaloAccountService.php#L469))
- `senderProfiles()` khi mở hội thoại ([ZaloAccountService.php:1241](../backend/app/Services/Zalo/ZaloAccountService.php#L1241))

**Cách xử lý (3 câu `if`)**: gate theo `channel` ngay trong `ZaloAccountService`, không sửa
gì bên trong hai service kia — luồng cá nhân không bị đụng một dòng nào.

```php
// getConversations()
if (! $account->isOa()) {
    $this->hiddenService->syncIfStale($account->id);
}

// enrichConversations() / senderProfiles(): thoát sớm
if ($account->isOa()) {
    return; // tên/avatar OA lấy từ webhook, phase sau gọi /v3.0/oa/user/detail
}
```

### 1.2 Trả lời trích dẫn sẽ luôn lỗi nếu rẽ nhánh sai chỗ

[ZaloAccountService.php:1440-1452](../backend/app/Services/Zalo/ZaloAccountService.php#L1440-L1452):
`sendMessage()` bắt buộc phải tra được `quote_payload` (dữ liệu thô của zca-js) mới cho reply,
không có thì trả lỗi thẳng. Tin OA **không bao giờ** có `quote_payload` — OA chỉ cần
`message.quote_message_id`.

→ Nhánh OA phải đặt **ngay đầu** `sendMessage()`, trước cả đoạn tra `thread_type` và
`quotePayload()`. Đây cũng là lý do nên dùng guard clause thay vì strategy pattern đầy đủ.

### 1.3 `createAccount()` luôn gọi worker

[ZaloAccountService.php:83-100](../backend/app/Services/Zalo/ZaloAccountService.php#L83-L100)
sinh `profile_key` và gọi `workerClient->createProfile()`. Tạo tài khoản OA qua
`POST /api/zalo-accounts` sẽ tạo một profile mồ côi trên worker và set `status = ERROR` khi
worker chết.

→ Tách route tạo OA riêng (`POST /api/zalo-oa/connect`), hoặc thêm nhánh `channel === 'oa'`
thì bỏ qua `createProfile` và set thẳng `READY`.

Lưu ý kèm theo: màn chat coi tài khoản dùng được **chỉ khi** `status === 'READY'`
([accounts.js:181-186](../backend/public/js/zalo-chat/accounts.js#L181-L186)). OA có token hợp
lệ phải set `READY`, nếu không composer bị khoá.

## 2. Bốn chỗ trong plan có thể bỏ hoặc làm nhẹ hơn

### 2.1 Bỏ bảng `zalo_oa_tokens` → 4 cột trên `zalo_accounts`

Quan hệ 1-1 với account, không có lịch sử token cần giữ. Một bảng riêng đổi lấy: thêm 1
migration, 1 model, 1 quan hệ, 1 lần join ở mỗi lần gửi tin. Dùng cột + cast `encrypted` của
Laravel là đủ, mã hoá y hệt.

### 2.2 Bỏ bảng `zalo_oa_webhook_events` khỏi MVP

Plan nêu 3 lý do, cả 3 đều đã có đường rẻ hơn:

| Mục tiêu | Plan gốc | Đã có sẵn / rẻ hơn |
|---|---|---|
| Chống trùng cuối cùng | `event_key` unique | unique `(zalo_account_id, msg_id)` + `upsert` — [migration:51](../backend/database/migrations/2026_08_01_000002_create_zalo_messages_table.php#L51), [IngestService:182](../backend/app/Services/Zalo/ZaloMessageIngestService.php#L182) |
| Chống trùng sớm (retry của Zalo) | INSERT MySQL đồng bộ | `Cache::add($eventKey, 1, 300)` trên Redis (`CACHE_STORE=redis`), ~0.2ms |
| Debug | bảng | log channel riêng, `Log::channel('zalo-oa')` |

Đây là chỗ ăn tài nguyên rõ nhất của plan gốc: **mỗi event là một INSERT MySQL đồng bộ nằm
trên đường 2 giây**, và bảng phình theo cả `user_received_message` / `user_seen_message` —
đúng hai loại event tần suất cao nhất mà MVP không dùng đến.

Muốn có bảng raw để replay thì đẩy sang Phase 3, và **ghi trong job**, không ghi trong
controller.

### 2.3 Lọc event ngay ở controller, đừng dispatch job cho event không dùng

MVP chỉ cần `user_send_text` và `oa_send_text`. Event ngoài whitelist: log debug + trả 200,
**không** dispatch. Tránh queue chạy không cho `user_seen_message` (mỗi tin người dùng đọc là
một event).

Vẫn giữ job cho event trong whitelist — phần chậm thật (AI) thì đã ở queue sẵn:
`ZaloMessageIngestService` → `AutoReplyService` → `RunAutoReplyJob::dispatch`
([AutoReplyService.php:143](../backend/app/Services/Ai/AutoReplyService.php#L143)), và
`queue:work redis` đã chạy sẵn trong compose ([docker-compose.yml:188-198](../docker-compose.yml#L188-L198)).

### 2.4 Webhook URL cố định, không có `{account}`

Webhook URL khai báo **theo Zalo App**, không theo từng OA. Một app quản nhiều OA thì mọi
event vẫn về chung một URL. Route `{account}` tạo cảm giác tách được nhưng thực tế luôn phải
rơi về nhánh resolve theo `oa_id` — bỏ tham số đi thì bớt một nhánh code và bớt một chỗ cấu
hình sai.

```php
Route::post('zalo-oa/webhook', [ZaloOaWebhookController::class, 'handle'])
    ->middleware('throttle:600,1')
    ->name('zalo-oa.webhook');
```

Resolve account: `ZaloAccount::where('oa_id', $oaId)->where('channel', 'oa')->first()`, với
`$oaId = $payload['recipient']['id']` cho `user_send_*` và `$payload['sender']['id']` cho
`oa_send_*`.

### 2.5 Chưa cần khối `capabilities`

[ZaloAccountController::index()](../backend/app/Http/Controllers/Zalo/ZaloAccountController.php#L67-L69)
trả thẳng model ra JSON → thêm cột `channel` là UI có ngay, không cần thêm code backend. Giai
đoạn này UI chỉ cần `account.channel === 'oa'` để ẩn nút. `capabilities` là lớp khái niệm
đáng thêm khi có kênh thứ ba, không phải bây giờ.

## 3. Phương án tinh gọn — Phase 1

Mục tiêu Phase 1 giữ nguyên như plan gốc: OA nhận tin, hiện trong màn chat, nhân viên và AI
gửi text trả lời.

### 3.1 Migration duy nhất

```php
Schema::table('zalo_accounts', function (Blueprint $table) {
    $table->string('channel', 20)->default('personal')->index();
    $table->string('oa_id', 64)->nullable()->index();
    $table->text('oa_access_token')->nullable();
    $table->text('oa_refresh_token')->nullable();
    $table->timestamp('oa_token_expires_at')->nullable();
});
```

`profile_key` vẫn `unique()` và `NOT NULL` → tài khoản OA điền UUID như thường, không đụng
schema. `zalo_uid` để null.

Model [ZaloAccount](../backend/app/Models/ZaloAccount.php): thêm 5 tên vào `$fillable`, cast
hai cột token là `'encrypted'`, `oa_token_expires_at` là `'datetime'`, thêm
`public function isOa(): bool => $this->channel === 'oa';`.

### 3.2 Ba file mới

**`app/Services/Zalo/Oa/ZaloOaClient.php`** — `sendText()`, `refreshToken()`,
`withFreshToken()`. Refresh token của Zalo dùng **một lần**, nên bắt buộc có lock:

```php
private function withFreshToken(ZaloAccount $account, \Closure $call)
{
    if ($account->oa_token_expires_at?->subMinutes(30)->isPast() ?? true) {
        Cache::lock("zalo-oa-refresh:{$account->id}", 15)->block(10, function () use ($account) {
            $account->refresh();                       // tiến trình khác có thể vừa refresh xong
            if ($account->oa_token_expires_at?->subMinutes(30)->isFuture()) {
                return;
            }
            $this->refreshToken($account);
        });
    }

    return $call($account->fresh());
}
```

**`app/Http/Controllers/Zalo/ZaloOaWebhookController.php`** — chỉ làm 4 việc, không chạm
MySQL:

```php
public function handle(Request $request): Response
{
    $raw = $request->getContent();                     // phải là chuỗi gốc, không phải json_encode lại
    $payload = json_decode($raw, true) ?: [];
    $event = (string) ($payload['event_name'] ?? '');

    if (! in_array($event, self::HANDLED_EVENTS, true)) {
        return response('', 200);                      // seen/received/... : bỏ, không dispatch
    }

    if (! $this->verifier->check($request, $raw)) {     // hash_equals(sha256(appId.data.ts.secret))
        Log::channel('zalo-oa')->warning('Chữ ký webhook sai', ['event' => $event]);
        return response('', 401);
    }

    $key = 'zalo-oa:evt:'.sha1($event.'|'.($payload['message']['msg_id'] ?? '').'|'.($payload['timestamp'] ?? ''));
    if (! Cache::add($key, 1, 300)) {
        return response('', 200);                      // retry của Zalo, đã nhận rồi
    }

    ProcessZaloOaEventJob::dispatch($payload);
    return response('', 200);
}
```

Route nằm **ngoài** nhóm `auth` trong [routes/api.php](../backend/routes/api.php) — cùng chỗ
với nhóm `worker/*` hiện tại (dòng 264-271). `routes/api.php` không áp CSRF nên không phải
khai báo miễn trừ.

**`app/Jobs/ProcessZaloOaEventJob.php`** — map payload OA về đúng shape `ingest()` đang nhận
([WorkerCallbackController:41-68](../backend/app/Http/Controllers/Zalo/ZaloWorkerCallbackController.php#L41-L68)
là bản mô tả đầy đủ nhất của shape này) rồi gọi
`app(ZaloMessageIngestService::class)->ingest([$normalized])`. Không cần `is_backfill`,
không cần `cli_msg_id`, không cần `quote_payload`.

### 3.3 Bảy chỗ sửa nhỏ

| # | File | Sửa |
|---|---|---|
| 1 | `ZaloAccountService::sendMessage()` dòng 1427 | guard clause OA, ~12 dòng (xem 3.4) |
| 2 | `ZaloAccountService::getConversations()` dòng 399 | bọc `syncIfStale` trong `if (! $account->isOa())` |
| 3 | `ZaloAccountService::enrichConversations()` dòng 469 | thoát sớm khi OA |
| 4 | `ZaloAccountService::senderProfiles()` dòng 1241 | thoát sớm khi OA |
| 5 | `ZaloAccountService::createAccount()` dòng 83 | bỏ `createProfile` khi OA (hoặc route riêng) |
| 6 | `config/zalo.php` | thêm khối `'oa' => [...]` như plan gốc mục 5.1 |
| 7 | `routes/api.php` + UI | 1 route webhook, 1-2 route kết nối; UI ẩn nút theo `channel` |

### 3.4 Guard clause trong `sendMessage()` — tái sử dụng được 4 helper có sẵn

Đặt ngay sau khi tìm được `$account`, **trước** đoạn tra `thread_type` và `quotePayload()`:

```php
if ($account->isOa()) {
    if ($attachments !== []) {
        return ['success' => false, 'message' => 'Tài khoản OA chưa gửi được tệp đính kèm.'];
    }

    $result = $this->oaClient->sendText($account, $conversationKey, $content, $replyToMsgId);

    if ($result['success']) {
        $msgId = $result['data']['message_id'] ?? null;
        if ($sentByAgent)          { $this->rememberAgentSentMessage($account->id, $msgId); }
        if ($sentByUserId !== null) { $this->rememberUserSentMessage($account->id, $msgId, $sentByUserId); }
        $this->refreshConversationAfterOutgoingSend($account, $conversationKey, $content, [], $sentByAgent);
    }

    return $result;
}
```

Vì nằm trong cùng class nên dùng lại được nguyên bốn private helper đang phục vụ luồng cá
nhân — nghĩa là **miễn phí** các thứ sau cho OA:

- AI tự trả lời: `AiAgentReplyService` gọi đúng hàm này
  ([AiAgentReplyService.php:76](../backend/app/Services/Ai/AiAgentReplyService.php#L76))
- Ghi nhận "nhân viên nào bấm gửi" (`zalo_user_sent_messages`)
- Không tính tin AI gửi là "đã có người xem hội thoại"
- Sidebar nhảy lên ngay khi gửi, không phải chờ webhook `oa_send_*` về
- API external `/api/external/...` cũng chạy được với OA, không sửa gì

Tin gửi đi nên ghi ngay bằng `message_id` mà OpenAPI trả về; webhook `oa_send_*` về sau sẽ bị
unique `(zalo_account_id, msg_id)` chặn — không cần code chống trùng riêng.

### 3.5 Kết nối OA: dán token trước, OAuth sau

Giữ đúng đề xuất của plan gốc. Admin dán `refresh_token` lấy từ API Explorer →
`ZaloOaClient::refreshToken()` đổi ra cặp token mới → tạo `zalo_accounts` với
`channel = 'oa'`, `oa_id`, `status = READY`. OAuth PKCE đầy đủ để Phase 2.

Cần một lệnh artisan/scheduler refresh chủ động trước hạn: access token sống 25 giờ, refresh
token 3 tháng và **dùng một lần** — để token hết hạn là phải đi cấp quyền lại từ đầu.

## 4. So sánh hai bản

| | Plan gốc (Phase 1) | Bản tinh gọn |
|---|---|---|
| Migration | 3 (2 bảng mới + alter) | 1 (alter, 5 cột) |
| Model mới | 2 | 0 |
| File mới | ~8 | 3 |
| Ghi MySQL cho mỗi webhook | 1 INSERT (đồng bộ, trong 2s) + ghi tin | ghi tin |
| Ghi cho event không dùng (`seen`/`received`) | 1 INSERT + 1 job mỗi event | 0 |
| Chống trùng | bảng raw + unique msg_id | Redis `Cache::add` + unique msg_id |
| Chạm vào luồng cá nhân | `sendMessage()` refactor strategy | 1 guard clause + 3 câu `if` |

## 5. Giữ nguyên từ plan gốc

Các mục sau vẫn đúng, không cần sửa: nguyên tắc không đưa OA qua worker; dùng chung kho dữ
liệu; webhook trả 200 trong 2s; idempotency bắt buộc; bảng map event ở mục 5.5; Phase 2
(OAuth PKCE + vận hành token), Phase 3 (attachment + profile), Phase 4 (quota, delivery
status, ZBS Template Message); toàn bộ mục 8 rủi ro; danh sách tài liệu tham khảo.

Bổ sung vào mục 8: **rủi ro lớn nhất không nằm ở phía OA mà ở phía luồng cá nhân** — mọi
đường code giả định "tài khoản nào cũng có phiên trên worker" (mục 1 của tài liệu này).

## 6. Test tối thiểu cho Phase 1

Theo đúng phong cách test đang có trong [tests/Feature](../backend/tests/Feature):

1. `ZaloOaWebhookTest`: chữ ký đúng → 200 + có job; chữ ký sai → 401 + không job; event trùng
   → 200 + **không** job thứ hai; event ngoài whitelist → 200 + không job.
2. `ZaloOaIngestTest`: `user_send_text` → có tin `direction=in`, hội thoại được tạo, auto-reply
   được kích hoạt; `oa_send_text` trùng `msg_id` với tin vừa gửi → không sinh tin thứ hai.
3. `ZaloOaSendTest`: `Http::fake` OpenAPI → gửi text thành công, sidebar cập nhật; tài khoản
   OA + attachment → trả lỗi rõ ràng; token hết hạn → refresh một lần rồi retry.
4. `ZaloOaIsolationTest` (bảo vệ luồng cũ): mở danh sách hội thoại của account OA →
   `ZaloWorkerClient` **không** được gọi lần nào.

## 7. Cần chốt trước khi code

- Một Zalo App cho nhiều OA, hay mỗi khách một App? Quyết định này đổi chỗ đặt
  `app_id`/`app_secret`: một app → để trong `config/zalo.php` như mục 5.1; nhiều app → thêm
  cột `oa_app_id` + `oa_app_secret` trên `zalo_accounts` (vẫn không cần bảng mới).
- Phase 1 chỉ text, hay cần ảnh/file ngay? Bản tinh gọn ở trên giả định chỉ text.
- Có bật event `oa_send_*` trong cấu hình app không? Không bật thì tin nhân viên trả lời từ
  OA Manager sẽ không về ToolZalo.
## Trả lời phần 7:
- Một Zalo App cho nhiều OA.
- Phase 1 ngoài text, hay cần ảnh/file ngay
- Có bật event `oa_send_*` trong cấu hình app.

## 8. Đã triển khai (12/08/2026)

Code theo đúng bản tinh gọn ở trên, có mở rộng thêm ảnh/file ngay ở phase 1 theo quyết định
đã chốt.

### 8.1 Đã thêm

| Loại | Tệp |
|---|---|
| Migration | `2026_08_11_000001_add_oa_columns_to_zalo_accounts_table.php` — 5 cột, không bảng mới |
| Client | `app/Services/Zalo/Oa/ZaloOaClient.php` |
| Chữ ký | `app/Services/Zalo/Oa/ZaloOaWebhookVerifier.php` |
| Map event | `app/Services/Zalo/Oa/ZaloOaEventMapper.php` |
| Tên/avatar khách | `app/Services/Zalo/Oa/ZaloOaContactResolver.php` |
| Webhook | `app/Http/Controllers/Zalo/ZaloOaWebhookController.php` |
| Kết nối OA | `app/Http/Controllers/Zalo/ZaloOaAccountController.php` |
| OAuth PKCE | `app/Services/Zalo/Oa/ZaloOaOAuthService.php` |
| Job | `app/Jobs/ProcessZaloOaEventJob.php` |
| Lệnh xoay token | `app/Console/Commands/RefreshZaloOaTokens.php` |
| Test | 6 tệp trong `tests/Feature/ZaloOa*.php` — 39 test |

Sửa nhỏ: `ZaloAccount` (cột + `isOa()` + `$hidden`), `ZaloAccountService` (guard clause gửi
tin + 8 chốt chặn worker), `SendMessageRequest` (kiểu tệp theo kênh), `config/zalo.php`,
`config/logging.php` (kênh log `zalo-oa`), `routes/api.php`, `routes/console.php`, và giao
diện (trang tài khoản + màn chat).

### 8.2 Kết nối OA bằng một nút bấm (OAuth v4 + PKCE)

Bản thiết kế xếp OAuth vào phase 2 và phase 1 dán `refresh_token` tay. Đã làm luôn OAuth vì
dán token là việc của lập trình viên, không phải của người dùng bình thường.

Luồng: admin bấm **Kết nối Zalo OA** → `POST /api/zalo-oa/authorize-url` sinh
`code_verifier`/`code_challenge`/`state` (đệm 15 phút trong Redis) → trình duyệt sang
`https://oauth.zaloapp.com/v4/oa/permission` → **admin chọn OA ngay trên màn hình của Zalo**
(nơi duy nhất biết họ sở hữu OA nào) → Zalo gọi về `/zalo-oa/oauth/callback?code=&oa_id=&state=`
→ đổi lấy cặp token → tạo tài khoản, **tên lấy tự động qua `/v2.0/oa/getoa`** → quay về trang
Tài khoản kèm thông báo.

Vài ràng buộc đã cài sẵn:

- **PKCE không phải thủ tục thừa**: `redirect_uri` là địa chỉ công khai; không có
  `code_verifier` thì một mã `code` nhặt được đủ để đổi lấy token gửi tin của OA khác.
- `state` là **mã dùng một lần** (`Cache::pull`) — bấm F5 ở trang callback không đổi token
  lần thứ hai.
- Callback vẫn đòi đăng nhập + quyền admin, không để địa chỉ vô danh dựng tài khoản.
- Cấp quyền lại một OA đã có thì **giữ nguyên bản ghi cũ** (lịch sử hội thoại nằm ở đó) và
  **không ghi đè tên đã đặt tay**.

Đường dán `refresh_token` vẫn còn, nhưng đã gập vào mục "Cách khác" — hữu ích khi chưa khai
được Redirect URI hoặc cần nối lại gấp lúc có sự cố. Ô tên giờ để trống được, bỏ trống thì
lấy tên thật của OA.

### 8.3 Khác với bản thiết kế

- **Thêm `ZaloOaContactResolver`** (thiết kế xếp việc này vào phase 3). Không có nó thì mọi
  hội thoại OA trong sidebar là một dãy số — webhook OA không mang tên người gửi, khác hẳn
  tin của nick cá nhân. Chạy trong job, chỉ tra một lần cho mỗi người.
- **Thêm `$hidden` cho hai cột token.** `ZaloAccountController::index()` trả thẳng model ra
  JSON, mà cast `encrypted` giải mã sẵn khi serialize — không chặn thì token OA bay xuống
  trình duyệt của mọi nhân viên. Có test riêng canh chỗ này.
- **Ghi ngay tin chữ vừa gửi** thay vì chờ webhook `oa_send_text` vọng về, để khung chat
  không trống sau khi bấm gửi. Chỉ với tin chữ: tin có tệp phải đợi webhook vì địa chỉ ảnh
  thật do Zalo cấp.

### 8.4 Cấu hình cần làm khi lên

1. `backend/.env`: `ZALO_OA_APP_ID`, `ZALO_OA_APP_SECRET` (Zalo Developers → ứng dụng).
   Để trống thì webhook từ chối mọi request — có chủ ý.
2. Zalo Developers → Webhook URL: `https://<domain>/api/zalo-oa/webhook`.
   Bật nhóm event `user_send_*` **và** `oa_send_*`.
   Cùng chỗ đó, khai **Redirect URI**: `https://<domain>/zalo-oa/oauth/callback` — phải trùng
   khít, sai một ký tự là Zalo từ chối ngay ở màn cấp quyền. Địa chỉ này cũng được in sẵn
   trên trang Tài khoản để copy.
3. `make rebuild` — container đang chạy bản image chứ không bind-mount, nên code mới chỉ lên
   sau khi build lại. Migration tự chạy lúc container khởi động (`php artisan migrate --force`
   trong `docker-compose.yml`).
4. Trang Tài khoản → bấm **Kết nối Zalo OA**, đăng nhập Zalo và chọn OA. Xong là có tài
   khoản với tên tự lấy theo OA.
5. Tuỳ chọn: chạy `php artisan schedule:work` (hoặc cron gọi `schedule:run`) để lệnh
   `zalo:oa-refresh-tokens` chạy mỗi giờ. Chưa có tiến trình này thì token vẫn tự làm mới ở
   lần gửi tin kế tiếp — chỉ hụt trường hợp một OA im lặng nhiều tháng liền.

### 8.5 Chưa làm (giữ nguyên phase sau)

`quota/message` trước khi auto-reply,
`user_received_message`/`user_seen_message` cho trạng thái đã nhận/đã xem, backfill lịch sử
qua `oa/conversation`, và ZBS Template Message.
