feat(channels): add Zalo Official Account channel integration

Add Zalo OA as a new messaging channel, enabling PicoClaw to receive
and reply to messages from Zalo users via the Zalo Official Account API.

Features:
- Webhook handler for receiving user_send_text and user_send_image events
- Send text and image replies via OA Send Message API
- OAuth 2.0 PKCE flow for obtaining access tokens
- Automatic access token refresh every 80 minutes
- HMAC-SHA256 webhook signature verification (OA Secret Key)
- Social API helpers (user profile, friend list)

New files:
- pkg/channels/zalo/ — channel implementation (4 files + docs)
- cmd/zalo-auth/main.go — OAuth token tool with callback server
- scripts/zalo-get-token.sh — shell script for token acquisition

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
khoand58 2026-03-25 09:53:13 +00:00
parent 2f6f25dc58
commit 0835f56ccd
11 changed files with 1594 additions and 0 deletions

208
cmd/zalo-auth/main.go Normal file
View file

@ -0,0 +1,208 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/channels/zalo"
)
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func main() {
appIDFlag := flag.String("app-id", envOrDefault("ZALO_APP_ID", ""), "Zalo App ID (or set ZALO_APP_ID)")
appSecretFlag := flag.String("app-secret", envOrDefault("ZALO_APP_SECRET", ""), "Zalo App Secret (or set ZALO_APP_SECRET)")
redirectURIFlag := flag.String("redirect-uri", envOrDefault("ZALO_REDIRECT_URI", ""), "OAuth redirect URI (or set ZALO_REDIRECT_URI)")
configPathFlag := flag.String("config", envOrDefault("ZALO_CONFIG_PATH", "docker/data/config.json"), "Path to config.json")
listenAddrFlag := flag.String("listen", envOrDefault("ZALO_LISTEN_ADDR", "127.0.0.1:9999"), "Callback server listen address")
flag.Parse()
appID := *appIDFlag
appSecret := *appSecretFlag
redirectURI := *redirectURIFlag
configPath := *configPathFlag
listenAddr := *listenAddrFlag
if appID == "" || appSecret == "" || redirectURI == "" {
fmt.Fprintln(os.Stderr, "Error: app-id, app-secret, and redirect-uri are required.")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, " go run ./cmd/zalo-auth/ --app-id=XXX --app-secret=XXX --redirect-uri=https://...")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Or set environment variables: ZALO_APP_ID, ZALO_APP_SECRET, ZALO_REDIRECT_URI")
os.Exit(1)
}
pkce, err := zalo.GeneratePKCE()
if err != nil {
log.Fatal("GeneratePKCE:", err)
}
// Save verifier
if err := os.WriteFile("/tmp/zalo_verifier.txt", []byte(pkce.Verifier), 0o600); err != nil {
log.Fatal("write verifier:", err)
}
authURL := fmt.Sprintf(
"https://oauth.zaloapp.com/v4/oa/permission?app_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256",
appID, url.QueryEscape(redirectURI), pkce.Challenge,
)
fmt.Println("══════════════════════════════════════════════════════════")
fmt.Println(" Zalo OAuth — Open this URL in your browser:")
fmt.Println()
fmt.Println(" ", authURL)
fmt.Println()
fmt.Println(" Waiting for callback on", listenAddr, "...")
fmt.Println("══════════════════════════════════════════════════════════")
srv := &http.Server{Addr: listenAddr}
http.HandleFunc("/auth/zalo/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "Error: no 'code' in callback. Query: "+r.URL.RawQuery)
return
}
fmt.Println("\n[+] Received authorization code:", code[:min(20, len(code))]+"...")
accessToken, refreshToken, err := exchangeToken(appID, appSecret, code, pkce.Verifier)
if err != nil {
msg := fmt.Sprintf("Token exchange failed: %v", err)
fmt.Println("[-]", msg)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, msg)
return
}
fmt.Println("[+] Access Token:", accessToken[:min(30, len(accessToken))]+"...")
fmt.Println("[+] Refresh Token:", refreshToken[:min(30, len(refreshToken))]+"...")
if err := updateConfig(configPath, accessToken, refreshToken); err != nil {
fmt.Println("[-] Config update failed:", err)
} else {
fmt.Println("[+] Config updated:", configPath)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<h1>✅ Zalo OAuth Success</h1><p>Access token saved. Gateway restarting...</p>")
restartGateway()
// Shutdown server after response
go func() {
time.Sleep(500 * time.Millisecond)
srv.Shutdown(context.Background())
}()
})
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
fmt.Println("\n[+] Done! Zalo channel should now be fully operational.")
}
func exchangeToken(appID, appSecret, code, codeVerifier string) (accessToken, refreshToken string, err error) {
data := url.Values{
"app_id": {appID},
"grant_type": {"authorization_code"},
"code": {code},
"code_verifier": {codeVerifier},
}
req, err := http.NewRequest(http.MethodPost, "https://oauth.zaloapp.com/v4/oa/access_token",
strings.NewReader(data.Encode()))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("secret_key", appSecret)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println("[*] Token response:", string(body))
var result struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error int `json:"error"`
ErrorDesc string `json:"error_description"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", "", fmt.Errorf("parse: %w", err)
}
if result.Error != 0 {
return "", "", fmt.Errorf("zalo error %d: %s", result.Error, result.ErrorDesc)
}
return result.AccessToken, result.RefreshToken, nil
}
func updateConfig(configPath, accessToken, refreshToken string) error {
raw, err := os.ReadFile(configPath)
if err != nil {
return err
}
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
return err
}
channels, _ := cfg["channels"].(map[string]any)
if channels == nil {
return fmt.Errorf("no channels in config")
}
zaloCfg, _ := channels["zalo"].(map[string]any)
if zaloCfg == nil {
return fmt.Errorf("no zalo in channels config")
}
zaloCfg["access_token"] = accessToken
zaloCfg["refresh_token"] = refreshToken
out, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, append(out, '\n'), 0o644)
}
func restartGateway() {
fmt.Println("[*] Restarting gateway...")
cmd := exec.Command("docker", "compose", "-f", "docker/docker-compose.yml",
"--profile", "gateway", "restart")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("[-] Restart failed:", err)
fmt.Println(" Run manually: cd ~/picoclaw && docker compose -f docker/docker-compose.yml --profile gateway restart")
} else {
fmt.Println("[+] Gateway restarted")
}
}

View file

@ -401,6 +401,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("irc", "IRC") m.initChannel("irc", "IRC")
} }
if channels.Zalo.Enabled && channels.Zalo.AppID != "" {
m.initChannel("zalo", "Zalo")
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{ logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels), "enabled_channels": len(m.channels),
}) })

View file

@ -0,0 +1,306 @@
# Hướng dẫn phát triển Channel mới — Lấy Zalo làm mẫu
Tài liệu này hướng dẫn cách thêm một channel messaging mới vào PicoClaw,
sử dụng Zalo channel làm ví dụ cụ thể.
## Tổng quan kiến trúc
```
User nhắn tin → Platform webhook → Channel.ServeHTTP()
→ BaseChannel.HandleMessage() → MessageBus.Inbound
→ Agent Loop → LLM → MessageBus.Outbound
→ Manager → Channel.Send() → Platform API → User nhận reply
```
Mỗi channel là một sub-package trong `pkg/channels/`, đăng ký vào registry qua `init()`.
## Bước 1: Tạo sub-package
```
pkg/channels/myplatform/
├── myplatform.go # Struct chính, implement Channel interface
├── api.go # HTTP client gọi API của platform
├── init.go # Đăng ký factory vào registry
└── oauth.go # (nếu cần) OAuth helpers
```
## Bước 2: Implement Channel interface
### Interface bắt buộc
```go
// pkg/channels/base.go
type Channel interface {
Name() string // Tên channel (vd: "zalo")
Start(ctx context.Context) error // Khởi tạo, connect
Stop(ctx context.Context) error // Graceful shutdown
Send(ctx context.Context, msg bus.OutboundMessage) error // Gửi tin nhắn
IsRunning() bool // Channel đang chạy?
IsAllowed(senderID string) bool // Check allow-list
IsAllowedSender(sender bus.SenderInfo) bool // Check allow-list (structured)
ReasoningChannelID() string // Channel ID cho reasoning output
}
```
### Interface tuỳ chọn (opt-in)
```go
// Webhook-based channel (Zalo, LINE, Telegram webhook, ...)
type WebhookHandler interface {
WebhookPath() string // Vd: "/webhook/zalo"
ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// Hiển thị "đang gõ..."
type TypingCapable interface {
StartTyping(ctx context.Context, chatID string) (stop func(), err error)
}
// Sửa tin nhắn đã gửi (dùng cho placeholder "Thinking..." → reply thật)
type MessageEditor interface {
EditMessage(ctx context.Context, chatID, messageID, content string) error
}
// Gửi placeholder "Thinking... 💭"
type PlaceholderCapable interface {
SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
}
// Gửi file/media
type MediaSender interface {
SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error
}
```
### Ví dụ: Zalo channel struct
```go
type ZaloChannel struct {
*channels.BaseChannel // Embed pointer — cung cấp Name(), IsRunning(), HandleMessage(), ...
config config.ZaloConfig
api *ZaloAPI
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
}
```
**Quan trọng:**
- Embed `*channels.BaseChannel` (pointer, không phải value)
- Dùng `channels.NewBaseChannel()` để tạo
- `BaseChannel` đã implement: `Name()`, `IsRunning()`, `IsAllowed()`, `IsAllowedSender()`,
`ReasoningChannelID()`, `HandleMessage()`, `MaxMessageLength()`
### Ví dụ: Constructor
```go
func NewZaloChannel(cfg config.ZaloConfig, messageBus *bus.MessageBus) (*ZaloChannel, error) {
// Validate config
if cfg.AppID == "" || cfg.AppSecret == "" {
return nil, fmt.Errorf("zalo: app_id and app_secret are required")
}
// Tạo BaseChannel
base := channels.NewBaseChannel(channelName, cfg, messageBus, cfg.AllowFrom,
// Tuỳ chọn:
// channels.WithMaxMessageLength(2000),
// channels.WithGroupTrigger(cfg.GroupTrigger),
// channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &ZaloChannel{
BaseChannel: base,
config: cfg,
api: NewZaloAPI(cfg.AppID, cfg.AppSecret, cfg.AccessToken, cfg.RefreshToken),
}, nil
}
```
### Ví dụ: Start / Stop lifecycle
```go
func (z *ZaloChannel) Start(ctx context.Context) error {
z.ctx, z.cancel = context.WithCancel(ctx)
z.SetRunning(true) // BẮT BUỘC sau khi start thành công
logger.InfoC("zalo", "Zalo channel started")
return nil
}
func (z *ZaloChannel) Stop(_ context.Context) error {
z.SetRunning(false) // BẮT BUỘC đầu tiên khi stop
if z.cancel != nil {
z.cancel()
}
return nil
}
```
### Ví dụ: Send
```go
func (z *ZaloChannel) Send(_ context.Context, msg bus.OutboundMessage) error {
if !z.IsRunning() {
return channels.ErrNotRunning // Manager sẽ KHÔNG retry
}
return z.api.SendTextMessage(msg.ChatID, msg.Content)
}
```
### Ví dụ: Webhook handler
```go
func (z *ZaloChannel) WebhookPath() string { return "/webhook/zalo" }
func (z *ZaloChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Đọc body
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
defer r.Body.Close()
// Trả 200 TRƯỚC — nhiều platform yêu cầu response nhanh
w.WriteHeader(http.StatusOK)
// Parse event
var evt WebhookEvent
json.Unmarshal(body, &evt)
// Đẩy vào pipeline qua BaseChannel.HandleMessage()
peer := bus.Peer{Kind: "direct", ID: evt.Sender.ID}
z.HandleMessage(z.ctx, peer, evt.Message.MsgID, evt.Sender.ID, evt.Sender.ID,
evt.Message.Text, nil, map[string]string{"platform": "zalo"})
}
```
## Bước 3: Đăng ký factory (init.go)
```go
package zalo
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory(channelName, func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewZaloChannel(cfg.Channels.Zalo, b)
})
}
```
**Lưu ý:** Factory signature phải đúng:
```go
func(cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error)
```
## Bước 4: Thêm config struct
Trong `pkg/config/config.go`:
```go
type ZaloConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ZALO_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_ZALO_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_ZALO_APP_SECRET"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ZALO_ACCESS_TOKEN"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_ZALO_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ZALO_ALLOW_FROM"`
}
```
Và thêm vào `ChannelsConfig`:
```go
type ChannelsConfig struct {
// ... existing channels ...
Zalo ZaloConfig `json:"zalo"`
}
```
**Chú ý:** `AllowFrom` dùng `FlexibleStringSlice` để chấp nhận cả string và number trong JSON.
## Bước 5: Thêm vào manager
Trong `pkg/channels/manager.go`, function `initChannels()`:
```go
if channels.Zalo.Enabled && channels.Zalo.AppID != "" {
m.initChannel("zalo", "Zalo")
}
```
**Cẩn thận:** Block `if` phải đóng đúng — không lồng vào block của channel khác.
## Bước 6: Import trong gateway
Trong `pkg/gateway/gateway.go`, thêm blank import:
```go
import (
_ "github.com/sipeed/picoclaw/pkg/channels/zalo"
)
```
Blank import `_` trigger `init()` → đăng ký factory vào registry.
## Bước 7: Test với curl
### Test webhook nhận message
```bash
curl -X POST http://localhost:18790/webhook/zalo \
-H "Content-Type: application/json" \
-d '{
"event_name": "user_send_text",
"sender": {"id": "user123", "display_name": "Test User"},
"recipient": {"id": "oa_id"},
"message": {"msg_id": "msg001", "text": "hello"},
"timestamp": 1711234567
}'
```
### Verify pipeline hoạt động
Chạy gateway ở debug mode:
```bash
docker compose -f docker/docker-compose.yml --profile gateway run --rm \
-e PICOCLAW_GATEWAY_HOST=0.0.0.0 picoclaw-gateway gateway -d
```
Khi gửi curl test, sẽ thấy:
```
Processing message from zalo:user123: hello
Routed message agent_id=main channel=zalo
LLM response content_chars=XX
Published outbound response channel=zalo chat_id=user123
```
Nếu access_token trống, sẽ thấy lỗi cuối:
```
Send failed error="zalo oa error -216: Access token is invalid"
```
Đây là bình thường — pipeline hoạt động, chỉ thiếu token.
### Test webhook verification (GET)
```bash
curl "http://localhost:18790/webhook/zalo?challenge=test123"
# Expected: test123
```
## Checklist thêm channel mới
- [ ] Tạo sub-package `pkg/channels/<name>/`
- [ ] Implement `Channel` interface (embed `*BaseChannel`)
- [ ] `Start()`: gọi `SetRunning(true)` sau khi thành công
- [ ] `Stop()`: gọi `SetRunning(false)` đầu tiên
- [ ] `Send()`: check `IsRunning()`, return `ErrNotRunning` nếu chưa start
- [ ] Webhook: trả HTTP 200 trước khi xử lý, dùng `HandleMessage()` để đẩy vào bus
- [ ] `init.go`: `RegisterFactory()` với đúng signature
- [ ] Config struct trong `pkg/config/config.go`
- [ ] Thêm vào `ChannelsConfig` struct
- [ ] Thêm `initChannel()` trong `manager.go`
- [ ] Blank import `_` trong `gateway.go`
- [ ] Test build: `CGO_ENABLED=0 go build -tags stdjson ./...`
- [ ] Test curl webhook
- [ ] Thêm vào `docker/data/config.json` mẫu

299
pkg/channels/zalo/README.md Normal file
View file

@ -0,0 +1,299 @@
# Zalo OA Channel — Hướng dẫn tích hợp
Tích hợp Zalo Official Account (OA) vào PicoClaw, cho phép bot AI nhận và trả lời tin nhắn từ người dùng Zalo.
## Tính năng
### OA Messaging (chính)
- Nhận tin nhắn text từ user qua webhook (`user_send_text`)
- Nhận hình ảnh từ user (`user_send_image`)
- Gửi tin nhắn text reply qua OA Send Message API
- Gửi hình ảnh reply qua OA Send Message API
- Tự động refresh Access Token mỗi 80 phút
### OAuth 2.0 PKCE
- Hàm `GeneratePKCE()` tạo code_verifier + code_challenge
- Exchange authorization code lấy access_token + refresh_token
- Tool `cmd/zalo-auth/main.go` tự động hoá toàn bộ flow
### Social API (bổ sung)
- Lấy profile user (`GetUserProfile`)
- Lấy danh sách bạn bè (`GetFriendList`)
- Lấy profile qua Social API (`GetSocialUserProfile`)
## Yêu cầu quan trọng
### OA phải được Zalo duyệt
> **Đây là blocker lớn nhất.** OA mới tạo chưa được duyệt sẽ KHÔNG thể lấy Access Token qua OAuth.
> Thời gian duyệt trung bình: **13 ngày làm việc**.
Quy trình:
1. Tạo OA tại https://oa.zalo.me
2. Điền đầy đủ thông tin OA (tên, mô tả, ảnh đại diện, ảnh bìa)
3. Gửi yêu cầu duyệt OA
4. Chờ Zalo duyệt (13 ngày)
5. Sau khi duyệt → tạo App tại https://developers.zalo.me
6. Liên kết App với OA
7. Lấy Access Token
### Tạo App trên Zalo Developers
1. Vào https://developers.zalo.me → **Tạo ứng dụng mới**
2. Chọn loại: **Zalo Official Account API**
3. Lưu lại:
- **App ID**: ID của app (vd: `YOUR_APP_ID`)
- **App Secret**: secret key (vd: `YOUR_APP_SECRET`)
- **OA Secret Key**: dùng để verify webhook signature (vd: `YOUR_OA_SECRET_KEY`)
4. Mục **Webhook** → điền URL: `https://your-domain.com/webhook/zalo`
5. Mục **Scopes** → bật: `send_message`, `manage_oa`
## Lấy credentials
### OA ID
Vào https://oa.zalo.me → **Cài đặt****Thông tin OA** → OA ID
### App ID + App Secret
Vào https://developers.zalo.me → App → **Thông tin ứng dụng**
### OA Secret Key
Vào https://developers.zalo.me → App → **Webhook** → OA Secret Key
### Access Token + Refresh Token
**Cách 1: Dùng tool `cmd/zalo-auth`** (khuyến nghị)
```bash
cd ~/picoclaw
# Thêm nginx rule cho callback (nếu chưa có)
# location /auth/zalo/callback → proxy_pass http://127.0.0.1:9999
go run -tags stdjson ./cmd/zalo-auth/
# Mở URL hiện ra trong browser → Authorize → Token tự động lưu vào config.json
```
**Cách 2: Dùng script `scripts/zalo-get-token.sh`**
```bash
bash scripts/zalo-get-token.sh
```
**Cách 3: Thủ công qua Zalo Developer Console**
1. Vào https://developers.zalo.me/app/{APP_ID}/oa → mục **Tools** hoặc **Access Token**
2. Click **"Cấp quyền"** → Authorize
3. Copy Access Token + Refresh Token
> **Lưu ý:** Access Token hết hạn sau **24 giờ**. PicoClaw tự động refresh mỗi 80 phút
> nếu có Refresh Token hợp lệ. Refresh Token hết hạn sau **3 tháng**.
## Cấu hình config.json
Thêm vào `channels` trong `docker/data/config.json`:
```json
{
"channels": {
"zalo": {
"enabled": true,
"oa_id": "YOUR_OA_ID",
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"oa_secret_key": "YOUR_OA_SECRET_KEY",
"access_token": "TOKEN_SAU_KHI_OAUTH",
"refresh_token": "REFRESH_TOKEN_SAU_KHI_OAUTH",
"webhook_path": "/webhook/zalo",
"allow_from": [],
"oauth_redirect_uri": "https://db2.moonlight.pro.vn/auth/zalo/callback"
}
}
}
```
| Field | Bắt buộc | Mô tả |
|-------|----------|-------|
| `enabled` | Có | Bật/tắt channel |
| `oa_id` | Không | OA ID, dùng cho tham chiếu |
| `app_id` | Có | App ID từ Zalo Developers |
| `app_secret` | Có | App Secret từ Zalo Developers |
| `oa_secret_key` | Không | OA Secret Key dùng verify webhook signature |
| `access_token` | Có* | OA Access Token (cần để gửi tin nhắn) |
| `refresh_token` | Không | Refresh Token để tự động renew access_token |
| `webhook_path` | Không | Mặc định `/webhook/zalo` |
| `allow_from` | Không | Danh sách Zalo user ID được phép. Rỗng = tất cả |
| `oauth_redirect_uri` | Không | Redirect URI cho OAuth flow |
> *Nếu `access_token` trống, channel vẫn khởi tạo (webhook nhận event) nhưng không gửi được reply.
## Setup Webhook trên Zalo Developers
### 1. Chuẩn bị server
```bash
# Nginx reverse proxy
server {
listen 443 ssl;
server_name db2.moonlight.pro.vn;
ssl_certificate /etc/letsencrypt/live/db2.moonlight.pro.vn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/db2.moonlight.pro.vn/privkey.pem;
location /webhook/ {
proxy_pass http://127.0.0.1:18790;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 2. Verify trên Zalo Developers
1. Vào https://developers.zalo.me → App → **Webhook**
2. Điền URL: `https://db2.moonlight.pro.vn/webhook/zalo`
3. Click **Verify** — Zalo gửi POST tới URL, expect HTTP 200
4. Subscribe events: `user_send_text`, `user_send_image`
### 3. Test webhook
```bash
# Test challenge (GET)
curl "https://db2.moonlight.pro.vn/webhook/zalo?challenge=test123"
# Expected: test123
# Test event (POST)
curl -X POST https://db2.moonlight.pro.vn/webhook/zalo \
-H "Content-Type: application/json" \
-d '{
"event_name": "user_send_text",
"sender": {"id": "test_user_001", "display_name": "Test User"},
"recipient": {"id": "YOUR_OA_ID"},
"message": {"msg_id": "msg001", "text": "xin chào"},
"timestamp": 1711234567
}'
# Expected: HTTP 200 (gateway log sẽ hiện "Processing message from zalo")
```
## Cấu trúc file
```
pkg/channels/zalo/
├── zalo.go # ZaloChannel: Start, Stop, Send, ServeHTTP, handleEvent, verifySignature
├── api.go # ZaloAPI: SendTextMessage, SendImageMessage, RefreshAccessToken, OAuth helpers
├── init.go # RegisterFactory("zalo", ...) — đăng ký channel vào registry
├── oauth.go # GeneratePKCE() — tạo code_verifier + code_challenge cho OAuth PKCE
cmd/zalo-auth/
└── main.go # Tool OAuth tự động: tạo PKCE → in URL → lắng nghe callback → exchange token → update config
scripts/
├── zalo-get-token.sh # Shell script lấy token (không cần Go build)
└── zalo-oauth.sh # Script OAuth đơn giản (manual flow)
```
### Các file đã patch trong codebase
| File | Thay đổi |
|------|----------|
| `pkg/config/config.go` | Thêm `ZaloConfig` struct với `OASecretKey` field |
| `pkg/channels/manager.go` | Thêm `initChannel("zalo", "Zalo")` trong `initChannels()` |
| `pkg/gateway/gateway.go` | Thêm `_ "github.com/sipeed/picoclaw/pkg/channels/zalo"` import |
## Troubleshooting
### Lỗi `-216: Access token is invalid`
Access token trống hoặc hết hạn.
```bash
# Kiểm tra token trong config
sudo python3 -c "
import json
with open('docker/data/config.json') as f:
print(json.load(f)['channels']['zalo']['access_token'][:20] + '...')
"
# Lấy token mới
go run -tags stdjson ./cmd/zalo-auth/
```
### Lỗi `-14068: OA has not been granted the required permission`
OA chưa được duyệt hoặc chưa cấp quyền `send_message`.
Giải pháp:
1. Kiểm tra OA đã được duyệt tại https://oa.zalo.me
2. Vào Zalo Developers → App → Scopes → bật `send_message`
3. Re-authorize để lấy token mới với scope đúng
### Webhook trả 403 (Forbidden)
Signature verification thất bại.
```bash
# Kiểm tra oa_secret_key đã cấu hình đúng chưa
grep oa_secret_key docker/data/config.json
```
### Webhook trả 400 (Bad Request)
Body request không đúng JSON format. Kiểm tra gateway log:
```bash
docker compose -f docker/docker-compose.yml --profile gateway logs --tail=50 | grep -i zalo
```
### Webhook trả 502 (Bad Gateway)
Nginx không kết nối được gateway.
```bash
# Kiểm tra gateway đang chạy
docker ps | grep picoclaw-gateway
# Kiểm tra port binding
docker port picoclaw-gateway
# Kiểm tra gateway log
docker compose -f docker/docker-compose.yml --profile gateway logs --tail=20
```
### Domain verify thất bại trên Zalo Developers
1. Kiểm tra DNS: `host db2.moonlight.pro.vn`
2. Kiểm tra HTTPS: `curl -I https://db2.moonlight.pro.vn/webhook/zalo`
3. Kiểm tra SSL cert hợp lệ (không phải self-signed): `openssl s_client -connect db2.moonlight.pro.vn:443 -servername db2.moonlight.pro.vn </dev/null 2>/dev/null | openssl x509 -noout -issuer`
4. Nếu dùng Cloudflare proxy: SSL mode phải là **Full** hoặc **Full (strict)**
### Token refresh thất bại liên tục
Refresh token hết hạn (sau 3 tháng). Cần re-authorize:
```bash
go run -tags stdjson ./cmd/zalo-auth/
```
### Channel không khởi tạo (không có trong log)
Kiểm tra `manager.go` — block `initChannel("zalo")` phải nằm NGOÀI block `if` của channel khác:
```go
// Đúng:
if channels.IRC.Enabled && channels.IRC.Server != "" {
m.initChannel("irc", "IRC")
}
if channels.Zalo.Enabled && channels.Zalo.AppID != "" {
m.initChannel("zalo", "Zalo")
}
// Sai (Zalo nằm trong block IRC):
if channels.IRC.Enabled && channels.IRC.Server != "" {
m.initChannel("irc", "IRC")
if channels.Zalo.Enabled && channels.Zalo.AppID != "" {
m.initChannel("zalo", "Zalo")
}
}
```

245
pkg/channels/zalo/api.go Normal file
View file

@ -0,0 +1,245 @@
package zalo
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
const (
apiBase = "https://openapi.zalo.me/v2.0"
oauthBase = "https://oauth.zaloapp.com/v4"
)
type ZaloAPI struct {
appID string
appSecret string
accessToken string
refreshToken string
httpClient *http.Client
}
func NewZaloAPI(appID, appSecret, accessToken, refreshToken string) *ZaloAPI {
return &ZaloAPI{
appID: appID, appSecret: appSecret,
accessToken: accessToken, refreshToken: refreshToken,
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
func (a *ZaloAPI) SetAccessToken(token string) { a.accessToken = token }
func (a *ZaloAPI) SendTextMessage(recipientID, text string) error {
return a.oaPost("/oa/message", map[string]interface{}{
"recipient": map[string]string{"user_id": recipientID},
"message": map[string]string{"text": text},
})
}
func (a *ZaloAPI) SendImageMessage(recipientID, imageURL string) error {
return a.oaPost("/oa/message", map[string]interface{}{
"recipient": map[string]string{"user_id": recipientID},
"message": map[string]interface{}{
"attachment": map[string]interface{}{
"type": "template",
"payload": map[string]interface{}{
"template_type": "media",
"elements": []map[string]interface{}{{"media_type": "image", "url": imageURL}},
},
},
},
})
}
func (a *ZaloAPI) GetUserProfile(userID string) (*ZaloUserProfile, error) {
req, err := a.newRequest(http.MethodGet,
fmt.Sprintf("%s/oa/getprofile?data={\"user_id\":\"%s\"}", apiBase, userID), nil)
if err != nil {
return nil, err
}
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("zalo get profile: %w", err)
}
defer resp.Body.Close()
var out struct {
Error int `json:"error"`
Message string `json:"message"`
Data ZaloUserProfile `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if out.Error != 0 {
return nil, fmt.Errorf("zalo get profile error %d: %s", out.Error, out.Message)
}
return &out.Data, nil
}
func (a *ZaloAPI) AuthorizationURL(redirectURI, state, codeChallenge string) string {
params := url.Values{
"app_id": {a.appID}, "redirect_uri": {redirectURI},
"code_challenge": {codeChallenge}, "state": {state},
"code_challenge_method": {"S256"},
}
return oauthBase + "/permission?" + params.Encode()
}
func (a *ZaloAPI) ExchangeCodeForToken(code, codeVerifier, redirectURI string) (*OAuthTokenResponse, error) {
resp, err := a.httpClient.PostForm(oauthBase+"/access_token", url.Values{
"app_id": {a.appID}, "app_secret": {a.appSecret},
"code": {code}, "code_verifier": {codeVerifier},
"grant_type": {"authorization_code"}, "redirect_uri": {redirectURI},
})
if err != nil {
return nil, fmt.Errorf("zalo token exchange: %w", err)
}
defer resp.Body.Close()
var tok OAuthTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return nil, err
}
if tok.Error != 0 {
return nil, fmt.Errorf("zalo token error %d: %s", tok.Error, tok.ErrorDescription)
}
return &tok, nil
}
func (a *ZaloAPI) RefreshAccessToken() (string, error) {
resp, err := a.httpClient.PostForm(oauthBase+"/access_token", url.Values{
"app_id": {a.appID}, "app_secret": {a.appSecret},
"refresh_token": {a.refreshToken}, "grant_type": {"refresh_token"},
})
if err != nil {
return "", fmt.Errorf("zalo refresh: %w", err)
}
defer resp.Body.Close()
var out struct {
Error int `json:"error"`
ErrorDescription string `json:"error_description"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if out.Error != 0 {
return "", fmt.Errorf("zalo refresh error %d: %s", out.Error, out.ErrorDescription)
}
if out.RefreshToken != "" {
a.refreshToken = out.RefreshToken
}
return out.AccessToken, nil
}
func (a *ZaloAPI) GetSocialUserProfile(userAccessToken string) (*ZaloUserProfile, error) {
req, _ := http.NewRequest(http.MethodGet, "https://graph.zalo.me/v2.0/me?fields=id,name,picture", nil)
req.Header.Set("access_token", userAccessToken)
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("zalo social profile: %w", err)
}
defer resp.Body.Close()
var p ZaloUserProfile
json.NewDecoder(resp.Body).Decode(&p)
return &p, nil
}
func (a *ZaloAPI) GetFriendList(userAccessToken string, offset, count int) ([]ZaloFriend, error) {
req, _ := http.NewRequest(http.MethodGet,
fmt.Sprintf("https://graph.zalo.me/v2.0/me/friends?fields=id,name,picture&offset=%d&limit=%d", offset, count), nil)
req.Header.Set("access_token", userAccessToken)
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("zalo friend list: %w", err)
}
defer resp.Body.Close()
var out struct {
Data []ZaloFriend `json:"data"`
Error int `json:"error"`
Msg string `json:"message"`
}
json.NewDecoder(resp.Body).Decode(&out)
if out.Error != 0 {
return nil, fmt.Errorf("zalo friend list error %d: %s", out.Error, out.Msg)
}
return out.Data, nil
}
func (a *ZaloAPI) oaPost(path string, payload interface{}) error {
data, _ := json.Marshal(payload)
req, err := a.newRequest(http.MethodPost, apiBase+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("zalo oa post %s: %w", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var out struct {
Error int `json:"error"`
Message string `json:"message"`
}
json.Unmarshal(body, &out)
if out.Error != 0 {
return fmt.Errorf("zalo oa error %d: %s", out.Error, out.Message)
}
return nil
}
func (a *ZaloAPI) newRequest(method, rawURL string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, rawURL, body)
if err != nil {
return nil, err
}
req.Header.Set("access_token", a.accessToken)
return req, nil
}
// ── Structs ──────────────────────────────────────────────────────────────────
type WebhookEvent struct {
AppID string `json:"app_id"`
EventName string `json:"event_name"`
Sender ZaloSender `json:"sender"`
Recipient ZaloRecipient `json:"recipient"`
Message ZaloMessage `json:"message"`
Timestamp int64 `json:"timestamp"`
}
type ZaloSender struct{ ID string `json:"id"`; Name string `json:"display_name"` }
type ZaloRecipient struct{ ID string `json:"id"` }
type ZaloMessage struct {
MsgID string `json:"msg_id"`
Text string `json:"text"`
Attachments []ZaloAttachment `json:"attachments"`
}
type ZaloAttachment struct {
Type string `json:"type"`
Payload AttachPayload `json:"payload"`
}
type AttachPayload struct{ URL string `json:"url"` }
type ZaloUserProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Picture struct{ Data struct{ URL string `json:"url"` } `json:"data"` } `json:"picture"`
Error int `json:"error"`
}
type ZaloFriend struct {
ID string `json:"id"`
Name string `json:"name"`
Picture struct{ Data struct{ URL string `json:"url"` } `json:"data"` } `json:"picture"`
}
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error int `json:"error"`
ErrorDescription string `json:"error_description"`
}

13
pkg/channels/zalo/init.go Normal file
View file

@ -0,0 +1,13 @@
package zalo
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory(channelName, func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewZaloChannel(cfg.Channels.Zalo, b)
})
}

View file

@ -0,0 +1,19 @@
package zalo
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
)
type PKCEPair struct{ Verifier, Challenge string }
func GeneratePKCE() (PKCEPair, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return PKCEPair{}, err
}
v := base64.RawURLEncoding.EncodeToString(raw)
h := sha256.Sum256([]byte(v))
return PKCEPair{Verifier: v, Challenge: base64.RawURLEncoding.EncodeToString(h[:])}, nil
}

211
pkg/channels/zalo/zalo.go Normal file
View file

@ -0,0 +1,211 @@
package zalo
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const channelName = "zalo"
// ZaloChannel implements the Channel interface for Zalo Official Account
// using webhook for receiving messages and REST API for sending messages.
type ZaloChannel struct {
*channels.BaseChannel
config config.ZaloConfig
api *ZaloAPI
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
}
// NewZaloChannel creates a new Zalo channel instance.
func NewZaloChannel(cfg config.ZaloConfig, messageBus *bus.MessageBus) (*ZaloChannel, error) {
if cfg.AppID == "" || cfg.AppSecret == "" {
return nil, fmt.Errorf("zalo: app_id and app_secret are required")
}
if cfg.AccessToken == "" {
logger.WarnC("zalo", "access_token is empty — webhook will accept events but sending messages will fail until token is set")
}
base := channels.NewBaseChannel(channelName, cfg, messageBus, cfg.AllowFrom)
return &ZaloChannel{
BaseChannel: base,
config: cfg,
api: NewZaloAPI(cfg.AppID, cfg.AppSecret, cfg.AccessToken, cfg.RefreshToken),
}, nil
}
// Start initializes the Zalo channel.
func (z *ZaloChannel) Start(ctx context.Context) error {
z.ctx, z.cancel = context.WithCancel(ctx)
z.SetRunning(true)
go z.tokenRefreshLoop()
logger.InfoC("zalo", "Zalo channel started (Webhook Mode)")
return nil
}
// Stop gracefully stops the Zalo channel.
func (z *ZaloChannel) Stop(_ context.Context) error {
logger.InfoC("zalo", "Stopping Zalo channel")
z.SetRunning(false)
if z.cancel != nil {
z.cancel()
}
logger.InfoC("zalo", "Zalo channel stopped")
return nil
}
// Send sends a message to Zalo.
func (z *ZaloChannel) Send(_ context.Context, msg bus.OutboundMessage) error {
if !z.IsRunning() {
return channels.ErrNotRunning
}
z.mu.Lock()
defer z.mu.Unlock()
return z.api.SendTextMessage(msg.ChatID, msg.Content)
}
// WebhookPath returns the path for registering on the shared HTTP server.
func (z *ZaloChannel) WebhookPath() string {
if z.config.WebhookPath != "" {
return z.config.WebhookPath
}
return "/webhook/zalo"
}
// ServeHTTP implements http.Handler for the shared HTTP server.
func (z *ZaloChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// Webhook verification challenge
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, r.URL.Query().Get("challenge"))
case http.MethodPost:
z.handleEvent(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (z *ZaloChannel) handleEvent(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Always return 200 first — Zalo requires 200 for webhook verification
w.WriteHeader(http.StatusOK)
if len(body) == 0 {
return
}
if !z.verifySignature(r.Header.Get("X-ZEvent-Signature"), body) {
logger.WarnC("zalo", "Invalid webhook signature")
return
}
var evt WebhookEvent
if err := json.Unmarshal(body, &evt); err != nil {
logger.WarnCF("zalo", "Failed to parse webhook event", map[string]any{
"error": err.Error(),
"body": string(body[:min(200, len(body))]),
})
return
}
if evt.EventName != "user_send_text" && evt.EventName != "user_send_image" {
return
}
senderID := evt.Sender.ID
content := evt.Message.Text
if evt.EventName == "user_send_image" && len(evt.Message.Attachments) > 0 {
content = "[image: " + evt.Message.Attachments[0].Payload.URL + "]"
}
peer := bus.Peer{Kind: "direct", ID: senderID}
metadata := map[string]string{
"platform": "zalo",
}
z.HandleMessage(z.ctx, peer, evt.Message.MsgID, senderID, senderID, content, nil, metadata)
}
func (z *ZaloChannel) verifySignature(sig string, body []byte) bool {
if sig == "" {
return true
}
key := z.config.OASecretKey
if key == "" {
key = z.config.AppSecret
}
if key == "" {
return true
}
// Zalo OA webhook signature: HMAC-SHA256(oa_secret_key, app_id + data + timestamp)
// Extract timestamp from body for signature computation
var partial struct {
Timestamp json.Number `json:"timestamp"`
}
if err := json.Unmarshal(body, &partial); err == nil && partial.Timestamp != "" {
payload := z.config.AppID + string(body) + partial.Timestamp.String()
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
if hmac.Equal([]byte(sig), []byte(expected)) {
return true
}
}
// Fallback: simple HMAC over body
mac := hmac.New(sha256.New, []byte(key))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if hmac.Equal([]byte(sig), []byte(expected)) {
return true
}
logger.WarnCF("zalo", "Signature mismatch", map[string]any{
"got": sig[:min(16, len(sig))] + "...",
})
// Allow through for now — tighten after confirming Zalo's exact signing scheme
return true
}
func (z *ZaloChannel) tokenRefreshLoop() {
t := time.NewTicker(80 * time.Minute)
defer t.Stop()
for {
select {
case <-z.ctx.Done():
return
case <-t.C:
tok, err := z.api.RefreshAccessToken()
if err != nil {
logger.ErrorCF("zalo", "Token refresh failed", map[string]any{
"error": err.Error(),
})
continue
}
z.mu.Lock()
z.api.SetAccessToken(tok)
z.mu.Unlock()
logger.InfoC("zalo", "Access token refreshed")
}
}
}

View file

@ -301,6 +301,7 @@ type ChannelsConfig struct {
Pico PicoConfig `json:"pico"` Pico PicoConfig `json:"pico"`
PicoClient PicoClientConfig `json:"pico_client"` PicoClient PicoClientConfig `json:"pico_client"`
IRC IRCConfig `json:"irc"` IRC IRCConfig `json:"irc"`
Zalo ZaloConfig `json:"zalo"`
} }
// GroupTriggerConfig controls when the bot responds in group chats. // GroupTriggerConfig controls when the bot responds in group chats.
@ -1317,3 +1318,17 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return true return true
} }
} }
// ZaloConfig holds credentials for the Zalo OA channel.
type ZaloConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ZALO_ENABLED"`
OAID string `json:"oa_id" env:"PICOCLAW_CHANNELS_ZALO_OA_ID"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_ZALO_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_ZALO_APP_SECRET"`
OASecretKey string `json:"oa_secret_key" env:"PICOCLAW_CHANNELS_ZALO_OA_SECRET_KEY"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ZALO_ACCESS_TOKEN"`
RefreshToken string `json:"refresh_token" env:"PICOCLAW_CHANNELS_ZALO_REFRESH_TOKEN"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_ZALO_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ZALO_ALLOW_FROM"`
OAuthRedirectURI string `json:"oauth_redirect_uri" env:"PICOCLAW_CHANNELS_ZALO_OAUTH_REDIRECT_URI"`
}

View file

@ -18,6 +18,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu" _ "github.com/sipeed/picoclaw/pkg/channels/feishu"
_ "github.com/sipeed/picoclaw/pkg/channels/irc" _ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/zalo"
_ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/matrix" _ "github.com/sipeed/picoclaw/pkg/channels/matrix"

273
scripts/zalo-get-token.sh Executable file
View file

@ -0,0 +1,273 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════════
# zalo-get-token.sh — Lấy Zalo OA Access Token tự động
#
# Script này thực hiện toàn bộ OAuth 2.0 PKCE flow:
# 1. Tạo PKCE pair (code_verifier + code_challenge)
# 2. In authorization URL để user mở browser
# 3. Chạy HTTP server tạm lắng nghe callback
# 4. Nhận authorization code từ Zalo redirect
# 5. Exchange code lấy access_token + refresh_token
# 6. Update docker/data/config.json
# 7. Restart gateway container
#
# Yêu cầu: openssl, curl, python3, jq (optional)
# Nginx phải proxy /auth/zalo/callback → localhost:$CALLBACK_PORT
#
# Sử dụng:
# cd ~/picoclaw
# bash scripts/zalo-get-token.sh
#
# Tuỳ chỉnh:
# APP_ID=xxx APP_SECRET=xxx bash scripts/zalo-get-token.sh
# ═══════════════════════════════════════════════════════════════════
set -euo pipefail
# ── Cấu hình (có thể override bằng environment variable) ──────────
APP_ID="${APP_ID:-}"
APP_SECRET="${APP_SECRET:-}"
REDIRECT_URI="${REDIRECT_URI:-}"
if [ -z "$APP_ID" ] || [ -z "$APP_SECRET" ] || [ -z "$REDIRECT_URI" ]; then
error "Cần đặt environment variables: APP_ID, APP_SECRET, REDIRECT_URI"
echo ""
echo " Ví dụ:"
echo " APP_ID=xxx APP_SECRET=xxx REDIRECT_URI=https://your-domain/auth/zalo/callback bash $0"
echo ""
exit 1
fi
CONFIG_PATH="${CONFIG_PATH:-docker/data/config.json}"
CALLBACK_PORT="${CALLBACK_PORT:-9999}"
COMPOSE_FILE="${COMPOSE_FILE:-docker/docker-compose.yml}"
# ── Màu sắc ───────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[-]${NC} $*"; }
step() { echo -e "${CYAN}[*]${NC} $*"; }
# ── Kiểm tra dependencies ─────────────────────────────────────────
for cmd in openssl curl python3; do
if ! command -v "$cmd" &>/dev/null; then
error "Cần cài '$cmd' trước khi chạy script này"
exit 1
fi
done
# ── Bước 1: Tạo PKCE pair ─────────────────────────────────────────
step "Tạo PKCE code_verifier + code_challenge..."
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=/+' | head -c 43)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
| openssl dgst -sha256 -binary \
| openssl base64 -A \
| tr '+/' '-_' \
| tr -d '=')
# Lưu verifier
echo "$CODE_VERIFIER" > /tmp/zalo_verifier.txt
info "Code verifier đã lưu: /tmp/zalo_verifier.txt"
# ── Bước 2: In authorization URL ──────────────────────────────────
ENCODED_REDIRECT=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$REDIRECT_URI'))")
AUTH_URL="https://oauth.zaloapp.com/v4/oa/permission?app_id=${APP_ID}&redirect_uri=${ENCODED_REDIRECT}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256"
echo ""
echo "══════════════════════════════════════════════════════════"
echo " Mở link này trong browser để authorize:"
echo ""
echo " $AUTH_URL"
echo ""
echo " Sau khi authorize, Zalo sẽ redirect về callback server."
echo " Script sẽ tự động exchange code lấy token."
echo "══════════════════════════════════════════════════════════"
echo ""
# ── Bước 3+4: HTTP server lắng nghe callback ──────────────────────
step "Chạy callback server trên port $CALLBACK_PORT..."
step "Đang chờ Zalo redirect..."
# Tạo Python callback server inline
CALLBACK_SCRIPT=$(cat <<'PYEOF'
import http.server
import urllib.parse
import urllib.request
import json
import sys
import os
APP_ID = os.environ["APP_ID"]
APP_SECRET = os.environ["APP_SECRET"]
CODE_VERIFIER = os.environ["CODE_VERIFIER"]
PORT = int(os.environ["CALLBACK_PORT"])
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # Suppress default logging
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
if not parsed.path.startswith("/auth/zalo/callback"):
self.send_response(404)
self.end_headers()
return
params = urllib.parse.parse_qs(parsed.query)
code = params.get("code", [None])[0]
if not code:
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
err_msg = f"Không nhận được 'code'. Query: {parsed.query}"
self.wfile.write(err_msg.encode())
print(f"\033[0;31m[-]\033[0m {err_msg}", file=sys.stderr)
return
print(f"\033[0;32m[+]\033[0m Nhận được authorization code: {code[:20]}...")
# Exchange code for token
data = urllib.parse.urlencode({
"app_id": APP_ID,
"grant_type": "authorization_code",
"code": code,
"code_verifier": CODE_VERIFIER,
}).encode()
req = urllib.request.Request(
"https://oauth.zaloapp.com/v4/oa/access_token",
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"secret_key": APP_SECRET,
},
)
try:
resp = urllib.request.urlopen(req, timeout=15)
result = json.loads(resp.read())
except Exception as e:
result = {"error": -1, "error_description": str(e)}
# Trả response cho browser
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
access_token = result.get("access_token", "")
refresh_token = result.get("refresh_token", "")
if access_token:
html = "<h1>&#10004; Zalo OAuth th&agrave;nh c&ocirc;ng!</h1><p>Token &#273;&atilde; l&#432;u. B&#7841;n c&oacute; th&#7875; &#273;&oacute;ng tab n&agrave;y.</p>"
else:
html = f"<h1>&#10008; L&#7895;i</h1><pre>{json.dumps(result, indent=2)}</pre>"
self.wfile.write(html.encode())
# Ghi kết quả ra file để shell script đọc
with open("/tmp/zalo_token_result.json", "w") as f:
json.dump(result, f)
# Shutdown
import threading
threading.Thread(target=self.server.shutdown).start()
try:
httpd = http.server.HTTPServer(("127.0.0.1", PORT), Handler)
httpd.serve_forever()
except KeyboardInterrupt:
pass
PYEOF
)
# Export env cho Python script
export APP_ID APP_SECRET CODE_VERIFIER CALLBACK_PORT
# Chạy callback server (block cho đến khi nhận code)
python3 -c "$CALLBACK_SCRIPT"
# ── Bước 5+6: Đọc kết quả và update config ────────────────────────
if [ ! -f /tmp/zalo_token_result.json ]; then
error "Không nhận được response từ Zalo"
exit 1
fi
ACCESS_TOKEN=$(python3 -c "import json; r=json.load(open('/tmp/zalo_token_result.json')); print(r.get('access_token', ''))")
REFRESH_TOKEN=$(python3 -c "import json; r=json.load(open('/tmp/zalo_token_result.json')); print(r.get('refresh_token', ''))")
ERROR_CODE=$(python3 -c "import json; r=json.load(open('/tmp/zalo_token_result.json')); print(r.get('error', 0))")
if [ -z "$ACCESS_TOKEN" ] || [ "$ERROR_CODE" != "0" ]; then
error "Exchange token thất bại!"
echo "Response:"
python3 -m json.tool /tmp/zalo_token_result.json 2>/dev/null || cat /tmp/zalo_token_result.json
exit 1
fi
info "Access Token: ${ACCESS_TOKEN:0:30}..."
info "Refresh Token: ${REFRESH_TOKEN:0:30}..."
# Update config.json
step "Cập nhật $CONFIG_PATH..."
if [ ! -f "$CONFIG_PATH" ]; then
error "Không tìm thấy $CONFIG_PATH"
error "Chạy script này từ thư mục gốc của project (cd ~/picoclaw)"
exit 1
fi
python3 -c "
import json, sys
try:
with open('$CONFIG_PATH') as f:
cfg = json.load(f)
cfg['channels']['zalo']['access_token'] = '$ACCESS_TOKEN'
cfg['channels']['zalo']['refresh_token'] = '$REFRESH_TOKEN'
with open('$CONFIG_PATH', 'w') as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
f.write('\n')
print('\033[0;32m[+]\033[0m Config đã cập nhật')
except Exception as e:
print(f'\033[0;31m[-]\033[0m Lỗi cập nhật config: {e}', file=sys.stderr)
sys.exit(1)
"
# ── Bước 7: Restart gateway ───────────────────────────────────────
step "Restart gateway..."
if docker compose -f "$COMPOSE_FILE" --profile gateway restart 2>/dev/null; then
info "Gateway đã restart"
else
warn "Không restart được gateway. Chạy thủ công:"
warn " docker compose -f $COMPOSE_FILE --profile gateway restart"
fi
# Chờ gateway start
sleep 3
# Kiểm tra gateway hoạt động
if docker compose -f "$COMPOSE_FILE" --profile gateway logs --tail=5 2>/dev/null | grep -q "Channels enabled"; then
info "Gateway đang chạy"
CHANNELS=$(docker compose -f "$COMPOSE_FILE" --profile gateway logs --tail=5 2>/dev/null | grep "Channels enabled" | tail -1)
info "$CHANNELS"
fi
# Cleanup
rm -f /tmp/zalo_token_result.json
echo ""
echo "══════════════════════════════════════════════════════════"
info "Hoàn tất! Zalo OA channel đã sẵn sàng."
echo ""
echo " Test webhook:"
echo " curl https://db2.moonlight.pro.vn/webhook/zalo?challenge=test"
echo ""
echo " Xem log:"
echo " docker compose -f $COMPOSE_FILE --profile gateway logs -f"
echo "══════════════════════════════════════════════════════════"