---
name: meta-ads-reader
description: Đọc các tài khoản quảng cáo Meta mà người dùng được cấp quyền, lấy campaign/ad set/ad insights, chuẩn hóa kết quả và quản lý vòng đời access token ở chế độ chỉ đọc.
user-invocable: true
metadata: {"openclaw":{"requires":{"bins":["curl"]},"primaryEnv":"LUCA_INTERNAL_API_KEY"}}
---

# CẤU HÌNH — CHỈNH CÁC GIÁ TRỊ NÀY TRƯỚC KHI CHẠY

> Đây là vùng cấu hình động duy nhất.
>
> **Không còn `META_APP_ID` / `META_APP_SECRET` / `META_ACCESS_TOKEN` cứng trong file này.**
> Ba giá trị đó nằm trong CRM Luca theo **từng nhân viên**, lấy động qua endpoint
> `/api/internal/meta/ads-accounts` (mục 3). Nhờ vậy mỗi nhân viên marketing dùng
> Meta App và token riêng, và OpenClaw ghép được chi phí quảng cáo của đúng người đó
> với doanh thu CRM của chính họ (mục 14.1).
>
> Bí mật duy nhất cần đặt cho skill này là `LUCA_INTERNAL_API_KEY` — đặt bằng
> `skills.entries.meta-ads-reader.env` hoặc SecretRef của OpenClaw; không chèn giá trị thật
> vào nội dung `SKILL.md`, Git, chat, log hay ảnh chụp màn hình.

```env
# --- Nguồn credential: CRM Luca ---------------------------------------------
# OpenClaw lấy META_APP_ID / META_APP_SECRET / META_ACCESS_TOKEN từ đây,
# không nhập tay và không lưu cứng trong file này.
LUCA_BASE_URL=https://pkluca.flyspa.vn
LUCA_API_PREFIX=/api/internal
LUCA_INTERNAL_API_KEY=g9H6KQncrY0czTqocZCA
LUCA_ADS_ACCOUNTS_PATH=/meta/ads-accounts

# Phòng ban Marketing trong CRM Luca — dùng để lọc đúng nhóm nhân viên chạy ads.
# Để trống = lấy mọi tài khoản có đủ cấu hình Meta.
LUCA_MARKETING_DEPARTMENT_ID=3

# --- Meta Graph API ----------------------------------------------------------
# Phiên bản hiện hành tại thời điểm viết file (2026-09-03).
# Trước khi nâng phiên bản, kiểm tra changelog và chạy thử trên môi trường test.
# API cũng trả về meta_graph_version cho từng tài khoản; ưu tiên giá trị từ API.
META_GRAPH_VERSION=v26.0

# Chỉ đọc dữ liệu quảng cáo. Không cấp ads_management nếu không cần chỉnh quảng cáo.
META_REQUIRED_SCOPES=ads_read

# Để trống: tự lấy tất cả Ads Account user/token đang được quyền truy cập.
# Hoặc giới hạn bằng danh sách có prefix act_, ngăn cách dấu phẩy.
META_AD_ACCOUNT_ALLOWLIST=

# Khoảng ngày mặc định giống ảnh Ads Manager: 30 ngày qua, không gồm hôm nay.
# Có thể đổi thành today, yesterday, last_7d hoặc truyền time_range cụ thể khi ra lệnh.
META_DEFAULT_DATE_PRESET=last_30d
META_DEFAULT_LEVEL=campaign
META_TIMEZONE=Asia/Ho_Chi_Minh

# Kiểu kết quả ưu tiên. OpenClaw vẫn phải giữ toàn bộ actions thô trong đầu ra.
# Giá trị đầu tiên tồn tại sẽ được dùng làm primary_result.
META_RESULT_ACTION_PRIORITY=onsite_conversion.messaging_conversation_started_7d,messaging_conversation_started_7d,onsite_conversion.total_messaging_connection,lead,onsite_conversion.lead_grouped,offsite_conversion.fb_pixel_lead,purchase,offsite_conversion.fb_pixel_purchase

# Vận hành an toàn
META_TOKEN_WARNING_DAYS=7
META_PAGE_LIMIT=100
META_MAX_CONCURRENCY=3
META_REQUEST_TIMEOUT_SECONDS=60
META_MAX_RETRIES=5
META_READ_ONLY=true
META_OUTPUT_LANGUAGE=vi
META_CURRENCY_LOCALE=vi-VN
```

## 1. Vai trò và mục tiêu của OpenClaw

Bạn là agent **chỉ đọc Meta Ads**. Nhiệm vụ:

1. Dùng một access token để tự lấy toàn bộ tài khoản quảng cáo mà token được phép truy cập.
2. Lấy đủ dữ liệu ở cấp `campaign`, `adset` hoặc `ad` theo yêu cầu.
3. Mặc định báo cáo 30 ngày qua ở cấp campaign.
4. Hiển thị tối thiểu các cột giống ảnh người dùng cung cấp:
   - Tài khoản quảng cáo.
   - Chiến dịch.
   - Kết quả.
   - Chi phí trên mỗi kết quả.
   - Ngân sách.
   - Số tiền đã chi tiêu.
   - Lượt hiển thị.
5. Xử lý toàn bộ phân trang; không được dừng ở 100 hay 200 dòng đầu.
6. Kiểm tra token trước mỗi lần đồng bộ và cảnh báo trước khi token hết hạn.

Không OCR Ads Manager và không điều khiển trình duyệt nếu API vẫn hoạt động. Không tạo, sửa, bật, tắt hoặc xóa quảng cáo. Khi `META_READ_ONLY=true`, chỉ được gọi các request `GET`.

## 2. Nguyên tắc bảo mật bắt buộc

- Không bao giờ in `META_ACCESS_TOKEN`, `META_APP_SECRET`, App Access Token hoặc URL `paging.next` đầy đủ vào log/đầu ra; các URL này có thể chứa token.
- Không đưa bí mật vào query do người dùng nhìn thấy. Khi gọi bằng `curl`, dùng `--data-urlencode`; khi viết chương trình, dùng secret store hoặc biến môi trường.
- Không lưu bí mật vào database dạng rõ nếu hệ thống có secret manager. Nếu buộc phải lưu, phải mã hóa at-rest và giới hạn quyền đọc.
- Không tự dùng email, mật khẩu, cookie Facebook hay tự vượt 2FA để cấp lại token.
- Tạo `appsecret_proof = HMAC-SHA256(META_ACCESS_TOKEN, META_APP_SECRET)` cho các request server-to-server nếu runtime hỗ trợ; không gửi `META_APP_SECRET` thay cho proof.
- File chứa token thật phải được loại khỏi Git và giới hạn quyền đọc, ví dụ `chmod 600`.

## 3. Lấy credential Meta từ CRM Luca

**Đây là bước đầu tiên của mọi lần chạy.** Không có credential nào nằm sẵn trong file này.

Nếu `LUCA_INTERNAL_API_KEY` còn bắt đầu bằng `THAY_`, dừng và trả `CONFIG_REQUIRED`.

### 3.1 Gọi endpoint

```bash
curl --silent --show-error --fail-with-body -G \
  "${LUCA_BASE_URL}${LUCA_API_PREFIX}${LUCA_ADS_ACCOUNTS_PATH}" \
  --header "x-api-key: ${LUCA_INTERNAL_API_KEY}" \
  --data-urlencode "department_id=${LUCA_MARKETING_DEPARTMENT_ID}"
```

Tham số tuỳ chọn: `user_id` (một nhân viên), `branch_id` (một chi nhánh),
`include_inactive=1` (lấy cả nhân viên đã nghỉ).

### 3.2 Cấu trúc trả về

```json
{
  "code": 200,
  "messages": "SUCCESS",
  "data": [
    {
      "user_id": 42,
      "full_name": "Nguyễn Văn A",
      "department_id": 3,
      "branch_id": 4,
      "meta_app_id": "…",
      "meta_app_secret": "…",
      "meta_access_token": "…",
      "meta_token_mode": "USER_TOKEN",
      "meta_graph_version": "v26.0",
      "token_status": "ACTIVE",
      "token_expires_at": 1793637174,
      "token_expires_at_human": "2026-11-02 23:32:54",
      "token_days_left": 59,
      "token_obtained_at": "2026-09-04 10:12:00"
    }
  ],
  "meta": { "total": 1, "expired": 0, "expiring_soon": 0, "graph_version": "v26.0" }
}
```

Endpoint chỉ trả về nhân viên có **đủ cả ba** trường; ai thiếu sẽ không xuất hiện.

### 3.3 Quy tắc dùng dữ liệu này

1. Mỗi phần tử trong `data` là **một bộ credential độc lập**. Chạy toàn bộ quy trình
   Meta (mục 4→13) riêng cho từng `user_id`, không trộn token của người này với người kia.
2. `META_APP_ID`, `META_APP_SECRET`, `META_ACCESS_TOKEN` của một lần chạy chính là
   `meta_app_id`, `meta_app_secret`, `meta_access_token` của phần tử đang xử lý.
3. `META_TOKEN_MODE` lấy từ `meta_token_mode`. `META_GRAPH_VERSION` ưu tiên
   `meta_graph_version` trả về từ API, chỉ fallback về giá trị trong khối cấu hình khi thiếu.
4. Bỏ qua và ghi vào báo cáo lỗi mọi phần tử có `token_status = "EXPIRED"`;
   vẫn chạy nhưng kèm cảnh báo `TOKEN_EXPIRING_SOON` khi `token_status = "EXPIRING_SOON"`.
5. `user_id` là **khoá ghép** giữa chi phí quảng cáo Meta và doanh thu CRM (mục 14.1).
   Không ghép bằng tên nhân viên vì tên có thể trùng hoặc bị sửa.
6. Nếu `meta.total = 0`, dừng với `CONFIG_REQUIRED` và báo: chưa nhân viên nào cấu hình
   Meta App trong CRM.

### 3.4 Bảo mật

- Giá trị lấy về chỉ giữ trong bộ nhớ của lần chạy, **không ghi ra file, log hay chat**.
- Không in `meta_app_secret` / `meta_access_token` ra bất kỳ đầu ra nào, kể cả khi báo lỗi.
- Không cache credential xuống đĩa. Mỗi lần chạy gọi lại endpoint để luôn có token mới nhất.

### 3.5 Kiểm tra thêm trước khi gọi Meta

- `META_GRAPH_VERSION` có dạng `vN.N`.
- `meta_token_mode` chỉ nhận `USER_TOKEN` hoặc `SYSTEM_USER_TOKEN`.
- `META_DEFAULT_LEVEL` chỉ nhận `campaign`, `adset` hoặc `ad`.
- `META_READ_ONLY` phải là `true` trong tài liệu này.
- Ngày phải theo `YYYY-MM-DD` và `since <= until`.

## 4. Kiểm tra token trước mỗi lần đồng bộ

Tạo App Access Token trong bộ nhớ, không lưu vào log:

```bash
APP_ACCESS_TOKEN="${META_APP_ID}|${META_APP_SECRET}"
```

Gọi Token Debugger:

```bash
curl --silent --show-error --fail-with-body -G \
  "https://graph.facebook.com/${META_GRAPH_VERSION}/debug_token" \
  --data-urlencode "input_token=${META_ACCESS_TOKEN}" \
  --data-urlencode "access_token=${APP_ACCESS_TOKEN}"
```

Đọc `data` trong response và kiểm tra:

| Trường | Quy tắc |
|---|---|
| `is_valid` | Phải là `true`; nếu không, dừng với `TOKEN_REAUTH_REQUIRED`. |
| `app_id` | Phải bằng `META_APP_ID`; nếu khác, dừng với `TOKEN_APP_MISMATCH`. |
| `scopes` | Phải có `ads_read`; thiếu thì dừng với `TOKEN_PERMISSION_REQUIRED`. |
| `expires_at` | Unix timestamp. Nếu `0`, token không có lịch hết hạn được công bố; vẫn phải kiểm tra định kỳ. |
| `data_access_expires_at` | Nếu có và khác `0`, cũng phải theo dõi như một hạn truy cập dữ liệu riêng. |
| `user_id` | Lưu để audit token đang đại diện cho user nào; không dùng làm bí mật. |

Tính `effective_expiry` là timestamp dương sớm nhất trong `expires_at` và `data_access_expires_at`.

- Đã quá hạn hoặc `is_valid=false`: không gọi Ads API; yêu cầu cấp lại quyền/token.
- Còn tối đa `META_TOKEN_WARNING_DAYS`: vẫn được đọc nếu token hợp lệ, nhưng trả thêm `TOKEN_EXPIRING_SOON` và hướng dẫn ở mục 11.
- Còn nhiều hơn ngưỡng: tiếp tục.
- `effective_expiry` không tồn tại: trả `NO_SCHEDULED_EXPIRY`, nhưng vẫn debug token mỗi ngày vì token có thể bị thu hồi sớm.

## 5. Đổi short-lived User Token thành long-lived User Token

> **OpenClaw không làm bước này.** Vòng đời token do CRM Luca quản lý: màn hình
> *Marketing → Tài khoản quảng cáo Meta* có sẵn nút "Lấy token qua Facebook"
> (tự đổi sang long-lived) và "Gia hạn token", ghi thẳng vào 3 cột của bảng `users`.
> Endpoint ở mục 3 luôn trả token mới nhất, kèm `token_status` và `token_days_left`.
>
> Nhiệm vụ của OpenClaw khi thấy token sắp/đã hết hạn là **báo cho owner vào CRM xử lý**
> (mục 11), không tự đổi token. Với `META_READ_ONLY=true`, OpenClaw chỉ gọi `GET`
> tới Graph API và không được ghi đè credential trong CRM.
>
> Phần dưới giữ lại để đối chiếu khi cần kiểm tra hành vi phía Meta.

Chỉ thực hiện khi `meta_token_mode = USER_TOKEN` và token đang là short-lived:

```bash
curl --silent --show-error --fail-with-body -G \
  "https://graph.facebook.com/${META_GRAPH_VERSION}/oauth/access_token" \
  --data-urlencode "grant_type=fb_exchange_token" \
  --data-urlencode "client_id=${META_APP_ID}" \
  --data-urlencode "client_secret=${META_APP_SECRET}" \
  --data-urlencode "fb_exchange_token=${META_ACCESS_TOKEN}"
```

Nếu thành công, response thường có:

```json
{
  "access_token": "TOKEN_MOI",
  "token_type": "bearer",
  "expires_in": 5184000
}
```

Quy trình thay token phải atomic:

1. Nhận token mới trong bộ nhớ, không log.
2. Debug token mới bằng mục 4.
3. Xác nhận `is_valid=true`, đúng `app_id` và có `ads_read`.
4. Gọi thử `/me?fields=id,name` và `/me/adaccounts?fields=id&limit=1`.
5. Chỉ sau khi cả hai thành công mới thay secret cũ bằng secret mới.
6. Lưu `obtained_at`, `expires_at`, `data_access_expires_at` và fingerprint dạng SHA-256 rút gọn để audit; không lưu token vào audit log.
7. Ghi lại `meta_token_expires_at` và `meta_token_obtained_at` để lần sau biết còn bao lâu.
8. Nếu bất kỳ bước nào lỗi, giữ token cũ và trả `TOKEN_EXCHANGE_FAILED`.

CRM Luca đã cài đặt đúng 8 bước này trong `App\Services\MetaAdsService::storeTokenAtomically()`,
kèm quy tắc: **không bao giờ** đưa System User Token qua `fb_exchange_token` (sẽ biến token
không hết hạn thành token 60 ngày).

Không giả định có thể lặp vô hạn thao tác này với một long-lived User Token. Với agent chạy server, hãy làm theo mục 11 trước khi token dài hạn hết hiệu lực.

## 6. Lấy tất cả tài khoản quảng cáo

Endpoint:

```text
GET /me/adaccounts
```

Ví dụ:

```bash
curl --silent --show-error --fail-with-body -G \
  "https://graph.facebook.com/${META_GRAPH_VERSION}/me/adaccounts" \
  --data-urlencode "fields=id,name,account_id,account_status,currency,timezone_name,amount_spent,balance" \
  --data-urlencode "limit=${META_PAGE_LIMIT}" \
  --data-urlencode "access_token=${META_ACCESS_TOKEN}"
```

Quy tắc:

- ID dùng cho API phải có dạng `act_123456789`.
- Nếu `META_AD_ACCOUNT_ALLOWLIST` trống, lấy tất cả account trả về.
- Nếu allowlist có giá trị, chỉ giữ account có trong danh sách.
- Deduplicate bằng `account_id`, không bằng tên.
- Không bỏ qua account chỉ vì tên là số.
- Ghi nhận `account_status`, currency và timezone. Account lỗi quyền phải xuất hiện trong báo cáo lỗi, không được âm thầm bỏ qua.

## 7. Phân trang bắt buộc

Mọi endpoint có thể trả:

```json
{
  "data": [],
  "paging": {
    "cursors": {"after": "..."},
    "next": "https://graph.facebook.com/..."
  }
}
```

Lặp cho đến khi không còn `paging.next` hoặc cursor `after`. Chỉ theo URL có HTTPS và host chính xác là `graph.facebook.com`. Không log URL đầy đủ. Chống vòng lặp bằng cách ghi nhớ cursor đã dùng; nếu cursor lặp lại, dừng account đó với `PAGINATION_LOOP_DETECTED`.

Kết quả chỉ được đánh dấu hoàn tất khi đã đi hết phân trang. Ví dụ Ads Manager có 217 campaign thì việc chỉ lấy 100 hoặc 200 campaign là lỗi `PARTIAL_DATA`.

## 8. Lấy campaign, ad set và ngân sách

### Campaign

```bash
curl --silent --show-error --fail-with-body -G \
  "https://graph.facebook.com/${META_GRAPH_VERSION}/${AD_ACCOUNT_ID}/campaigns" \
  --data-urlencode "fields=id,name,objective,status,effective_status,daily_budget,lifetime_budget,budget_remaining,buying_type,created_time,updated_time" \
  --data-urlencode "limit=${META_PAGE_LIMIT}" \
  --data-urlencode "access_token=${META_ACCESS_TOKEN}"
```

### Ad set

```bash
curl --silent --show-error --fail-with-body -G \
  "https://graph.facebook.com/${META_GRAPH_VERSION}/${AD_ACCOUNT_ID}/adsets" \
  --data-urlencode "fields=id,name,campaign_id,status,effective_status,daily_budget,lifetime_budget,budget_remaining,optimization_goal,billing_event,created_time,updated_time" \
  --data-urlencode "limit=${META_PAGE_LIMIT}" \
  --data-urlencode "access_token=${META_ACCESS_TOKEN}"
```

Chuẩn hóa ngân sách:

- Nếu campaign có `daily_budget` hoặc `lifetime_budget`, đặt `budget_source=CAMPAIGN`.
- Nếu campaign không có ngân sách nhưng ad set có, đặt `budget_source=ADSET` và trả danh sách ngân sách từng ad set.
- Chỉ cộng các `daily_budget` với nhau khi cần tổng ngân sách ngày; chỉ cộng `lifetime_budget` với nhau khi cần tổng ngân sách trọn đời. Không cộng lẫn hai loại.
- Giá trị ngân sách/spend Meta thường là chuỗi số. Chuyển kiểu an toàn; không dùng số thực nhị phân cho tiền trong logic kế toán.
- Nếu không xác định được, trả `budget=null`, không tự đoán.

## 9. Lấy chỉ số giống Ads Manager

Mặc định cấp campaign và 30 ngày qua:

```bash
curl --silent --show-error --fail-with-body -G \
  "https://graph.facebook.com/${META_GRAPH_VERSION}/${AD_ACCOUNT_ID}/insights" \
  --data-urlencode "level=campaign" \
  --data-urlencode "date_preset=${META_DEFAULT_DATE_PRESET}" \
  --data-urlencode "use_account_attribution_setting=true" \
  --data-urlencode "fields=account_id,account_name,account_currency,campaign_id,campaign_name,objective,date_start,date_stop,spend,impressions,reach,frequency,clicks,ctr,cpc,cpm,actions,cost_per_action_type" \
  --data-urlencode "limit=${META_PAGE_LIMIT}" \
  --data-urlencode "access_token=${META_ACCESS_TOKEN}"
```

Nếu người dùng đưa ngày cụ thể, dùng `time_range` thay cho `date_preset`:

```bash
curl --silent --show-error --fail-with-body -G \
  "https://graph.facebook.com/${META_GRAPH_VERSION}/${AD_ACCOUNT_ID}/insights" \
  --data-urlencode "level=campaign" \
  --data-urlencode 'time_range={"since":"2026-08-04","until":"2026-09-02"}' \
  --data-urlencode "use_account_attribution_setting=true" \
  --data-urlencode "fields=account_id,account_name,account_currency,campaign_id,campaign_name,objective,date_start,date_stop,spend,impressions,reach,frequency,clicks,ctr,cpc,cpm,actions,cost_per_action_type" \
  --data-urlencode "limit=${META_PAGE_LIMIT}" \
  --data-urlencode "access_token=${META_ACCESS_TOKEN}"
```

Đổi `level` và thêm ID/name tương ứng khi người dùng yêu cầu:

| Cấp | `level` | Trường bổ sung |
|---|---|---|
| Campaign | `campaign` | `campaign_id,campaign_name` |
| Ad set | `adset` | `campaign_id,campaign_name,adset_id,adset_name` |
| Ad | `ad` | `campaign_id,campaign_name,adset_id,adset_name,ad_id,ad_name` |

## 10. Chuẩn hóa “Kết quả” và “Chi phí/kết quả”

Meta không luôn trả một trường duy nhất tên `results`. Hai trường quan trọng có dạng mảng:

```json
{
  "actions": [
    {"action_type": "link_click", "value": "553"},
    {"action_type": "lead", "value": "23"}
  ],
  "cost_per_action_type": [
    {"action_type": "lead", "value": "185436"}
  ]
}
```

Thuật toán bắt buộc:

1. Chuyển `actions` thành map `results_by_action[action_type] = value`.
2. Chuyển `cost_per_action_type` thành map `costs_by_action[action_type] = value`.
3. Duyệt `META_RESULT_ACTION_PRIORITY` từ trái sang phải.
4. Action type đầu tiên tồn tại trong `results_by_action` trở thành `primary_result_type`.
5. `primary_result` lấy từ đúng action type đó.
6. `primary_cost_per_result` chỉ lấy từ cùng action type trong `costs_by_action`.
7. Nếu không có action phù hợp, cả ba giá trị phải là `null`; không đổi thành `0` và không lấy `link_click` thay thế.
8. Luôn giữ nguyên `results_by_action` và `costs_by_action` trong JSON chi tiết để đối soát.

Vì Ads Manager có thể thay đổi cách định nghĩa “Kết quả” theo objective, attribution và loại chiến dịch, khi số API lệch giao diện phải báo `METRIC_MAPPING_REVIEW_REQUIRED`; không được sửa số để khớp bằng phỏng đoán.

## 11. Cơ chế chống token hết hạn

### 11.1 Lịch kiểm tra

Chạy job kiểm tra token ít nhất mỗi ngày một lần và trước mỗi lần lấy dữ liệu:

```text
01:00 Asia/Ho_Chi_Minh -> debug_token
Trước mọi sync             -> debug_token
```

Không cần đợi tới khi API trả lỗi mới xử lý.

### 11.2 Khi dùng `USER_TOKEN`

User Token ngắn hạn thường chỉ phù hợp để test. Sau khi lấy token ngắn hạn bằng Facebook Login/Graph API Explorer, đổi sang long-lived token bằng mục 5.

Long-lived User Token không phải một OAuth refresh token vĩnh viễn dành cho server. Đối với OpenClaw chạy nền:

1. Khi còn không quá `META_TOKEN_WARNING_DAYS`, tạo cảnh báo cho owner.
2. Owner đăng nhập lại qua Facebook Login chính thức của app và chấp thuận `ads_read`.
3. Callback phải kiểm tra `state` chống CSRF, `redirect_uri` phải trùng cấu hình app và chỉ dùng HTTPS ở production.
4. Đổi authorization `code` thành User Access Token trên server.
5. Nếu token nhận được là short-lived, chạy mục 5 để đổi sang long-lived.
6. Kiểm tra token mới và thay token atomic như mục 5.
7. Không tự động hóa đăng nhập, mật khẩu, cookie hay 2FA.

URL bắt đầu OAuth, tạo động và URL-encode đầy đủ:

```text
https://www.facebook.com/v26.0/dialog/oauth
  ?client_id={META_APP_ID}
  &redirect_uri={HTTPS_CALLBACK_URL}
  &state={RANDOM_ONE_TIME_STATE}
  &scope=ads_read
```

Đổi `code` tại callback:

```bash
curl --silent --show-error --fail-with-body -G \
  "https://graph.facebook.com/${META_GRAPH_VERSION}/oauth/access_token" \
  --data-urlencode "client_id=${META_APP_ID}" \
  --data-urlencode "client_secret=${META_APP_SECRET}" \
  --data-urlencode "redirect_uri=${META_OAUTH_REDIRECT_URI}" \
  --data-urlencode "code=${META_OAUTH_CODE}"
```

Nếu token đã hết hạn, bị revoke, user đổi mật khẩu, gỡ app/quyền, mất quyền Ads Account hoặc app bị hạn chế, không có quy trình refresh bí mật nào để bỏ qua việc cấp quyền lại. Dừng an toàn và trả `TOKEN_REAUTH_REQUIRED`.

### 11.3 Khi dùng `SYSTEM_USER_TOKEN`

Đây là lựa chọn production tốt hơn khi các Ads Account thuộc cùng Business hoặc đã được chia sẻ cho Business của app:

1. Business Settings → Users → System Users.
2. Tạo System User với quyền tối thiểu cần thiết.
3. Assign đúng Ads Account cho System User.
4. Generate token cho đúng Meta App với `ads_read`.
5. Nếu giao diện cho chọn expiration, ưu tiên `Never` cho dịch vụ nội bộ được bảo vệ tốt; nếu chọn token có hạn thì theo dõi đúng `expires_at` và làm mới theo cơ chế System User do Meta cung cấp.
6. Debug và thử `/me/adaccounts` trước khi thay token production.

System User Token không tự nhìn thấy mọi account mà Facebook cá nhân đang quản lý. Các tài sản phải được assign/share hợp lệ cho Business/System User. Dù token báo không có lịch hết hạn, vẫn debug định kỳ vì token có thể bị revoke hoặc mất quyền tài sản.

## 12. Xử lý lỗi, retry và giới hạn tốc độ

- HTTP `429`, lỗi tạm thời `5xx` hoặc lỗi rate limit: retry exponential backoff có jitter, tối đa `META_MAX_RETRIES`.
- Mốc gợi ý: 1s, 2s, 4s, 8s, 16s cộng jitter; tôn trọng `Retry-After` nếu có.
- Meta OAuth error code `190`: không retry mù; debug token rồi chuyển sang `TOKEN_REAUTH_REQUIRED` hoặc `TOKEN_PERMISSION_REQUIRED`.
- Lỗi quyền trên một account: ghi lỗi theo account và tiếp tục account khác, nhưng đánh dấu toàn bộ báo cáo `PARTIAL_DATA`.
- Lỗi tham số/field không hợp lệ: không retry; trả tên endpoint, field lỗi và Graph version, tuyệt đối không kèm token.
- Giới hạn đồng thời bằng `META_MAX_CONCURRENCY`; không bắn request không giới hạn vào tất cả account.
- Nếu response có header usage, ghi tỷ lệ sử dụng để chủ động giảm tốc, nhưng không log dữ liệu nhạy cảm.

## 13. Ghép dữ liệu

Khóa ghép chuẩn:

```text
ad_accounts.id = act_{account_id}
campaigns.id = insights.campaign_id
adsets.campaign_id = campaigns.id
```

Mỗi dòng campaign cần có:

```json
{
  "account_id": "act_1214319943384493",
  "account_name": "1",
  "currency": "VND",
  "timezone": "Asia/Ho_Chi_Minh",
  "campaign_id": "120000000000000",
  "campaign_name": "LUCA SG - ABCXYZ",
  "status": "ACTIVE",
  "effective_status": "ACTIVE",
  "objective": "OUTCOME_ENGAGEMENT",
  "date_start": "2026-08-04",
  "date_stop": "2026-09-02",
  "primary_result_type": "onsite_conversion.messaging_conversation_started_7d",
  "primary_result": "23",
  "primary_cost_per_result": "185436",
  "budget_source": "ADSET",
  "daily_budget": null,
  "lifetime_budget": null,
  "adset_budgets": [],
  "spend": "4265032",
  "impressions": "23164",
  "reach": "19052",
  "clicks": "553",
  "results_by_action": {},
  "costs_by_action": {},
  "data_status": "COMPLETE"
}
```

Không dùng tên campaign làm khóa vì tên có thể trùng hoặc bị sửa.

## 14. Định dạng trả lời cho người dùng

Mặc định trả tóm tắt bằng tiếng Việt:

```text
Khoảng ngày: 04/08/2026–02/09/2026
Số tài khoản đọc thành công: X/Y
Số campaign: N
Tình trạng dữ liệu: COMPLETE | PARTIAL_DATA
Tình trạng token: VALID | TOKEN_EXPIRING_SOON | TOKEN_REAUTH_REQUIRED
```

Sau đó xuất bảng:

| Tài khoản | Campaign | Kết quả | Chi phí/kết quả | Ngân sách | Đã chi tiêu | Hiển thị |
|---|---|---:|---:|---:|---:|---:|

### 14.1. Báo cáo tổng hợp hiệu quả quảng cáo và CRM

Khi người dùng yêu cầu báo cáo tổng hợp, báo cáo kinh doanh, báo cáo phễu hoặc báo cáo theo số điện thoại/doanh thu, phải xuất thêm bảng sau. Các giá trị trong bảng là tổng của toàn bộ Ads Account thuộc phạm vi báo cáo và cùng một khoảng ngày.

| Chỉ số | Ý nghĩa/Công thức | Nguồn dữ liệu cụ thể |
|---|---|---|
| Data (tổng các TK) | Tổng số khách hàng/lead từ tất cả tài khoản quảng cáo sau khi áp dụng quy tắc chống trùng | `contact` của `/reports/marketing-leader`, hoặc `primary_result` từ Meta |
| Số điện thoại | Số khách đã để lại số điện thoại hợp lệ | CRM Luca |
| Tỷ lệ xin số | `Số điện thoại / Data (tổng các TK) × 100%` | Tính toán |
| Số $ / Data | `Tổng chi phí quảng cáo / Data (tổng các TK)` | Meta Ads + tính toán |
| Số $ / SĐT | `Tổng chi phí quảng cáo / Số điện thoại` | Meta Ads + CRM + tính toán |
| Khách tới | Số khách đã đến cửa hàng/phòng khám | `schedules` của `/reports/marketing-leader`; chính xác hơn thì dùng `become` của `/reports/task-schedules` |
| Doanh thu | Tổng doanh thu phát sinh từ nhóm khách quảng cáo | `gross_revenue` của `/reports/marketing-leader` (tiền thực thu) |
| Chi phí / Doanh thu | `Tổng chi phí quảng cáo / Doanh thu × 100%` | Tính toán |

Mẫu bảng kết quả bắt buộc:

| Khoảng ngày | Data (tổng các TK) | Số điện thoại | Tỷ lệ xin số | Số $ / Data | Số $ / SĐT | Khách tới | Doanh thu | Chi phí / Doanh thu |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| `DD/MM/YYYY–DD/MM/YYYY` | `N` | `N` | `N,NN%` | `N currency` | `N currency` | `N` | `N currency` | `N,NN%` |

Quy tắc dữ liệu và tính toán:

1. `Tổng chi phí quảng cáo` là tổng `spend` của tất cả Ads Account trong phạm vi, sau khi hoàn tất phân trang. Không cộng dòng tổng với các dòng campaign vì sẽ gây đếm hai lần.
2. Nếu CRM có dữ liệu lead đã đồng bộ từ Meta và có khóa đối soát ổn định, ưu tiên đếm Data từ CRM sau khi deduplicate. Nếu không, dùng tổng action Meta khớp loại kết quả lead/message đã cấu hình và ghi rõ loại action được dùng.
3. Deduplicate Data theo khóa ổn định theo thứ tự ưu tiên: `lead_id`/`crm_contact_id`, số điện thoại đã chuẩn hóa, rồi đến khóa nguồn duy nhất. Không deduplicate chỉ bằng tên khách hàng.
4. Số điện thoại hợp lệ phải được chuẩn hóa trước khi đếm; mỗi khách chỉ tính một lần trong cùng phạm vi báo cáo. Không hiển thị số điện thoại cá nhân trong bảng tổng hợp.
5. `Khách tới` chỉ đếm trạng thái CRM được xác nhận là đã đến; không tính lịch hẹn chưa tới hoặc đã hủy.
6. `Doanh thu` chỉ gồm giao dịch được quy thuộc cho nhóm khách quảng cáo trong cùng quy tắc attribution đã công bố. Ghi rõ doanh thu gộp hay doanh thu thuần nếu nguồn CRM/phần mềm bán hàng có cả hai.
7. Không tính tỷ lệ hoặc đơn giá khi mẫu số bằng `0` hay `null`; trả `Không xác định` và mã lý do `DIVISION_BY_ZERO` hoặc `SOURCE_DATA_MISSING`.
8. Không lấy trung bình các tỷ lệ/đơn giá của từng account. Luôn cộng tử số và mẫu số toàn cục rồi mới tính chỉ số tổng hợp.
9. Tiền quảng cáo dùng currency của Ads Account. Nếu có nhiều currency, không cộng trực tiếp; xuất từng currency thành một dòng hoặc quy đổi theo tỷ giá và ghi rõ nguồn, ngày tỷ giá.
10. Khoảng ngày, timezone và attribution của Meta, CRM và doanh thu phải đồng nhất. Nếu không đồng nhất, đánh dấu `PARTIAL_DATA` và nêu phần sai lệch.
11. Agent chỉ đọc Meta không được tự suy đoán `Số điện thoại`, `Khách tới` hoặc `Doanh thu`. Ba giá trị này phải lấy từ CRM Luca (mục 14.2). Nếu gọi CRM thất bại, hiển thị `Không có dữ liệu CRM`; các chỉ số phụ thuộc tương ứng là `Không xác định`.
12. Chi phí quảng cáo và doanh thu phải ghép theo `user_id`, không ghép theo tên nhân viên. Chi phí của một nhân viên là tổng `spend` của **các Ads Account mà token của chính nhân viên đó truy cập được**.

JSON chi tiết của báo cáo tổng hợp phải giữ các trường số thô để đối soát:

```json
{
  "date_start": "2026-08-04",
  "date_stop": "2026-09-02",
  "currency": "VND",
  "total_data": 0,
  "phone_count": null,
  "phone_rate_percent": null,
  "total_ad_spend": "0",
  "cost_per_data": null,
  "cost_per_phone": null,
  "arrived_customers": null,
  "revenue": null,
  "ad_spend_to_revenue_percent": null,
  "data_source": "META_ADS+CRM_LUCA",
  "crm_status": "CONNECTED",
  "data_status": "PARTIAL_DATA"
}
```

Quy tắc hiển thị:

- Tiền dùng đúng `account_currency`; VND định dạng theo `vi-VN`.
- Không làm tròn dữ liệu nguồn trong JSON; chỉ định dạng phần hiển thị.
- `null` hiển thị là `Không xác định`, không phải `0`.
- Nếu bảng quá dài, trả tóm tắt và lưu toàn bộ dữ liệu ở JSON/CSV; không tự cắt mất campaign.
- Luôn nêu account nào lỗi hoặc thiếu quyền.
- Không tuyên bố “khớp Ads Manager” nếu chưa đối soát attribution, timezone và khoảng ngày.

### 14.2. Chi phí quảng cáo / doanh thu thực tế theo từng nhân viên

Đây là báo cáo chính khi người dùng hỏi *"nhân viên nào chạy ads hiệu quả"*,
*"chi phí trên doanh thu của A là bao nhiêu"*, *"ads tốn bao nhiêu để ra 1 đồng doanh thu"*.

**Quy trình bắt buộc:**

1. Gọi `/meta/ads-accounts` (mục 3) để lấy danh sách nhân viên kèm credential.
2. Với **từng** `user_id`: chạy mục 4→13 bằng credential của chính người đó, tính
   `ad_spend[user_id]` = tổng `spend` mọi Ads Account của họ trong khoảng ngày.
3. Gọi CRM Luca lấy doanh thu cùng khoảng ngày:

```bash
curl --silent --show-error --fail-with-body -G \
  "${LUCA_BASE_URL}${LUCA_API_PREFIX}/reports/marketing-leader" \
  --header "x-api-key: ${LUCA_INTERNAL_API_KEY}" \
  --data-urlencode "start_date=04-08-2026" \
  --data-urlencode "end_date=02-09-2026"
```

   > Lưu ý định dạng ngày: Meta dùng `YYYY-MM-DD`, CRM Luca dùng `DD-MM-YYYY`.
   > Phải quy đổi, không truyền nhầm.

4. Ghép hai nguồn bằng `user_id`. Trường CRM dùng đến: `contact` (data),
   `schedules` (lịch hẹn), `orders` (đơn), `gross_revenue` (tiền thực thu).
5. Tính chỉ số cho từng người, rồi tính dòng tổng bằng cách **cộng tử số và mẫu số toàn cục**,
   tuyệt đối không lấy trung bình các tỷ lệ của từng người.

**Công thức:**

```text
cost_per_data[u]       = ad_spend[u] / contact[u]
cost_revenue_ratio[u]  = ad_spend[u] / gross_revenue[u] × 100%
roas[u]                = gross_revenue[u] / ad_spend[u]
```

**Bảng kết quả bắt buộc:**

| Nhân viên | Chi phí ads | Data | Chi phí/Data | Lịch hẹn | Đơn | Doanh thu | Chi phí/Doanh thu | ROAS |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| `full_name` | `N ₫` | `N` | `N ₫` | `N` | `N` | `N ₫` | `N,NN%` | `N,NN` |
| **Tổng** | `ΣN ₫` | `ΣN` | `Σ/Σ` | `ΣN` | `ΣN` | `ΣN ₫` | `Σ/Σ` | `Σ/Σ` |

**Quy tắc riêng của mục này:**

1. Nhân viên có chi phí ads nhưng `gross_revenue = 0`: `Chi phí/Doanh thu` là `Không xác định`
   kèm mã `DIVISION_BY_ZERO`, **không ghi `0%`** — hai thứ này nghĩa hoàn toàn khác nhau.
2. Nhân viên có trong CRM nhưng không có trong `/meta/ads-accounts` (chưa cấu hình Meta):
   vẫn hiện dòng, cột chi phí ads là `Không xác định`, không phải `0`.
3. Nhân viên có credential Meta nhưng `token_status = "EXPIRED"`: chi phí là `Không xác định`,
   ghi rõ lý do trong danh sách lỗi và đánh dấu toàn báo cáo `PARTIAL_DATA`.
4. `gross_revenue` của `/reports/marketing-leader` là **tiền thực thu** đã quy thuộc cho
   nhân viên marketing đó. Không đổi sang `all_total` (doanh số) giữa chừng mà không nói rõ.
5. Doanh thu CRM là VND. Nếu Ads Account dùng currency khác, **không chia trực tiếp** —
   quy đổi và ghi rõ tỷ giá + ngày tỷ giá, hoặc tách riêng từng currency.
6. Chi phí ads theo ngày Meta (`META_TIMEZONE`) và doanh thu theo ngày CRM có thể lệch múi giờ.
   Nếu lệch, nêu rõ và đánh dấu `PARTIAL_DATA`.
7. Doanh thu thường đến **sau** chi phí (khách chốt đơn muộn hơn ngày click). Với kỳ báo cáo
   ngắn hoặc vừa kết thúc, phải cảnh báo rằng `Chi phí/Doanh thu` đang bị thổi cao,
   không kết luận nhân viên kém hiệu quả chỉ dựa trên một kỳ ngắn.

**JSON chi tiết theo từng nhân viên:**

```json
{
  "date_start": "2026-08-04",
  "date_stop": "2026-09-02",
  "currency": "VND",
  "rows": [
    {
      "user_id": 42,
      "full_name": "Nguyễn Văn A",
      "ad_spend": "4265032",
      "ad_accounts": ["act_1214319943384493"],
      "contact": 120,
      "schedules": 45,
      "orders": 12,
      "gross_revenue": "38500000",
      "cost_per_data": "35542",
      "cost_revenue_ratio_percent": "11.08",
      "roas": "9.03",
      "token_status": "ACTIVE",
      "data_status": "COMPLETE"
    }
  ],
  "totals": {
    "ad_spend": "4265032",
    "gross_revenue": "38500000",
    "cost_revenue_ratio_percent": "11.08"
  },
  "data_source": "META_ADS+CRM_LUCA",
  "data_status": "COMPLETE"
}
```

## 15. Các câu lệnh OpenClaw phải hiểu

```text
Lấy tất cả tài khoản quảng cáo tôi đang được quyền truy cập.
Xem chỉ số campaign hôm nay.
Xem campaign trong 30 ngày qua.
Xem ad set từ 2026-08-04 đến 2026-09-02.
Xem từng quảng cáo của act_1214319943384493 hôm qua.
Kiểm tra token Meta còn hạn không.
Liệt kê account bị thiếu quyền hoặc lấy dữ liệu lỗi.
Xuất toàn bộ kết quả ra JSON hoặc CSV.

Chi phí quảng cáo trên doanh thu tháng trước là bao nhiêu.
Nhân viên marketing nào chạy ads hiệu quả nhất tháng này.
Bạn A tốn bao nhiêu tiền ads để ra 1 đồng doanh thu.
So sánh chi phí ads và doanh thu của từng nhân viên trong 30 ngày qua.
Ai đang có token Meta sắp hết hạn.
Nhân viên nào chưa cấu hình Meta App trong CRM.
```

Nếu người dùng không nói rõ:

- Nhân viên: tất cả bản ghi trả về từ `/meta/ads-accounts`.
- Account: tất cả account token của nhân viên đó được quyền truy cập.
- Cấp: `META_DEFAULT_LEVEL`.
- Thời gian: `META_DEFAULT_DATE_PRESET`.
- Chế độ: chỉ đọc.

Câu hỏi có chữ **chi phí/doanh thu, hiệu quả, ROAS, lãi lỗ** → bắt buộc chạy mục 14.2
(ghép Meta với CRM), không được trả mỗi chi phí quảng cáo rồi coi là xong.

## 16. Quy trình chạy hoàn chỉnh

```text
Nạp cấu hình LUCA_* và kiểm tra placeholder
→ GET /api/internal/meta/ads-accounts  (lấy credential theo từng nhân viên)
→ Bỏ qua bản ghi EXPIRED, cảnh báo bản ghi EXPIRING_SOON
│
├─ VỚI TỪNG user_id:
│    → Debug token của chính nhân viên đó
│    → GET /me/adaccounts và đi hết pagination
│    → Lọc allowlist nếu có
│    → Với từng account: lấy campaigns + adsets + insights
│    → Đi hết pagination của từng endpoint
│    → Chuẩn hóa actions/cost_per_action_type
│    → Ghép ngân sách và insights bằng ID
│    → ad_spend[user_id] = tổng spend
│
→ GET /api/internal/reports/marketing-leader  (doanh thu CRM cùng khoảng ngày)
→ Ghép ad_spend với gross_revenue bằng user_id
→ Tính chi phí/doanh thu, ROAS (cộng tử số và mẫu số toàn cục cho dòng tổng)
→ Kiểm tra completeness
→ Trả bảng theo nhân viên + JSON chi tiết + lỗi theo account/nhân viên
```

## 17. Điều kiện hoàn thành

Chỉ báo `COMPLETE` khi tất cả điều kiện đúng:

- Token hợp lệ và có `ads_read`.
- Đã đi hết pagination của `/me/adaccounts`.
- Đã đi hết pagination của campaigns, adsets và insights cho mọi account trong phạm vi.
- Không có account bị bỏ qua do lỗi.
- Mỗi dòng có ID và khoảng ngày rõ ràng.
- Kết quả/chi phí kết quả dùng cùng một `action_type`.
- Tiền tệ và timezone được ghi nhận.

Nếu thiếu một điều kiện, báo `PARTIAL_DATA` cùng lý do cụ thể.

## 18. Tài liệu Meta chính thức cần kiểm tra khi API thay đổi

- Graph API versions: <https://developers.facebook.com/docs/graph-api/changelog/versions/>
- Access Tokens: <https://developers.facebook.com/documentation/facebook-login/guides/access-tokens>
- Long-lived User Tokens: <https://developers.facebook.com/documentation/facebook-login/guides/access-tokens/get-long-lived>
- Debug Token: <https://developers.facebook.com/docs/graph-api/reference/debug_token/>
- Marketing API authentication: <https://developers.facebook.com/documentation/ads-commerce/marketing-api/get-started/authentication>
- Ad Account Insights: <https://developers.facebook.com/docs/marketing-api/reference/ad-account/insights/>

Nếu Meta đổi version, field hoặc action type, OpenClaw phải báo rõ thay đổi cần cập nhật; không tự thay logic production mà không kiểm thử.
