Tóm tắt
Kênh zalo_oa chỉ hỗ trợ long-polling. Hai field webhook_url / webhook_secret đã có trong ZaloConfig và đã 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 |
pollLoop → getUpdates (: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
- Tạo bot qua Zalo Bot Creator, lấy token
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"}'
- Cấu hình channel instance
zalo_oa với token đó, khởi động gateway
- 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/
- Mở app Zalo → tìm OA Zalo Bot Manager
- Trong cửa sổ chat chọn Tạo bot → mở mini app Zalo Bot Creator
- Nhập tên bot — bắt buộc prefix
Bot (vd Bot MyShop)
- 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 |
Có |
URL nhận thông báo, dạng HTTPS |
secret_token |
String |
Có |
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 |
Có |
ID người nhận hoặc hội thoại |
text |
String |
Có |
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_mode và text_styles thì parse_mode được ưu tiên, text_styles bị bỏ qua.
{ "ok": true, "result": { "message_id": "82599fa32f56d00e8941", "date": 1749632637199 } }
sendChatAction — chat_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
- 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
WebhookHandler() trên *Channel để thoả channels.WebhookChannel (internal/channels/channel.go:184-190), + block mount trong cmd/gateway_lifecycle.go giống block Bitrix24.
- Lifecycle trong
Start() (zalo.go:95):
webhook_url rỗng → deleteWebhook() (dọn state cũ, idempotent) rồi pollLoop — hà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=false → slog.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)
- Validate
webhook_secret bắt buộc khi có webhook_url, độ dài 8–256 → lỗi rõ ràng từ New().
Acceptance criteria
Liên quan
Tóm tắt
Kênh
zalo_oachỉ hỗ trợ long-polling. Hai fieldwebhook_url/webhook_secretđã có trongZaloConfigvà đã 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ìgetUpdatesngừ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
internal/channels/zalo/zalo.go:95Start()luôngo c.pollLoop(ctx), không có nhánh webhookinternal/channels/zalo/zalo.go:148pollLoop→getUpdates(:500) là đường nhận tin duy nhấtinternal/config/config_channels.go:204-205WebhookURL,WebhookSecretkhai báo nhưng không nơi nào tiêu thụui/web/src/pages/channels/channel-schemas.ts:105-108, 212-218webhook_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
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"}'zalo_oavới token đó, khởi động gatewaygetMevẫ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ớpapiBaseđang hard-code tạiinternal/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/
Bot(vdBot MyShop)Lưu ý:
developers.zalo.me,openapi.zalo.me, OAuthapp_id/app_secret/refresh_token).1. Cơ chế Polling
Hướng dẫn: https://bot.zapps.me/docs/build-your-bot/
API: https://bot.zapps.me/docs/apis/getUpdates/
getUpdatestimeoutZalo ghi rõ 2 điều:
deleteWebhookđể xoá cấu hình Webhook trước."SDK tham khảo:
python-zalo-bot,node-zalo-bot2. 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/
setWebhookurlsecret_tokenX-Bot-Api-Secret-TokenRà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ạigetUpdates.{ "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:
okngoài = API call thành công;result.ok= webhook có trả 2xx hay không.Payload webhook
Zalo gửi
POSTtới URL đã đăng ký, kèm header:Xác thực bằng so sánh chuỗi, KHÔNG có HMAC. Mẫu trong doc Zalo:
Body:
{ "ok": true, "result": { "event_name": "message.text.received", "message": { } } }Shape này trùng với
resultcủagetUpdates— 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ùngres.json({ message: "Success" }).Event types
message.text.receivedhandleTextMessage(zalo.go:199)message.image.receivedhandleImageMessage(zalo.go:236)message.sticker.receiveddefaultmessage.voice.receiveddefaultmessage.unsupported.receiveddefault— dành cho nhóm người dùng được bảo vệ pháp lý, không kèm nội dung thậtScope của issue này giữ nguyên text + image.
3. API gửi tin (tham khảo)
sendMessagechat_idtextparse_modemarkdownhoặchtmltext_stylesNếu có cả
parse_modevàtext_stylesthìparse_modeđược ưu tiên,text_stylesbị bỏ qua.{ "ok": true, "result": { "message_id": "82599fa32f56d00e8941", "date": 1749632637199 } }sendChatAction—chat_id+action(typing,upload_photosắ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/
Chi tiết nằm ở field
descriptiontrong response.callAPIWith(zalo.go:483) đã bungerror_code+descriptionra 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.ServeMuxcủ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}(wildcardServeMuxGo 1.22+),{instance}=BaseChannel.Name()(internal/channels/channel.go:250). Mỗi instancesetWebhookmột URL riêng,secret_tokenriêng.Các bước
internal/channels/zalo/webhook_router.go: registryinstanceName → *Channelcó mutex,ClaimWebhookRoute()idempotent, handler verify secret bằngsubtle.ConstantTimeCompare, xử lý event async (trả200trước,processUpdatetrong goroutine có recover).404; secret sai/thiếu →403+slog.Warn("security.zalo_webhook_bad_secret"); body hỏng →400WebhookHandler()trên*Channelđể thoảchannels.WebhookChannel(internal/channels/channel.go:184-190), + block mount trongcmd/gateway_lifecycle.gogiống block Bitrix24.Start()(zalo.go:95):webhook_urlrỗng →deleteWebhook()(dọn state cũ, idempotent) rồipollLoop— hành vi y hệt hiện tạiwebhook_urlcó →setWebhook{url, secret_token}, register router, khôngpollLoopTrimSuffix(webhook_url,"/") + "/zalo/webhook/" + instanceNameverification.ok=false→slog.Warn+MarkFailednon-fatal (Zalo vẫn lưu URL, server có thể lên sau)Stop()(zalo.go:113) → unregister router, khôngdeleteWebhook(restart sẽ set lại; xoá làm mất event lúc downtime)webhook_secretbắt buộc khi cówebhook_url, độ dài 8–256 → lỗi rõ ràng từNew().Acceptance criteria
setWebhooknhận và trả lời được tin text qua GoClawmedia_max_mbX-Bot-Api-Secret-Token→403+ logsecurity.*webhook_urlrỗng → polling chạy y như trước, không regressiondm_policy(pairing/allowlist/open/disabled) hoạt động giống hệt nhánh pollinggo build ./...,go build -tags sqliteonly ./...,go vet ./...,go test -race ./...xanhLiên quan
status:blocked)