fix whatsapp

This commit is contained in:
lpf
2026-03-09 19:35:12 +08:00
parent de77e6c786
commit 0b9192132f
25 changed files with 2311 additions and 139 deletions

View File

@@ -28,10 +28,12 @@ import (
"sync"
"time"
"clawgo/pkg/channels"
cfgpkg "clawgo/pkg/config"
"clawgo/pkg/nodes"
"clawgo/pkg/tools"
"github.com/gorilla/websocket"
"rsc.io/qr"
)
type Server struct {
@@ -409,6 +411,9 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("/webui/api/chat/live", s.handleWebUIChatLive)
mux.HandleFunc("/webui/api/runtime", s.handleWebUIRuntime)
mux.HandleFunc("/webui/api/version", s.handleWebUIVersion)
mux.HandleFunc("/webui/api/whatsapp/status", s.handleWebUIWhatsAppStatus)
mux.HandleFunc("/webui/api/whatsapp/logout", s.handleWebUIWhatsAppLogout)
mux.HandleFunc("/webui/api/whatsapp/qr.svg", s.handleWebUIWhatsAppQR)
mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload)
mux.HandleFunc("/webui/api/nodes", s.handleWebUINodes)
mux.HandleFunc("/webui/api/node_dispatches", s.handleWebUINodeDispatches)
@@ -1141,6 +1146,197 @@ func (s *Server) handleWebUIVersion(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleWebUIWhatsAppStatus(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
payload, code := s.webUIWhatsAppStatusPayload(r.Context())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(payload)
}
func (s *Server) handleWebUIWhatsAppLogout(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
waCfg, err := s.loadWhatsAppConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logoutURL, err := channels.BridgeLogoutURL(strings.TrimSpace(waCfg.BridgeURL))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, logoutURL, nil)
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
return
}
}
func (s *Server) handleWebUIWhatsAppQR(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
payload, code := s.webUIWhatsAppStatusPayload(r.Context())
status, _ := payload["status"].(map[string]interface{})
qrCode := ""
if status != nil {
qrCode, _ = status["qr_code"].(string)
}
if code != http.StatusOK || strings.TrimSpace(qrCode) == "" {
http.Error(w, "qr unavailable", http.StatusNotFound)
return
}
qrCode = strings.TrimSpace(qrCode)
qrImage, err := qr.Encode(qrCode, qr.M)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = io.WriteString(w, renderQRCodeSVG(qrImage, 8, 24))
}
func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]interface{}, int) {
waCfg, err := s.loadWhatsAppConfig()
if err != nil {
return map[string]interface{}{
"ok": false,
"error": err.Error(),
}, http.StatusInternalServerError
}
bridgeURL := strings.TrimSpace(waCfg.BridgeURL)
statusURL, err := channels.BridgeStatusURL(bridgeURL)
if err != nil {
return map[string]interface{}{
"ok": false,
"enabled": waCfg.Enabled,
"bridge_url": bridgeURL,
"error": err.Error(),
}, http.StatusBadRequest
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
resp, err := (&http.Client{Timeout: 8 * time.Second}).Do(req)
if err != nil {
return map[string]interface{}{
"ok": false,
"enabled": waCfg.Enabled,
"bridge_url": bridgeURL,
"bridge_running": false,
"error": err.Error(),
}, http.StatusOK
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return map[string]interface{}{
"ok": false,
"enabled": waCfg.Enabled,
"bridge_url": bridgeURL,
"bridge_running": false,
"error": strings.TrimSpace(string(body)),
}, http.StatusOK
}
var status channels.WhatsAppBridgeStatus
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
return map[string]interface{}{
"ok": false,
"enabled": waCfg.Enabled,
"bridge_url": bridgeURL,
"bridge_running": false,
"error": err.Error(),
}, http.StatusOK
}
return map[string]interface{}{
"ok": true,
"enabled": waCfg.Enabled,
"bridge_url": bridgeURL,
"bridge_running": true,
"status": map[string]interface{}{
"state": status.State,
"connected": status.Connected,
"logged_in": status.LoggedIn,
"bridge_addr": status.BridgeAddr,
"user_jid": status.UserJID,
"push_name": status.PushName,
"platform": status.Platform,
"qr_available": status.QRAvailable,
"qr_code": status.QRCode,
"last_event": status.LastEvent,
"last_error": status.LastError,
"updated_at": status.UpdatedAt,
},
}, http.StatusOK
}
func (s *Server) loadWhatsAppConfig() (cfgpkg.WhatsAppConfig, error) {
configPath := strings.TrimSpace(s.configPath)
if configPath == "" {
configPath = filepath.Join(cfgpkg.GetConfigDir(), "config.json")
}
cfg, err := cfgpkg.LoadConfig(configPath)
if err != nil {
return cfgpkg.WhatsAppConfig{}, err
}
return cfg.Channels.WhatsApp, nil
}
func renderQRCodeSVG(code *qr.Code, scale, quietZone int) string {
if code == nil || code.Size <= 0 {
return ""
}
if scale <= 0 {
scale = 8
}
if quietZone < 0 {
quietZone = 0
}
total := (code.Size + quietZone*2) * scale
var b strings.Builder
b.Grow(total * 8)
b.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, total, total))
b.WriteString(fmt.Sprintf(`<rect width="%d" height="%d" fill="#ffffff"/>`, total, total))
b.WriteString(`<g fill="#111111">`)
for y := 0; y < code.Size; y++ {
for x := 0; x < code.Size; x++ {
if !code.Black(x, y) {
continue
}
rx := (x + quietZone) * scale
ry := (y + quietZone) * scale
b.WriteString(fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d"/>`, rx, ry, scale, scale))
}
}
b.WriteString(`</g></svg>`)
return b.String()
}
func (s *Server) handleWebUIRuntime(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -4126,6 +4322,7 @@ func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) {
}
sessionsDir := filepath.Join(filepath.Dir(s.workspacePath), "agents", "main", "sessions")
_ = os.MkdirAll(sessionsDir, 0755)
includeInternal := r.URL.Query().Get("include_internal") == "1"
type item struct {
Key string `json:"key"`
Channel string `json:"channel,omitempty"`
@@ -4146,6 +4343,9 @@ func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(key) == "" {
continue
}
if !includeInternal && !isUserFacingSessionKey(key) {
continue
}
if _, ok := seen[key]; ok {
continue
}
@@ -4163,6 +4363,29 @@ func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "sessions": out})
}
func isUserFacingSessionKey(key string) bool {
k := strings.ToLower(strings.TrimSpace(key))
if k == "" {
return false
}
switch {
case strings.HasPrefix(k, "subagent:"):
return false
case strings.HasPrefix(k, "internal:"):
return false
case strings.HasPrefix(k, "heartbeat:"):
return false
case strings.HasPrefix(k, "cron:"):
return false
case strings.HasPrefix(k, "hook:"):
return false
case strings.HasPrefix(k, "node:"):
return false
default:
return true
}
}
func (s *Server) handleWebUISubagentProfiles(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)

View File

@@ -20,6 +20,106 @@ import (
"github.com/gorilla/websocket"
)
func TestHandleWebUIWhatsAppStatus(t *testing.T) {
t.Parallel()
bridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/status":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"state": "connected",
"connected": true,
"logged_in": true,
"bridge_addr": "127.0.0.1:3001",
"user_jid": "8613012345678@s.whatsapp.net",
"qr_available": false,
"last_event": "connected",
"updated_at": "2026-03-09T12:00:00+08:00",
})
default:
http.NotFound(w, r)
}
}))
defer bridge.Close()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
cfg := cfgpkg.DefaultConfig()
cfg.Logging.Enabled = false
cfg.Channels.WhatsApp.Enabled = true
cfg.Channels.WhatsApp.BridgeURL = "ws" + strings.TrimPrefix(bridge.URL, "http") + "/ws"
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodGet, "/webui/api/whatsapp/status", nil)
rec := httptest.NewRecorder()
srv.handleWebUIWhatsAppStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"bridge_running":true`) {
t.Fatalf("expected bridge_running=true, got: %s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"user_jid":"8613012345678@s.whatsapp.net"`) {
t.Fatalf("expected user_jid in payload, got: %s", rec.Body.String())
}
}
func TestHandleWebUIWhatsAppQR(t *testing.T) {
t.Parallel()
bridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/status":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"state": "qr_ready",
"connected": false,
"logged_in": false,
"bridge_addr": "127.0.0.1:3001",
"qr_available": true,
"qr_code": "test-qr-code",
"last_event": "qr_ready",
"updated_at": "2026-03-09T12:00:00+08:00",
})
default:
http.NotFound(w, r)
}
}))
defer bridge.Close()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
cfg := cfgpkg.DefaultConfig()
cfg.Logging.Enabled = false
cfg.Channels.WhatsApp.Enabled = true
cfg.Channels.WhatsApp.BridgeURL = "ws" + strings.TrimPrefix(bridge.URL, "http") + "/ws"
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodGet, "/webui/api/whatsapp/qr.svg", nil)
rec := httptest.NewRecorder()
srv.handleWebUIWhatsAppQR(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") {
t.Fatalf("expected svg content-type, got %q", ct)
}
if !strings.Contains(rec.Body.String(), "<svg") {
t.Fatalf("expected svg payload, got: %s", rec.Body.String())
}
}
func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T) {
t.Parallel()
@@ -279,6 +379,56 @@ func TestHandleNodeConnectRelaysSignalMessages(t *testing.T) {
}
}
func TestHandleWebUISessionsHidesInternalSessionsByDefault(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
sessionsDir := filepath.Join(tmp, "agents", "main", "sessions")
if err := os.MkdirAll(sessionsDir, 0755); err != nil {
t.Fatalf("mkdir sessions dir: %v", err)
}
for _, name := range []string{
"review-api.jsonl",
"internal:heartbeat.jsonl",
"heartbeat:default.jsonl",
"cron:nightly.jsonl",
"subagent:worker.jsonl",
} {
if err := os.WriteFile(filepath.Join(sessionsDir, name), []byte("{}\n"), 0644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetWorkspacePath(filepath.Join(tmp, "workspace"))
req := httptest.NewRequest(http.MethodGet, "/webui/api/sessions", nil)
rec := httptest.NewRecorder()
srv.handleWebUISessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var payload struct {
OK bool `json:"ok"`
Sessions []struct {
Key string `json:"key"`
} `json:"sessions"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
keys := make([]string, 0, len(payload.Sessions))
for _, item := range payload.Sessions {
keys = append(keys, item.Key)
}
if len(keys) != 1 || keys[0] != "review-api" {
t.Fatalf("unexpected sessions: %v", keys)
}
}
func TestHandleWebUISubagentsRuntimeLive(t *testing.T) {
t.Parallel()