diff --git a/cmd/zalo-auth/main.go b/cmd/zalo-auth/main.go
new file mode 100644
index 000000000..992e22c81
--- /dev/null
+++ b/cmd/zalo-auth/main.go
@@ -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, "
✅ Zalo OAuth Success
Access token saved. Gateway restarting...
")
+
+ 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")
+ }
+}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index dd0b129e4..b7545fc55 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -401,6 +401,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("irc", "IRC")
}
+ if channels.Zalo.Enabled && channels.Zalo.AppID != "" {
+ m.initChannel("zalo", "Zalo")
+ }
+
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
diff --git a/pkg/channels/zalo/CONTRIBUTING.md b/pkg/channels/zalo/CONTRIBUTING.md
new file mode 100644
index 000000000..f87aa147f
--- /dev/null
+++ b/pkg/channels/zalo/CONTRIBUTING.md
@@ -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//`
+- [ ] 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
diff --git a/pkg/channels/zalo/README.md b/pkg/channels/zalo/README.md
new file mode 100644
index 000000000..ab74f1230
--- /dev/null
+++ b/pkg/channels/zalo/README.md
@@ -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: **1–3 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 (1–3 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 | 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")
+ }
+}
+```
diff --git a/pkg/channels/zalo/api.go b/pkg/channels/zalo/api.go
new file mode 100644
index 000000000..75bc32ffb
--- /dev/null
+++ b/pkg/channels/zalo/api.go
@@ -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"`
+}
diff --git a/pkg/channels/zalo/init.go b/pkg/channels/zalo/init.go
new file mode 100644
index 000000000..a615b0081
--- /dev/null
+++ b/pkg/channels/zalo/init.go
@@ -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)
+ })
+}
diff --git a/pkg/channels/zalo/oauth.go b/pkg/channels/zalo/oauth.go
new file mode 100644
index 000000000..3be679ca6
--- /dev/null
+++ b/pkg/channels/zalo/oauth.go
@@ -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
+}
diff --git a/pkg/channels/zalo/zalo.go b/pkg/channels/zalo/zalo.go
new file mode 100644
index 000000000..835996dfd
--- /dev/null
+++ b/pkg/channels/zalo/zalo.go
@@ -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")
+ }
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index eab770991..2f82e1042 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -301,6 +301,7 @@ type ChannelsConfig struct {
Pico PicoConfig `json:"pico"`
PicoClient PicoClientConfig `json:"pico_client"`
IRC IRCConfig `json:"irc"`
+ Zalo ZaloConfig `json:"zalo"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
@@ -1317,3 +1318,17 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
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"`
+}
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index 92bef6c15..9c9ba4b36 100644
--- a/pkg/gateway/gateway.go
+++ b/pkg/gateway/gateway.go
@@ -18,6 +18,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
_ "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/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
diff --git a/scripts/zalo-get-token.sh b/scripts/zalo-get-token.sh
new file mode 100755
index 000000000..8b8737458
--- /dev/null
+++ b/scripts/zalo-get-token.sh
@@ -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 = "✔ Zalo OAuth thành công!
Token đã lưu. Bạn có thể đóng tab này.
"
+ else:
+ html = f"✘ Lỗi
{json.dumps(result, indent=2)}"
+
+ 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 "══════════════════════════════════════════════════════════"