picoclaw/cmd/zalo-auth/main.go
khoand58 0835f56ccd 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>
2026-03-25 10:04:30 +00:00

208 lines
6.4 KiB
Go

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")
}
}