Skip to content

[Bug] Zalo Bot: kênh im lặng khi bot đã đăng ký webhook — webhook_url/webhook_secret là config chết #1542

Description

@maitanloi

Tóm tắt

Kênh zalo_oa chỉ hỗ trợ long-polling. Hai field webhook_url / webhook_secret đã có trong ZaloConfigđã phơi ra UI, nhưng không có dòng code nào đọc chúng. Hệ quả nghiêm trọng hơn: Zalo Bot API quy định polling và webhook loại trừ nhau — bot nào đã đăng ký webhook thì getUpdates ngừng hoạt động, và kênh GoClaw sẽ im lặng hoàn toàn: không nhận tin, không báo lỗi, không log gì bất thường.

Bằng chứng trong code

Nơi Vấn đề
internal/channels/zalo/zalo.go:95 Start() luôn go c.pollLoop(ctx), không có nhánh webhook
internal/channels/zalo/zalo.go:148 pollLoopgetUpdates (:500) là đường nhận tin duy nhất
internal/config/config_channels.go:204-205 WebhookURL, WebhookSecret khai báo nhưng không nơi nào tiêu thụ
ui/web/src/pages/channels/channel-schemas.ts:105-108, 212-218 UI cho người dùng nhập webhook_url + webhook_secret → điền xong không có tác dụng gì

grep -rn "WebhookURL\|WebhookSecret" internal/channels/zalo/ → không có kết quả.

Cách tái hiện

  1. Tạo bot qua Zalo Bot Creator, lấy token
  2. curl -X POST https://bot-api.zaloplatforms.com/bot$TOKEN/setWebhook -H 'Content-Type: application/json' -d '{"url":"https://example.com/hook","secret_token":"abcd1234"}'
  3. Cấu hình channel instance zalo_oa với token đó, khởi động gateway
  4. Nhắn tin cho bot → không có gì xảy ra. getMe vẫn OK nên channel báo healthy.

Mức độ ảnh hưởng

Người dùng làm theo hướng dẫn webhook của Zalo (Zalo khuyến nghị webhook cho production) sẽ thấy kênh "đang chạy" nhưng không bao giờ nhận được tin. Không có tín hiệu nào chỉ ra nguyên nhân.


Tài liệu tích hợp Zalo Bot API

Docs chính thức có 2 domain mirror, nội dung giống nhau:

API host chung: https://bot-api.zaloplatforms.com — khớp apiBase đang hard-code tại internal/channels/zalo/zalo.go:38.

Mọi endpoint theo dạng: POST https://bot-api.zaloplatforms.com/bot{BOT_TOKEN}/{method}, Content-Type: application/json.

0. Tạo bot và lấy token

Hướng dẫn: https://bot.zapps.me/docs/create-bot/

  1. Mở app Zalo → tìm OA Zalo Bot Manager
  2. Trong cửa sổ chat chọn Tạo bot → mở mini app Zalo Bot Creator
  3. Nhập tên bot — bắt buộc prefix Bot (vd Bot MyShop)
  4. Hệ thống gửi Bot Token qua tin nhắn Zalo cho tài khoản của bạn

Lưu ý:

  • Không cần sở hữu Zalo OA. Zalo Bot là sản phẩm riêng, khác hoàn toàn Zalo OA Open API (developers.zalo.me, openapi.zalo.me, OAuth app_id/app_secret/refresh_token).
  • Zalo Bot Creator đang beta, giới hạn 3 bot/tài khoản.

1. Cơ chế Polling

Hướng dẫn: https://bot.zapps.me/docs/build-your-bot/
API: https://bot.zapps.me/docs/apis/getUpdates/

getUpdates

Tham số Kiểu Bắt buộc Mô tả
timeout String Không Timeout HTTP request tính bằng giây. Mặc định 30.

Zalo ghi rõ 2 điều:

  • "Không hoạt động nếu đã cấu hình Webhook — phải dùng deleteWebhook để xoá cấu hình Webhook trước."
  • Chỉ nên dùng cho local dev/test; production nên dùng webhook để tránh mất event.

SDK tham khảo: python-zalo-bot, node-zalo-bot

2. Cơ chế Webhook

Hướng dẫn: https://bot.zapps.me/docs/build-your-bot-with-webhook/
API: https://bot.zapps.me/docs/apis/setWebhook/

setWebhook

Tham số Kiểu Bắt buộc Mô tả
url String URL nhận thông báo, dạng HTTPS
secret_token String 8–256 ký tự, Zalo gắn lại trong header X-Bot-Api-Secret-Token

Ràng buộc: URL phải public từ internet. localhost, 127.0.0.1, IP nội bộ (192.168.x.x, 10.x.x.x) bị từ chối. Local dev phải qua ngrok / Cloudflare Tunnel.

Response:

{
  "ok": true,
  "result": {
    "url": "https://your-webhookurl.com",
    "updated_at": 1749538250568,
    "verification": {
      "ok": true,
      "url": "https://your-webhookurl.com",
      "status_code": 200,
      "outcome": "webhook.ok",
      "latency_ms": 214,
      "hint": "Your endpoint responded successfully."
    }
  }
}

Zalo lưu URL kể cả khi verification thất bại — cho phép đăng ký trước khi server sẵn sàng.

getWebhookInfo — không tham số.

{ "ok": true, "result": { "url": "https://your-webhookurl.com", "updated_at": 1749633372026 } }

deleteWebhook — không tham số. Dùng khi muốn quay lại getUpdates.

{ "ok": true, "result": { "url": "", "updated_at": 1749538250568 } }

testWebhook — không tham số. Tự kiểm tra endpoint có nhận được request từ Zalo không.

{
  "ok": true,
  "result": {
    "ok": false,
    "url": "https://your-webhookurl.com",
    "status_code": 403,
    "outcome": "webhook.http.403",
    "latency_ms": 189,
    "hint": "Your server or CDN rejected the request with 403. Check WAF..."
  }
}

Phân biệt: ok ngoài = API call thành công; result.ok = webhook có trả 2xx hay không.

Payload webhook

Zalo gửi POST tới URL đã đăng ký, kèm header:

X-Bot-Api-Secret-Token: <secret_token đã đăng ký>

Xác thực bằng so sánh chuỗi, KHÔNG có HMAC. Mẫu trong doc Zalo:

const secretToken = req.headers["x-bot-api-secret-token"];
if (secretToken !== WEBHOOK_SECRET_TOKEN) {
  return res.status(403).json({ message: "Unauthorized" });
}

Body:

{
  "ok": true,
  "result": {
    "event_name": "message.text.received",
    "message": { }
  }
}

Shape này trùng với result của getUpdates — nghĩa là zaloAPIResponse + zaloUpdate + processUpdate (zalo.go:184) hiện có dùng lại được nguyên vẹn, không phải viết parser mới.

Response mong đợi: 200 + JSON, mẫu doc dùng res.json({ message: "Success" }).

Event types

Event GoClaw hiện xử lý?
message.text.received handleTextMessage (zalo.go:199)
message.image.received handleImageMessage (zalo.go:236)
message.sticker.received ❌ rơi vào default
message.voice.received ❌ rơi vào default
message.unsupported.received ❌ rơi vào default — dành cho nhóm người dùng được bảo vệ pháp lý, không kèm nội dung thật

Scope của issue này giữ nguyên text + image.

3. API gửi tin (tham khảo)

sendMessage

Tham số Kiểu Bắt buộc Mô tả
chat_id String ID người nhận hoặc hội thoại
text String 1–2000 ký tự
parse_mode String Không markdown hoặc html
text_styles Array Không Style runs trên raw text

Nếu có cả parse_modetext_styles thì parse_mode được ưu tiên, text_styles bị bỏ qua.

{ "ok": true, "result": { "message_id": "82599fa32f56d00e8941", "date": 1749632637199 } }

sendChatActionchat_id + action (typing, upload_photo sắp có). GoClaw hiện chưa dùng — có thể nối vào typing indicator sau.

Các API khác: getMe, sendPhoto, sendSticker, sendVoice.

4. Error codes

https://bot.zapps.me/docs/error-code/

Code Ý nghĩa
400 Bad request — sai đường dẫn hoặc API name không hợp lệ
401 Unauthorized — token hết hạn hoặc không hợp lệ
403 Internal server error
404 Not found — yêu cầu truy cập không hợp lệ
408 Request timeout
429 Quota exceeded — vượt giới hạn API

Chi tiết nằm ở field description trong response. callAPIWith (zalo.go:483) đã bung error_code + description ra error string.


Đề xuất hướng sửa

Ràng buộc quan trọng: không dùng được pattern Feishu

Route webhook chỉ được mount một lần lúc gateway khởi động (cmd/gateway_lifecycle.go:366), và http.ServeMux của Go không gỡ route được, đăng ký trùng pattern thì panic.

Feishu mount route theo từng instance (internal/channels/feishu/feishu.go:299) → channel instance tạo sau qua UI sẽ không có route, phải restart gateway.

Nên đi theo pattern Bitrix24/Pancake: một global router với route tĩnh, claim đúng một lần bằng CompareAndSwap (internal/channels/bitrix24/router.go:264,386; internal/channels/pancake/webhook_handler.go:79), mount ở startup kể cả khi chưa có instance nào.

Ràng buộc: payload không chứa bot id

Không suy được instance từ body. Giải pháp: path mang discriminator — POST /zalo/webhook/{instance} (wildcard ServeMux Go 1.22+), {instance} = BaseChannel.Name() (internal/channels/channel.go:250). Mỗi instance setWebhook một URL riêng, secret_token riêng.

Các bước

  1. Global router internal/channels/zalo/webhook_router.go: registry instanceName → *Channel có mutex, ClaimWebhookRoute() idempotent, handler verify secret bằng subtle.ConstantTimeCompare, xử lý event async (trả 200 trước, processUpdate trong goroutine có recover).
    • instance không tồn tại → 404; secret sai/thiếu → 403 + slog.Warn("security.zalo_webhook_bad_secret"); body hỏng → 400
  2. WebhookHandler() trên *Channel để thoả channels.WebhookChannel (internal/channels/channel.go:184-190), + block mount trong cmd/gateway_lifecycle.go giống block Bitrix24.
  3. Lifecycle trong Start() (zalo.go:95):
    • webhook_url rỗng → deleteWebhook() (dọn state cũ, idempotent) rồi pollLoophành vi y hệt hiện tại
    • webhook_url có → setWebhook{url, secret_token}, register router, không pollLoop
    • URL cuối = TrimSuffix(webhook_url,"/") + "/zalo/webhook/" + instanceName
    • verification.ok=falseslog.Warn + MarkFailed non-fatal (Zalo vẫn lưu URL, server có thể lên sau)
    • Stop() (zalo.go:113) → unregister router, không deleteWebhook (restart sẽ set lại; xoá làm mất event lúc downtime)
  4. Validate webhook_secret bắt buộc khi có webhook_url, độ dài 8–256 → lỗi rõ ràng từ New().

Acceptance criteria

  • Bot đã setWebhook nhận và trả lời được tin text qua GoClaw
  • Gửi ảnh → agent nhận được media, tôn trọng media_max_mb
  • Sai/thiếu X-Bot-Api-Secret-Token403 + log security.*
  • webhook_url rỗng → polling chạy y như trước, không regression
  • dm_policy (pairing/allowlist/open/disabled) hoạt động giống hệt nhánh polling
  • Instance tạo sau khi gateway đã chạy vẫn nhận được webhook — đây là lý do chọn global router
  • Nhiều instance cùng chạy, mỗi instance nhận đúng tin của bot mình
  • go build ./..., go build -tags sqliteonly ./..., go vet ./..., go test -race ./... xanh

Liên quan

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2-mediumFunctional bug, UX broken — prioritized backlogarea:channelsTelegram, Discord, channel managerbugSomething isn't workingmaintain:bug-confirmedBug confirmed by github-maintain automationmaintain:triagedTriaged by maintain workflow

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions