From 0835f56ccd55701934d9c5ace820de42ef763db0 Mon Sep 17 00:00:00 2001 From: khoand58 Date: Wed, 25 Mar 2026 09:53:13 +0000 Subject: [PATCH] feat(channels): add Zalo Official Account channel integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/zalo-auth/main.go | 208 ++++++++++++++++++++ pkg/channels/manager.go | 4 + pkg/channels/zalo/CONTRIBUTING.md | 306 ++++++++++++++++++++++++++++++ pkg/channels/zalo/README.md | 299 +++++++++++++++++++++++++++++ pkg/channels/zalo/api.go | 245 ++++++++++++++++++++++++ pkg/channels/zalo/init.go | 13 ++ pkg/channels/zalo/oauth.go | 19 ++ pkg/channels/zalo/zalo.go | 211 ++++++++++++++++++++ pkg/config/config.go | 15 ++ pkg/gateway/gateway.go | 1 + scripts/zalo-get-token.sh | 273 ++++++++++++++++++++++++++ 11 files changed, 1594 insertions(+) create mode 100644 cmd/zalo-auth/main.go create mode 100644 pkg/channels/zalo/CONTRIBUTING.md create mode 100644 pkg/channels/zalo/README.md create mode 100644 pkg/channels/zalo/api.go create mode 100644 pkg/channels/zalo/init.go create mode 100644 pkg/channels/zalo/oauth.go create mode 100644 pkg/channels/zalo/zalo.go create mode 100755 scripts/zalo-get-token.sh 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 "══════════════════════════════════════════════════════════"