mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-06 18:07:28 +08:00
fix whatsapp
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user