feat(channels): add Zalo Official Account channel integration
Add Zalo OA as a new messaging channel, enabling PicoClaw to receive and reply to messages from Zalo users via the Zalo Official Account API. Features: - Webhook handler for receiving user_send_text and user_send_image events - Send text and image replies via OA Send Message API - OAuth 2.0 PKCE flow for obtaining access tokens - Automatic access token refresh every 80 minutes - HMAC-SHA256 webhook signature verification (OA Secret Key) - Social API helpers (user profile, friend list) New files: - pkg/channels/zalo/ — channel implementation (4 files + docs) - cmd/zalo-auth/main.go — OAuth token tool with callback server - scripts/zalo-get-token.sh — shell script for token acquisition Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f6f25dc58
commit
0835f56ccd
11 changed files with 1594 additions and 0 deletions
208
cmd/zalo-auth/main.go
Normal file
208
cmd/zalo-auth/main.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels/zalo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func envOrDefault(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
appIDFlag := flag.String("app-id", envOrDefault("ZALO_APP_ID", ""), "Zalo App ID (or set ZALO_APP_ID)")
|
||||||
|
appSecretFlag := flag.String("app-secret", envOrDefault("ZALO_APP_SECRET", ""), "Zalo App Secret (or set ZALO_APP_SECRET)")
|
||||||
|
redirectURIFlag := flag.String("redirect-uri", envOrDefault("ZALO_REDIRECT_URI", ""), "OAuth redirect URI (or set ZALO_REDIRECT_URI)")
|
||||||
|
configPathFlag := flag.String("config", envOrDefault("ZALO_CONFIG_PATH", "docker/data/config.json"), "Path to config.json")
|
||||||
|
listenAddrFlag := flag.String("listen", envOrDefault("ZALO_LISTEN_ADDR", "127.0.0.1:9999"), "Callback server listen address")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
appID := *appIDFlag
|
||||||
|
appSecret := *appSecretFlag
|
||||||
|
redirectURI := *redirectURIFlag
|
||||||
|
configPath := *configPathFlag
|
||||||
|
listenAddr := *listenAddrFlag
|
||||||
|
|
||||||
|
if appID == "" || appSecret == "" || redirectURI == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "Error: app-id, app-secret, and redirect-uri are required.")
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage:")
|
||||||
|
fmt.Fprintln(os.Stderr, " go run ./cmd/zalo-auth/ --app-id=XXX --app-secret=XXX --redirect-uri=https://...")
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
fmt.Fprintln(os.Stderr, "Or set environment variables: ZALO_APP_ID, ZALO_APP_SECRET, ZALO_REDIRECT_URI")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkce, err := zalo.GeneratePKCE()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("GeneratePKCE:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save verifier
|
||||||
|
if err := os.WriteFile("/tmp/zalo_verifier.txt", []byte(pkce.Verifier), 0o600); err != nil {
|
||||||
|
log.Fatal("write verifier:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := fmt.Sprintf(
|
||||||
|
"https://oauth.zaloapp.com/v4/oa/permission?app_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256",
|
||||||
|
appID, url.QueryEscape(redirectURI), pkce.Challenge,
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt.Println("══════════════════════════════════════════════════════════")
|
||||||
|
fmt.Println(" Zalo OAuth — Open this URL in your browser:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" ", authURL)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" Waiting for callback on", listenAddr, "...")
|
||||||
|
fmt.Println("══════════════════════════════════════════════════════════")
|
||||||
|
|
||||||
|
srv := &http.Server{Addr: listenAddr}
|
||||||
|
|
||||||
|
http.HandleFunc("/auth/zalo/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
fmt.Fprint(w, "Error: no 'code' in callback. Query: "+r.URL.RawQuery)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n[+] Received authorization code:", code[:min(20, len(code))]+"...")
|
||||||
|
|
||||||
|
accessToken, refreshToken, err := exchangeToken(appID, appSecret, code, pkce.Verifier)
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("Token exchange failed: %v", err)
|
||||||
|
fmt.Println("[-]", msg)
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
fmt.Fprint(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[+] Access Token:", accessToken[:min(30, len(accessToken))]+"...")
|
||||||
|
fmt.Println("[+] Refresh Token:", refreshToken[:min(30, len(refreshToken))]+"...")
|
||||||
|
|
||||||
|
if err := updateConfig(configPath, accessToken, refreshToken); err != nil {
|
||||||
|
fmt.Println("[-] Config update failed:", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("[+] Config updated:", configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, "<h1>✅ Zalo OAuth Success</h1><p>Access token saved. Gateway restarting...</p>")
|
||||||
|
|
||||||
|
restartGateway()
|
||||||
|
|
||||||
|
// Shutdown server after response
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
srv.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n[+] Done! Zalo channel should now be fully operational.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeToken(appID, appSecret, code, codeVerifier string) (accessToken, refreshToken string, err error) {
|
||||||
|
data := url.Values{
|
||||||
|
"app_id": {appID},
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"code": {code},
|
||||||
|
"code_verifier": {codeVerifier},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://oauth.zaloapp.com/v4/oa/access_token",
|
||||||
|
strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("secret_key", appSecret)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Println("[*] Token response:", string(body))
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Error int `json:"error"`
|
||||||
|
ErrorDesc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return "", "", fmt.Errorf("parse: %w", err)
|
||||||
|
}
|
||||||
|
if result.Error != 0 {
|
||||||
|
return "", "", fmt.Errorf("zalo error %d: %s", result.Error, result.ErrorDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.AccessToken, result.RefreshToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateConfig(configPath, accessToken, refreshToken string) error {
|
||||||
|
raw, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, _ := cfg["channels"].(map[string]any)
|
||||||
|
if channels == nil {
|
||||||
|
return fmt.Errorf("no channels in config")
|
||||||
|
}
|
||||||
|
zaloCfg, _ := channels["zalo"].(map[string]any)
|
||||||
|
if zaloCfg == nil {
|
||||||
|
return fmt.Errorf("no zalo in channels config")
|
||||||
|
}
|
||||||
|
|
||||||
|
zaloCfg["access_token"] = accessToken
|
||||||
|
zaloCfg["refresh_token"] = refreshToken
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(configPath, append(out, '\n'), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartGateway() {
|
||||||
|
fmt.Println("[*] Restarting gateway...")
|
||||||
|
cmd := exec.Command("docker", "compose", "-f", "docker/docker-compose.yml",
|
||||||
|
"--profile", "gateway", "restart")
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Println("[-] Restart failed:", err)
|
||||||
|
fmt.Println(" Run manually: cd ~/picoclaw && docker compose -f docker/docker-compose.yml --profile gateway restart")
|
||||||
|
} else {
|
||||||
|
fmt.Println("[+] Gateway restarted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -401,6 +401,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("irc", "IRC")
|
m.initChannel("irc", "IRC")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if channels.Zalo.Enabled && channels.Zalo.AppID != "" {
|
||||||
|
m.initChannel("zalo", "Zalo")
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
306
pkg/channels/zalo/CONTRIBUTING.md
Normal file
306
pkg/channels/zalo/CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
# Hướng dẫn phát triển Channel mới — Lấy Zalo làm mẫu
|
||||||
|
|
||||||
|
Tài liệu này hướng dẫn cách thêm một channel messaging mới vào PicoClaw,
|
||||||
|
sử dụng Zalo channel làm ví dụ cụ thể.
|
||||||
|
|
||||||
|
## Tổng quan kiến trúc
|
||||||
|
|
||||||
|
```
|
||||||
|
User nhắn tin → Platform webhook → Channel.ServeHTTP()
|
||||||
|
→ BaseChannel.HandleMessage() → MessageBus.Inbound
|
||||||
|
→ Agent Loop → LLM → MessageBus.Outbound
|
||||||
|
→ Manager → Channel.Send() → Platform API → User nhận reply
|
||||||
|
```
|
||||||
|
|
||||||
|
Mỗi channel là một sub-package trong `pkg/channels/`, đăng ký vào registry qua `init()`.
|
||||||
|
|
||||||
|
## Bước 1: Tạo sub-package
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/channels/myplatform/
|
||||||
|
├── myplatform.go # Struct chính, implement Channel interface
|
||||||
|
├── api.go # HTTP client gọi API của platform
|
||||||
|
├── init.go # Đăng ký factory vào registry
|
||||||
|
└── oauth.go # (nếu cần) OAuth helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bước 2: Implement Channel interface
|
||||||
|
|
||||||
|
### Interface bắt buộc
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/channels/base.go
|
||||||
|
type Channel interface {
|
||||||
|
Name() string // Tên channel (vd: "zalo")
|
||||||
|
Start(ctx context.Context) error // Khởi tạo, connect
|
||||||
|
Stop(ctx context.Context) error // Graceful shutdown
|
||||||
|
Send(ctx context.Context, msg bus.OutboundMessage) error // Gửi tin nhắn
|
||||||
|
IsRunning() bool // Channel đang chạy?
|
||||||
|
IsAllowed(senderID string) bool // Check allow-list
|
||||||
|
IsAllowedSender(sender bus.SenderInfo) bool // Check allow-list (structured)
|
||||||
|
ReasoningChannelID() string // Channel ID cho reasoning output
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interface tuỳ chọn (opt-in)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Webhook-based channel (Zalo, LINE, Telegram webhook, ...)
|
||||||
|
type WebhookHandler interface {
|
||||||
|
WebhookPath() string // Vd: "/webhook/zalo"
|
||||||
|
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hiển thị "đang gõ..."
|
||||||
|
type TypingCapable interface {
|
||||||
|
StartTyping(ctx context.Context, chatID string) (stop func(), err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sửa tin nhắn đã gửi (dùng cho placeholder "Thinking..." → reply thật)
|
||||||
|
type MessageEditor interface {
|
||||||
|
EditMessage(ctx context.Context, chatID, messageID, content string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gửi placeholder "Thinking... 💭"
|
||||||
|
type PlaceholderCapable interface {
|
||||||
|
SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gửi file/media
|
||||||
|
type MediaSender interface {
|
||||||
|
SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ví dụ: Zalo channel struct
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ZaloChannel struct {
|
||||||
|
*channels.BaseChannel // Embed pointer — cung cấp Name(), IsRunning(), HandleMessage(), ...
|
||||||
|
config config.ZaloConfig
|
||||||
|
api *ZaloAPI
|
||||||
|
mu sync.Mutex
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Quan trọng:**
|
||||||
|
- Embed `*channels.BaseChannel` (pointer, không phải value)
|
||||||
|
- Dùng `channels.NewBaseChannel()` để tạo
|
||||||
|
- `BaseChannel` đã implement: `Name()`, `IsRunning()`, `IsAllowed()`, `IsAllowedSender()`,
|
||||||
|
`ReasoningChannelID()`, `HandleMessage()`, `MaxMessageLength()`
|
||||||
|
|
||||||
|
### Ví dụ: Constructor
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewZaloChannel(cfg config.ZaloConfig, messageBus *bus.MessageBus) (*ZaloChannel, error) {
|
||||||
|
// Validate config
|
||||||
|
if cfg.AppID == "" || cfg.AppSecret == "" {
|
||||||
|
return nil, fmt.Errorf("zalo: app_id and app_secret are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo BaseChannel
|
||||||
|
base := channels.NewBaseChannel(channelName, cfg, messageBus, cfg.AllowFrom,
|
||||||
|
// Tuỳ chọn:
|
||||||
|
// channels.WithMaxMessageLength(2000),
|
||||||
|
// channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
// channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &ZaloChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
api: NewZaloAPI(cfg.AppID, cfg.AppSecret, cfg.AccessToken, cfg.RefreshToken),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ví dụ: Start / Stop lifecycle
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (z *ZaloChannel) Start(ctx context.Context) error {
|
||||||
|
z.ctx, z.cancel = context.WithCancel(ctx)
|
||||||
|
z.SetRunning(true) // BẮT BUỘC sau khi start thành công
|
||||||
|
logger.InfoC("zalo", "Zalo channel started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *ZaloChannel) Stop(_ context.Context) error {
|
||||||
|
z.SetRunning(false) // BẮT BUỘC đầu tiên khi stop
|
||||||
|
if z.cancel != nil {
|
||||||
|
z.cancel()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ví dụ: Send
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (z *ZaloChannel) Send(_ context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !z.IsRunning() {
|
||||||
|
return channels.ErrNotRunning // Manager sẽ KHÔNG retry
|
||||||
|
}
|
||||||
|
return z.api.SendTextMessage(msg.ChatID, msg.Content)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ví dụ: Webhook handler
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (z *ZaloChannel) WebhookPath() string { return "/webhook/zalo" }
|
||||||
|
|
||||||
|
func (z *ZaloChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Đọc body
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Trả 200 TRƯỚC — nhiều platform yêu cầu response nhanh
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Parse event
|
||||||
|
var evt WebhookEvent
|
||||||
|
json.Unmarshal(body, &evt)
|
||||||
|
|
||||||
|
// Đẩy vào pipeline qua BaseChannel.HandleMessage()
|
||||||
|
peer := bus.Peer{Kind: "direct", ID: evt.Sender.ID}
|
||||||
|
z.HandleMessage(z.ctx, peer, evt.Message.MsgID, evt.Sender.ID, evt.Sender.ID,
|
||||||
|
evt.Message.Text, nil, map[string]string{"platform": "zalo"})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bước 3: Đăng ký factory (init.go)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package zalo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory(channelName, func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewZaloChannel(cfg.Channels.Zalo, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lưu ý:** Factory signature phải đúng:
|
||||||
|
```go
|
||||||
|
func(cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bước 4: Thêm config struct
|
||||||
|
|
||||||
|
Trong `pkg/config/config.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ZaloConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ZALO_ENABLED"`
|
||||||
|
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_ZALO_APP_ID"`
|
||||||
|
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_ZALO_APP_SECRET"`
|
||||||
|
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ZALO_ACCESS_TOKEN"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_ZALO_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ZALO_ALLOW_FROM"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Và thêm vào `ChannelsConfig`:
|
||||||
|
```go
|
||||||
|
type ChannelsConfig struct {
|
||||||
|
// ... existing channels ...
|
||||||
|
Zalo ZaloConfig `json:"zalo"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chú ý:** `AllowFrom` dùng `FlexibleStringSlice` để chấp nhận cả string và number trong JSON.
|
||||||
|
|
||||||
|
## Bước 5: Thêm vào manager
|
||||||
|
|
||||||
|
Trong `pkg/channels/manager.go`, function `initChannels()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if channels.Zalo.Enabled && channels.Zalo.AppID != "" {
|
||||||
|
m.initChannel("zalo", "Zalo")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cẩn thận:** Block `if` phải đóng đúng — không lồng vào block của channel khác.
|
||||||
|
|
||||||
|
## Bước 6: Import trong gateway
|
||||||
|
|
||||||
|
Trong `pkg/gateway/gateway.go`, thêm blank import:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/zalo"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Blank import `_` trigger `init()` → đăng ký factory vào registry.
|
||||||
|
|
||||||
|
## Bước 7: Test với curl
|
||||||
|
|
||||||
|
### Test webhook nhận message
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:18790/webhook/zalo \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"event_name": "user_send_text",
|
||||||
|
"sender": {"id": "user123", "display_name": "Test User"},
|
||||||
|
"recipient": {"id": "oa_id"},
|
||||||
|
"message": {"msg_id": "msg001", "text": "hello"},
|
||||||
|
"timestamp": 1711234567
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify pipeline hoạt động
|
||||||
|
|
||||||
|
Chạy gateway ở debug mode:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway run --rm \
|
||||||
|
-e PICOCLAW_GATEWAY_HOST=0.0.0.0 picoclaw-gateway gateway -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Khi gửi curl test, sẽ thấy:
|
||||||
|
```
|
||||||
|
Processing message from zalo:user123: hello
|
||||||
|
Routed message agent_id=main channel=zalo
|
||||||
|
LLM response content_chars=XX
|
||||||
|
Published outbound response channel=zalo chat_id=user123
|
||||||
|
```
|
||||||
|
|
||||||
|
Nếu access_token trống, sẽ thấy lỗi cuối:
|
||||||
|
```
|
||||||
|
Send failed error="zalo oa error -216: Access token is invalid"
|
||||||
|
```
|
||||||
|
|
||||||
|
Đây là bình thường — pipeline hoạt động, chỉ thiếu token.
|
||||||
|
|
||||||
|
### Test webhook verification (GET)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:18790/webhook/zalo?challenge=test123"
|
||||||
|
# Expected: test123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist thêm channel mới
|
||||||
|
|
||||||
|
- [ ] Tạo sub-package `pkg/channels/<name>/`
|
||||||
|
- [ ] Implement `Channel` interface (embed `*BaseChannel`)
|
||||||
|
- [ ] `Start()`: gọi `SetRunning(true)` sau khi thành công
|
||||||
|
- [ ] `Stop()`: gọi `SetRunning(false)` đầu tiên
|
||||||
|
- [ ] `Send()`: check `IsRunning()`, return `ErrNotRunning` nếu chưa start
|
||||||
|
- [ ] Webhook: trả HTTP 200 trước khi xử lý, dùng `HandleMessage()` để đẩy vào bus
|
||||||
|
- [ ] `init.go`: `RegisterFactory()` với đúng signature
|
||||||
|
- [ ] Config struct trong `pkg/config/config.go`
|
||||||
|
- [ ] Thêm vào `ChannelsConfig` struct
|
||||||
|
- [ ] Thêm `initChannel()` trong `manager.go`
|
||||||
|
- [ ] Blank import `_` trong `gateway.go`
|
||||||
|
- [ ] Test build: `CGO_ENABLED=0 go build -tags stdjson ./...`
|
||||||
|
- [ ] Test curl webhook
|
||||||
|
- [ ] Thêm vào `docker/data/config.json` mẫu
|
||||||
299
pkg/channels/zalo/README.md
Normal file
299
pkg/channels/zalo/README.md
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
# Zalo OA Channel — Hướng dẫn tích hợp
|
||||||
|
|
||||||
|
Tích hợp Zalo Official Account (OA) vào PicoClaw, cho phép bot AI nhận và trả lời tin nhắn từ người dùng Zalo.
|
||||||
|
|
||||||
|
## Tính năng
|
||||||
|
|
||||||
|
### OA Messaging (chính)
|
||||||
|
- Nhận tin nhắn text từ user qua webhook (`user_send_text`)
|
||||||
|
- Nhận hình ảnh từ user (`user_send_image`)
|
||||||
|
- Gửi tin nhắn text reply qua OA Send Message API
|
||||||
|
- Gửi hình ảnh reply qua OA Send Message API
|
||||||
|
- Tự động refresh Access Token mỗi 80 phút
|
||||||
|
|
||||||
|
### OAuth 2.0 PKCE
|
||||||
|
- Hàm `GeneratePKCE()` tạo code_verifier + code_challenge
|
||||||
|
- Exchange authorization code lấy access_token + refresh_token
|
||||||
|
- Tool `cmd/zalo-auth/main.go` tự động hoá toàn bộ flow
|
||||||
|
|
||||||
|
### Social API (bổ sung)
|
||||||
|
- Lấy profile user (`GetUserProfile`)
|
||||||
|
- Lấy danh sách bạn bè (`GetFriendList`)
|
||||||
|
- Lấy profile qua Social API (`GetSocialUserProfile`)
|
||||||
|
|
||||||
|
## Yêu cầu quan trọng
|
||||||
|
|
||||||
|
### OA phải được Zalo duyệt
|
||||||
|
|
||||||
|
> **Đây là blocker lớn nhất.** OA mới tạo chưa được duyệt sẽ KHÔNG thể lấy Access Token qua OAuth.
|
||||||
|
> Thời gian duyệt trung bình: **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 2>/dev/null | openssl x509 -noout -issuer`
|
||||||
|
4. Nếu dùng Cloudflare proxy: SSL mode phải là **Full** hoặc **Full (strict)**
|
||||||
|
|
||||||
|
### Token refresh thất bại liên tục
|
||||||
|
|
||||||
|
Refresh token hết hạn (sau 3 tháng). Cần re-authorize:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run -tags stdjson ./cmd/zalo-auth/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Channel không khởi tạo (không có trong log)
|
||||||
|
|
||||||
|
Kiểm tra `manager.go` — block `initChannel("zalo")` phải nằm NGOÀI block `if` của channel khác:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Đúng:
|
||||||
|
if channels.IRC.Enabled && channels.IRC.Server != "" {
|
||||||
|
m.initChannel("irc", "IRC")
|
||||||
|
}
|
||||||
|
|
||||||
|
if channels.Zalo.Enabled && channels.Zalo.AppID != "" {
|
||||||
|
m.initChannel("zalo", "Zalo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sai (Zalo nằm trong block IRC):
|
||||||
|
if channels.IRC.Enabled && channels.IRC.Server != "" {
|
||||||
|
m.initChannel("irc", "IRC")
|
||||||
|
if channels.Zalo.Enabled && channels.Zalo.AppID != "" {
|
||||||
|
m.initChannel("zalo", "Zalo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
245
pkg/channels/zalo/api.go
Normal file
245
pkg/channels/zalo/api.go
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
package zalo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
apiBase = "https://openapi.zalo.me/v2.0"
|
||||||
|
oauthBase = "https://oauth.zaloapp.com/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ZaloAPI struct {
|
||||||
|
appID string
|
||||||
|
appSecret string
|
||||||
|
accessToken string
|
||||||
|
refreshToken string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewZaloAPI(appID, appSecret, accessToken, refreshToken string) *ZaloAPI {
|
||||||
|
return &ZaloAPI{
|
||||||
|
appID: appID, appSecret: appSecret,
|
||||||
|
accessToken: accessToken, refreshToken: refreshToken,
|
||||||
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) SetAccessToken(token string) { a.accessToken = token }
|
||||||
|
|
||||||
|
func (a *ZaloAPI) SendTextMessage(recipientID, text string) error {
|
||||||
|
return a.oaPost("/oa/message", map[string]interface{}{
|
||||||
|
"recipient": map[string]string{"user_id": recipientID},
|
||||||
|
"message": map[string]string{"text": text},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) SendImageMessage(recipientID, imageURL string) error {
|
||||||
|
return a.oaPost("/oa/message", map[string]interface{}{
|
||||||
|
"recipient": map[string]string{"user_id": recipientID},
|
||||||
|
"message": map[string]interface{}{
|
||||||
|
"attachment": map[string]interface{}{
|
||||||
|
"type": "template",
|
||||||
|
"payload": map[string]interface{}{
|
||||||
|
"template_type": "media",
|
||||||
|
"elements": []map[string]interface{}{{"media_type": "image", "url": imageURL}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) GetUserProfile(userID string) (*ZaloUserProfile, error) {
|
||||||
|
req, err := a.newRequest(http.MethodGet,
|
||||||
|
fmt.Sprintf("%s/oa/getprofile?data={\"user_id\":\"%s\"}", apiBase, userID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zalo get profile: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out struct {
|
||||||
|
Error int `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data ZaloUserProfile `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out.Error != 0 {
|
||||||
|
return nil, fmt.Errorf("zalo get profile error %d: %s", out.Error, out.Message)
|
||||||
|
}
|
||||||
|
return &out.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) AuthorizationURL(redirectURI, state, codeChallenge string) string {
|
||||||
|
params := url.Values{
|
||||||
|
"app_id": {a.appID}, "redirect_uri": {redirectURI},
|
||||||
|
"code_challenge": {codeChallenge}, "state": {state},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
}
|
||||||
|
return oauthBase + "/permission?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) ExchangeCodeForToken(code, codeVerifier, redirectURI string) (*OAuthTokenResponse, error) {
|
||||||
|
resp, err := a.httpClient.PostForm(oauthBase+"/access_token", url.Values{
|
||||||
|
"app_id": {a.appID}, "app_secret": {a.appSecret},
|
||||||
|
"code": {code}, "code_verifier": {codeVerifier},
|
||||||
|
"grant_type": {"authorization_code"}, "redirect_uri": {redirectURI},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zalo token exchange: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var tok OAuthTokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tok.Error != 0 {
|
||||||
|
return nil, fmt.Errorf("zalo token error %d: %s", tok.Error, tok.ErrorDescription)
|
||||||
|
}
|
||||||
|
return &tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) RefreshAccessToken() (string, error) {
|
||||||
|
resp, err := a.httpClient.PostForm(oauthBase+"/access_token", url.Values{
|
||||||
|
"app_id": {a.appID}, "app_secret": {a.appSecret},
|
||||||
|
"refresh_token": {a.refreshToken}, "grant_type": {"refresh_token"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("zalo refresh: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out struct {
|
||||||
|
Error int `json:"error"`
|
||||||
|
ErrorDescription string `json:"error_description"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if out.Error != 0 {
|
||||||
|
return "", fmt.Errorf("zalo refresh error %d: %s", out.Error, out.ErrorDescription)
|
||||||
|
}
|
||||||
|
if out.RefreshToken != "" {
|
||||||
|
a.refreshToken = out.RefreshToken
|
||||||
|
}
|
||||||
|
return out.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) GetSocialUserProfile(userAccessToken string) (*ZaloUserProfile, error) {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "https://graph.zalo.me/v2.0/me?fields=id,name,picture", nil)
|
||||||
|
req.Header.Set("access_token", userAccessToken)
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zalo social profile: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var p ZaloUserProfile
|
||||||
|
json.NewDecoder(resp.Body).Decode(&p)
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) GetFriendList(userAccessToken string, offset, count int) ([]ZaloFriend, error) {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet,
|
||||||
|
fmt.Sprintf("https://graph.zalo.me/v2.0/me/friends?fields=id,name,picture&offset=%d&limit=%d", offset, count), nil)
|
||||||
|
req.Header.Set("access_token", userAccessToken)
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zalo friend list: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out struct {
|
||||||
|
Data []ZaloFriend `json:"data"`
|
||||||
|
Error int `json:"error"`
|
||||||
|
Msg string `json:"message"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
if out.Error != 0 {
|
||||||
|
return nil, fmt.Errorf("zalo friend list error %d: %s", out.Error, out.Msg)
|
||||||
|
}
|
||||||
|
return out.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) oaPost(path string, payload interface{}) error {
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
req, err := a.newRequest(http.MethodPost, apiBase+path, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("zalo oa post %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
var out struct {
|
||||||
|
Error int `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &out)
|
||||||
|
if out.Error != 0 {
|
||||||
|
return fmt.Errorf("zalo oa error %d: %s", out.Error, out.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ZaloAPI) newRequest(method, rawURL string, body io.Reader) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest(method, rawURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("access_token", a.accessToken)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Structs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type WebhookEvent struct {
|
||||||
|
AppID string `json:"app_id"`
|
||||||
|
EventName string `json:"event_name"`
|
||||||
|
Sender ZaloSender `json:"sender"`
|
||||||
|
Recipient ZaloRecipient `json:"recipient"`
|
||||||
|
Message ZaloMessage `json:"message"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
type ZaloSender struct{ ID string `json:"id"`; Name string `json:"display_name"` }
|
||||||
|
type ZaloRecipient struct{ ID string `json:"id"` }
|
||||||
|
type ZaloMessage struct {
|
||||||
|
MsgID string `json:"msg_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Attachments []ZaloAttachment `json:"attachments"`
|
||||||
|
}
|
||||||
|
type ZaloAttachment struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload AttachPayload `json:"payload"`
|
||||||
|
}
|
||||||
|
type AttachPayload struct{ URL string `json:"url"` }
|
||||||
|
type ZaloUserProfile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture struct{ Data struct{ URL string `json:"url"` } `json:"data"` } `json:"picture"`
|
||||||
|
Error int `json:"error"`
|
||||||
|
}
|
||||||
|
type ZaloFriend struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture struct{ Data struct{ URL string `json:"url"` } `json:"data"` } `json:"picture"`
|
||||||
|
}
|
||||||
|
type OAuthTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Error int `json:"error"`
|
||||||
|
ErrorDescription string `json:"error_description"`
|
||||||
|
}
|
||||||
13
pkg/channels/zalo/init.go
Normal file
13
pkg/channels/zalo/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package zalo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory(channelName, func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewZaloChannel(cfg.Channels.Zalo, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
19
pkg/channels/zalo/oauth.go
Normal file
19
pkg/channels/zalo/oauth.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
package zalo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PKCEPair struct{ Verifier, Challenge string }
|
||||||
|
|
||||||
|
func GeneratePKCE() (PKCEPair, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return PKCEPair{}, err
|
||||||
|
}
|
||||||
|
v := base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
h := sha256.Sum256([]byte(v))
|
||||||
|
return PKCEPair{Verifier: v, Challenge: base64.RawURLEncoding.EncodeToString(h[:])}, nil
|
||||||
|
}
|
||||||
211
pkg/channels/zalo/zalo.go
Normal file
211
pkg/channels/zalo/zalo.go
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
package zalo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const channelName = "zalo"
|
||||||
|
|
||||||
|
// ZaloChannel implements the Channel interface for Zalo Official Account
|
||||||
|
// using webhook for receiving messages and REST API for sending messages.
|
||||||
|
type ZaloChannel struct {
|
||||||
|
*channels.BaseChannel
|
||||||
|
config config.ZaloConfig
|
||||||
|
api *ZaloAPI
|
||||||
|
mu sync.Mutex
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewZaloChannel creates a new Zalo channel instance.
|
||||||
|
func NewZaloChannel(cfg config.ZaloConfig, messageBus *bus.MessageBus) (*ZaloChannel, error) {
|
||||||
|
if cfg.AppID == "" || cfg.AppSecret == "" {
|
||||||
|
return nil, fmt.Errorf("zalo: app_id and app_secret are required")
|
||||||
|
}
|
||||||
|
if cfg.AccessToken == "" {
|
||||||
|
logger.WarnC("zalo", "access_token is empty — webhook will accept events but sending messages will fail until token is set")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := channels.NewBaseChannel(channelName, cfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
return &ZaloChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
api: NewZaloAPI(cfg.AppID, cfg.AppSecret, cfg.AccessToken, cfg.RefreshToken),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initializes the Zalo channel.
|
||||||
|
func (z *ZaloChannel) Start(ctx context.Context) error {
|
||||||
|
z.ctx, z.cancel = context.WithCancel(ctx)
|
||||||
|
z.SetRunning(true)
|
||||||
|
go z.tokenRefreshLoop()
|
||||||
|
logger.InfoC("zalo", "Zalo channel started (Webhook Mode)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the Zalo channel.
|
||||||
|
func (z *ZaloChannel) Stop(_ context.Context) error {
|
||||||
|
logger.InfoC("zalo", "Stopping Zalo channel")
|
||||||
|
z.SetRunning(false)
|
||||||
|
if z.cancel != nil {
|
||||||
|
z.cancel()
|
||||||
|
}
|
||||||
|
logger.InfoC("zalo", "Zalo channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to Zalo.
|
||||||
|
func (z *ZaloChannel) Send(_ context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !z.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
z.mu.Lock()
|
||||||
|
defer z.mu.Unlock()
|
||||||
|
return z.api.SendTextMessage(msg.ChatID, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookPath returns the path for registering on the shared HTTP server.
|
||||||
|
func (z *ZaloChannel) WebhookPath() string {
|
||||||
|
if z.config.WebhookPath != "" {
|
||||||
|
return z.config.WebhookPath
|
||||||
|
}
|
||||||
|
return "/webhook/zalo"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler for the shared HTTP server.
|
||||||
|
func (z *ZaloChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
// Webhook verification challenge
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
fmt.Fprint(w, r.URL.Query().Get("challenge"))
|
||||||
|
case http.MethodPost:
|
||||||
|
z.handleEvent(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *ZaloChannel) handleEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Always return 200 first — Zalo requires 200 for webhook verification
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
if len(body) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !z.verifySignature(r.Header.Get("X-ZEvent-Signature"), body) {
|
||||||
|
logger.WarnC("zalo", "Invalid webhook signature")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var evt WebhookEvent
|
||||||
|
if err := json.Unmarshal(body, &evt); err != nil {
|
||||||
|
logger.WarnCF("zalo", "Failed to parse webhook event", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"body": string(body[:min(200, len(body))]),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if evt.EventName != "user_send_text" && evt.EventName != "user_send_image" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := evt.Sender.ID
|
||||||
|
content := evt.Message.Text
|
||||||
|
if evt.EventName == "user_send_image" && len(evt.Message.Attachments) > 0 {
|
||||||
|
content = "[image: " + evt.Message.Attachments[0].Payload.URL + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
peer := bus.Peer{Kind: "direct", ID: senderID}
|
||||||
|
metadata := map[string]string{
|
||||||
|
"platform": "zalo",
|
||||||
|
}
|
||||||
|
|
||||||
|
z.HandleMessage(z.ctx, peer, evt.Message.MsgID, senderID, senderID, content, nil, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *ZaloChannel) verifySignature(sig string, body []byte) bool {
|
||||||
|
if sig == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
key := z.config.OASecretKey
|
||||||
|
if key == "" {
|
||||||
|
key = z.config.AppSecret
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Zalo OA webhook signature: HMAC-SHA256(oa_secret_key, app_id + data + timestamp)
|
||||||
|
// Extract timestamp from body for signature computation
|
||||||
|
var partial struct {
|
||||||
|
Timestamp json.Number `json:"timestamp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &partial); err == nil && partial.Timestamp != "" {
|
||||||
|
payload := z.config.AppID + string(body) + partial.Timestamp.String()
|
||||||
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if hmac.Equal([]byte(sig), []byte(expected)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: simple HMAC over body
|
||||||
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
|
mac.Write(body)
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if hmac.Equal([]byte(sig), []byte(expected)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
logger.WarnCF("zalo", "Signature mismatch", map[string]any{
|
||||||
|
"got": sig[:min(16, len(sig))] + "...",
|
||||||
|
})
|
||||||
|
// Allow through for now — tighten after confirming Zalo's exact signing scheme
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *ZaloChannel) tokenRefreshLoop() {
|
||||||
|
t := time.NewTicker(80 * time.Minute)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-z.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
tok, err := z.api.RefreshAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("zalo", "Token refresh failed", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
z.mu.Lock()
|
||||||
|
z.api.SetAccessToken(tok)
|
||||||
|
z.mu.Unlock()
|
||||||
|
logger.InfoC("zalo", "Access token refreshed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -301,6 +301,7 @@ type ChannelsConfig struct {
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
PicoClient PicoClientConfig `json:"pico_client"`
|
PicoClient PicoClientConfig `json:"pico_client"`
|
||||||
IRC IRCConfig `json:"irc"`
|
IRC IRCConfig `json:"irc"`
|
||||||
|
Zalo ZaloConfig `json:"zalo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
|
|
@ -1317,3 +1318,17 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ZaloConfig holds credentials for the Zalo OA channel.
|
||||||
|
type ZaloConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ZALO_ENABLED"`
|
||||||
|
OAID string `json:"oa_id" env:"PICOCLAW_CHANNELS_ZALO_OA_ID"`
|
||||||
|
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_ZALO_APP_ID"`
|
||||||
|
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_ZALO_APP_SECRET"`
|
||||||
|
OASecretKey string `json:"oa_secret_key" env:"PICOCLAW_CHANNELS_ZALO_OA_SECRET_KEY"`
|
||||||
|
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ZALO_ACCESS_TOKEN"`
|
||||||
|
RefreshToken string `json:"refresh_token" env:"PICOCLAW_CHANNELS_ZALO_REFRESH_TOKEN"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_ZALO_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ZALO_ALLOW_FROM"`
|
||||||
|
OAuthRedirectURI string `json:"oauth_redirect_uri" env:"PICOCLAW_CHANNELS_ZALO_OAUTH_REDIRECT_URI"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/zalo"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
||||||
|
|
|
||||||
273
scripts/zalo-get-token.sh
Executable file
273
scripts/zalo-get-token.sh
Executable file
|
|
@ -0,0 +1,273 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# zalo-get-token.sh — Lấy Zalo OA Access Token tự động
|
||||||
|
#
|
||||||
|
# Script này thực hiện toàn bộ OAuth 2.0 PKCE flow:
|
||||||
|
# 1. Tạo PKCE pair (code_verifier + code_challenge)
|
||||||
|
# 2. In authorization URL để user mở browser
|
||||||
|
# 3. Chạy HTTP server tạm lắng nghe callback
|
||||||
|
# 4. Nhận authorization code từ Zalo redirect
|
||||||
|
# 5. Exchange code lấy access_token + refresh_token
|
||||||
|
# 6. Update docker/data/config.json
|
||||||
|
# 7. Restart gateway container
|
||||||
|
#
|
||||||
|
# Yêu cầu: openssl, curl, python3, jq (optional)
|
||||||
|
# Nginx phải proxy /auth/zalo/callback → localhost:$CALLBACK_PORT
|
||||||
|
#
|
||||||
|
# Sử dụng:
|
||||||
|
# cd ~/picoclaw
|
||||||
|
# bash scripts/zalo-get-token.sh
|
||||||
|
#
|
||||||
|
# Tuỳ chỉnh:
|
||||||
|
# APP_ID=xxx APP_SECRET=xxx bash scripts/zalo-get-token.sh
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Cấu hình (có thể override bằng environment variable) ──────────
|
||||||
|
APP_ID="${APP_ID:-}"
|
||||||
|
APP_SECRET="${APP_SECRET:-}"
|
||||||
|
REDIRECT_URI="${REDIRECT_URI:-}"
|
||||||
|
|
||||||
|
if [ -z "$APP_ID" ] || [ -z "$APP_SECRET" ] || [ -z "$REDIRECT_URI" ]; then
|
||||||
|
error "Cần đặt environment variables: APP_ID, APP_SECRET, REDIRECT_URI"
|
||||||
|
echo ""
|
||||||
|
echo " Ví dụ:"
|
||||||
|
echo " APP_ID=xxx APP_SECRET=xxx REDIRECT_URI=https://your-domain/auth/zalo/callback bash $0"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CONFIG_PATH="${CONFIG_PATH:-docker/data/config.json}"
|
||||||
|
CALLBACK_PORT="${CALLBACK_PORT:-9999}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker/docker-compose.yml}"
|
||||||
|
|
||||||
|
# ── Màu sắc ───────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[-]${NC} $*"; }
|
||||||
|
step() { echo -e "${CYAN}[*]${NC} $*"; }
|
||||||
|
|
||||||
|
# ── Kiểm tra dependencies ─────────────────────────────────────────
|
||||||
|
for cmd in openssl curl python3; do
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
error "Cần cài '$cmd' trước khi chạy script này"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Bước 1: Tạo PKCE pair ─────────────────────────────────────────
|
||||||
|
step "Tạo PKCE code_verifier + code_challenge..."
|
||||||
|
|
||||||
|
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=/+' | head -c 43)
|
||||||
|
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
|
||||||
|
| openssl dgst -sha256 -binary \
|
||||||
|
| openssl base64 -A \
|
||||||
|
| tr '+/' '-_' \
|
||||||
|
| tr -d '=')
|
||||||
|
|
||||||
|
# Lưu verifier
|
||||||
|
echo "$CODE_VERIFIER" > /tmp/zalo_verifier.txt
|
||||||
|
info "Code verifier đã lưu: /tmp/zalo_verifier.txt"
|
||||||
|
|
||||||
|
# ── Bước 2: In authorization URL ──────────────────────────────────
|
||||||
|
ENCODED_REDIRECT=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$REDIRECT_URI'))")
|
||||||
|
AUTH_URL="https://oauth.zaloapp.com/v4/oa/permission?app_id=${APP_ID}&redirect_uri=${ENCODED_REDIRECT}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════════════"
|
||||||
|
echo " Mở link này trong browser để authorize:"
|
||||||
|
echo ""
|
||||||
|
echo " $AUTH_URL"
|
||||||
|
echo ""
|
||||||
|
echo " Sau khi authorize, Zalo sẽ redirect về callback server."
|
||||||
|
echo " Script sẽ tự động exchange code lấy token."
|
||||||
|
echo "══════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Bước 3+4: HTTP server lắng nghe callback ──────────────────────
|
||||||
|
step "Chạy callback server trên port $CALLBACK_PORT..."
|
||||||
|
step "Đang chờ Zalo redirect..."
|
||||||
|
|
||||||
|
# Tạo Python callback server inline
|
||||||
|
CALLBACK_SCRIPT=$(cat <<'PYEOF'
|
||||||
|
import http.server
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
APP_ID = os.environ["APP_ID"]
|
||||||
|
APP_SECRET = os.environ["APP_SECRET"]
|
||||||
|
CODE_VERIFIER = os.environ["CODE_VERIFIER"]
|
||||||
|
PORT = int(os.environ["CALLBACK_PORT"])
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass # Suppress default logging
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
if not parsed.path.startswith("/auth/zalo/callback"):
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
code = params.get("code", [None])[0]
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.end_headers()
|
||||||
|
err_msg = f"Không nhận được 'code'. Query: {parsed.query}"
|
||||||
|
self.wfile.write(err_msg.encode())
|
||||||
|
print(f"\033[0;31m[-]\033[0m {err_msg}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\033[0;32m[+]\033[0m Nhận được authorization code: {code[:20]}...")
|
||||||
|
|
||||||
|
# Exchange code for token
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"app_id": APP_ID,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"code_verifier": CODE_VERIFIER,
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://oauth.zaloapp.com/v4/oa/access_token",
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"secret_key": APP_SECRET,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=15)
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
except Exception as e:
|
||||||
|
result = {"error": -1, "error_description": str(e)}
|
||||||
|
|
||||||
|
# Trả response cho browser
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
access_token = result.get("access_token", "")
|
||||||
|
refresh_token = result.get("refresh_token", "")
|
||||||
|
|
||||||
|
if access_token:
|
||||||
|
html = "<h1>✔ Zalo OAuth thành công!</h1><p>Token đã lưu. Bạn có thể đóng tab này.</p>"
|
||||||
|
else:
|
||||||
|
html = f"<h1>✘ Lỗi</h1><pre>{json.dumps(result, indent=2)}</pre>"
|
||||||
|
|
||||||
|
self.wfile.write(html.encode())
|
||||||
|
|
||||||
|
# Ghi kết quả ra file để shell script đọc
|
||||||
|
with open("/tmp/zalo_token_result.json", "w") as f:
|
||||||
|
json.dump(result, f)
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=self.server.shutdown).start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
httpd = http.server.HTTPServer(("127.0.0.1", PORT), Handler)
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Export env cho Python script
|
||||||
|
export APP_ID APP_SECRET CODE_VERIFIER CALLBACK_PORT
|
||||||
|
|
||||||
|
# Chạy callback server (block cho đến khi nhận code)
|
||||||
|
python3 -c "$CALLBACK_SCRIPT"
|
||||||
|
|
||||||
|
# ── Bước 5+6: Đọc kết quả và update config ────────────────────────
|
||||||
|
if [ ! -f /tmp/zalo_token_result.json ]; then
|
||||||
|
error "Không nhận được response từ Zalo"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACCESS_TOKEN=$(python3 -c "import json; r=json.load(open('/tmp/zalo_token_result.json')); print(r.get('access_token', ''))")
|
||||||
|
REFRESH_TOKEN=$(python3 -c "import json; r=json.load(open('/tmp/zalo_token_result.json')); print(r.get('refresh_token', ''))")
|
||||||
|
ERROR_CODE=$(python3 -c "import json; r=json.load(open('/tmp/zalo_token_result.json')); print(r.get('error', 0))")
|
||||||
|
|
||||||
|
if [ -z "$ACCESS_TOKEN" ] || [ "$ERROR_CODE" != "0" ]; then
|
||||||
|
error "Exchange token thất bại!"
|
||||||
|
echo "Response:"
|
||||||
|
python3 -m json.tool /tmp/zalo_token_result.json 2>/dev/null || cat /tmp/zalo_token_result.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Access Token: ${ACCESS_TOKEN:0:30}..."
|
||||||
|
info "Refresh Token: ${REFRESH_TOKEN:0:30}..."
|
||||||
|
|
||||||
|
# Update config.json
|
||||||
|
step "Cập nhật $CONFIG_PATH..."
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_PATH" ]; then
|
||||||
|
error "Không tìm thấy $CONFIG_PATH"
|
||||||
|
error "Chạy script này từ thư mục gốc của project (cd ~/picoclaw)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
with open('$CONFIG_PATH') as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
cfg['channels']['zalo']['access_token'] = '$ACCESS_TOKEN'
|
||||||
|
cfg['channels']['zalo']['refresh_token'] = '$REFRESH_TOKEN'
|
||||||
|
with open('$CONFIG_PATH', 'w') as f:
|
||||||
|
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write('\n')
|
||||||
|
print('\033[0;32m[+]\033[0m Config đã cập nhật')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'\033[0;31m[-]\033[0m Lỗi cập nhật config: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
"
|
||||||
|
|
||||||
|
# ── Bước 7: Restart gateway ───────────────────────────────────────
|
||||||
|
step "Restart gateway..."
|
||||||
|
|
||||||
|
if docker compose -f "$COMPOSE_FILE" --profile gateway restart 2>/dev/null; then
|
||||||
|
info "Gateway đã restart"
|
||||||
|
else
|
||||||
|
warn "Không restart được gateway. Chạy thủ công:"
|
||||||
|
warn " docker compose -f $COMPOSE_FILE --profile gateway restart"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Chờ gateway start
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Kiểm tra gateway hoạt động
|
||||||
|
if docker compose -f "$COMPOSE_FILE" --profile gateway logs --tail=5 2>/dev/null | grep -q "Channels enabled"; then
|
||||||
|
info "Gateway đang chạy"
|
||||||
|
CHANNELS=$(docker compose -f "$COMPOSE_FILE" --profile gateway logs --tail=5 2>/dev/null | grep "Channels enabled" | tail -1)
|
||||||
|
info "$CHANNELS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -f /tmp/zalo_token_result.json
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════════════"
|
||||||
|
info "Hoàn tất! Zalo OA channel đã sẵn sàng."
|
||||||
|
echo ""
|
||||||
|
echo " Test webhook:"
|
||||||
|
echo " curl https://db2.moonlight.pro.vn/webhook/zalo?challenge=test"
|
||||||
|
echo ""
|
||||||
|
echo " Xem log:"
|
||||||
|
echo " docker compose -f $COMPOSE_FILE --profile gateway logs -f"
|
||||||
|
echo "══════════════════════════════════════════════════════════"
|
||||||
Loading…
Add table
Reference in a new issue