From c7b159d2edc58094dc99ea085ea0cbcf7810a023 Mon Sep 17 00:00:00 2001 From: lpf Date: Tue, 10 Mar 2026 12:07:56 +0800 Subject: [PATCH] refine whatsapp bridge defaults and webui polish --- Dockerfile | 2 +- Makefile | 13 +- cmd/cmd_gateway.go | 4 +- pkg/api/server.go | 75 +++- pkg/api/server_test.go | 73 ++++ pkg/channels/whatsapp_bridge.go | 39 +- pkg/channels/whatsapp_bridge_test.go | 27 ++ pkg/config/config.go | 2 +- pkg/config/validate.go | 3 - webui/src/components/Header.tsx | 46 ++- webui/src/components/RecursiveConfig.tsx | 31 +- webui/src/i18n/index.ts | 108 +++++- webui/src/index.css | 329 +++++++++++++++- webui/src/pages/ChannelSettings.tsx | 454 +++++++++++++++++------ webui/src/pages/Chat.tsx | 87 +++-- webui/src/pages/Config.tsx | 71 ++-- webui/src/pages/Cron.tsx | 18 +- webui/src/pages/Dashboard.tsx | 51 +-- webui/src/pages/EKG.tsx | 54 +-- webui/src/pages/LogCodes.tsx | 20 +- webui/src/pages/Logs.tsx | 52 +-- webui/src/pages/MCP.tsx | 12 +- webui/src/pages/Memory.tsx | 54 ++- webui/src/pages/NodeArtifacts.tsx | 50 +-- webui/src/pages/Nodes.tsx | 12 +- webui/src/pages/Skills.tsx | 18 +- webui/src/pages/SubagentProfiles.tsx | 94 ++--- webui/src/pages/Subagents.tsx | 11 +- webui/src/pages/TaskAudit.tsx | 11 +- 29 files changed, 1389 insertions(+), 432 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6bce05e..40627df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.5-bookworm AS builder +FROM golang:1.25.7-bookworm AS builder WORKDIR /src diff --git a/Makefile b/Makefile index d41a6fc..5ffc631 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-linux-slim build-all build-webui package-all install install-win uninstall clean help test install-bootstrap-docs sync-embed-workspace sync-embed-workspace-base sync-embed-webui cleanup-embed-workspace test-only clean-test-artifacts dev +.PHONY: all build build-linux-slim build-all build-webui package-all install install-win uninstall clean help test test-docker install-bootstrap-docs sync-embed-workspace sync-embed-workspace-base sync-embed-webui cleanup-embed-workspace test-only clean-test-artifacts dev # Build variables BINARY_NAME=clawgo @@ -324,12 +324,15 @@ clean: @rm -rf $(BUILD_DIR) @echo "Clean complete" -## test-only: Run tests without leaving build artifacts (cleans embed workspace and test cache) -test-only: sync-embed-workspace +## test: Run Go tests without leaving build artifacts (cleans embed workspace and test cache) +test: sync-embed-workspace @echo "Running tests..." @set -e; trap '$(MAKE) cleanup-embed-workspace clean-test-artifacts' EXIT; \ $(GO) test ./... +## test-only: Backward-compatible alias for Go tests +test-only: test + ## clean-test-artifacts: Remove test caches/artifacts generated by go test clean-test-artifacts: @$(GO) clean -testcache @@ -364,8 +367,8 @@ dev: sync-embed-workspace echo " Args: $(DEV_ARGS)"; \ CLAWGO_CONFIG="$(DEV_CONFIG)" $(GO) run $(GOFLAGS) ./$(CMD_DIR) $(DEV_ARGS) -## test: Build and compile-check in Docker (Dockerfile.test) -test: sync-embed-workspace +## test-docker: Build and compile-check in Docker (Dockerfile.test) +test-docker: sync-embed-workspace @echo "Running Docker compile test..." @set -e; trap '$(MAKE) cleanup-embed-workspace' EXIT; \ docker build -f Dockerfile.test -t clawgo:test . diff --git a/cmd/cmd_gateway.go b/cmd/cmd_gateway.go index b8a4f76..3e66c65 100644 --- a/cmd/cmd_gateway.go +++ b/cmd/cmd_gateway.go @@ -447,14 +447,14 @@ func gatewayCmd() { return } - newWhatsAppBridge, _ := setupEmbeddedWhatsAppBridge(ctx, newCfg) - channelManager.StopAll(ctx) agentLoop.Stop() if whatsAppBridge != nil { whatsAppBridge.Stop() } + newWhatsAppBridge, _ := setupEmbeddedWhatsAppBridge(ctx, newCfg) + channelManager = newChannelManager agentLoop = newAgentLoop cfg = newCfg diff --git a/pkg/api/server.go b/pkg/api/server.go index e05c0f5..ffbc5b0 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -1218,12 +1218,12 @@ func (s *Server) handleWebUIWhatsAppLogout(w http.ResponseWriter, r *http.Reques http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - waCfg, err := s.loadWhatsAppConfig() + cfg, err := s.loadConfig() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - logoutURL, err := channels.BridgeLogoutURL(strings.TrimSpace(waCfg.BridgeURL)) + logoutURL, err := channels.BridgeLogoutURL(s.resolveWhatsAppBridgeURL(cfg)) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -1272,14 +1272,15 @@ func (s *Server) handleWebUIWhatsAppQR(w http.ResponseWriter, r *http.Request) { } func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]interface{}, int) { - waCfg, err := s.loadWhatsAppConfig() + cfg, err := s.loadConfig() if err != nil { return map[string]interface{}{ "ok": false, "error": err.Error(), }, http.StatusInternalServerError } - bridgeURL := strings.TrimSpace(waCfg.BridgeURL) + waCfg := cfg.Channels.WhatsApp + bridgeURL := s.resolveWhatsAppBridgeURL(cfg) statusURL, err := channels.BridgeStatusURL(bridgeURL) if err != nil { return map[string]interface{}{ @@ -1344,15 +1345,77 @@ func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]int } func (s *Server) loadWhatsAppConfig() (cfgpkg.WhatsAppConfig, error) { + cfg, err := s.loadConfig() + if err != nil { + return cfgpkg.WhatsAppConfig{}, err + } + return cfg.Channels.WhatsApp, nil +} + +func (s *Server) loadConfig() (*cfgpkg.Config, 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 nil, err } - return cfg.Channels.WhatsApp, nil + return cfg, nil +} + +func (s *Server) resolveWhatsAppBridgeURL(cfg *cfgpkg.Config) string { + if cfg == nil { + return "" + } + raw := strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL) + if raw == "" { + return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port) + } + hostPort := comparableBridgeHostPort(raw) + if hostPort == "" { + return raw + } + if hostPort == "127.0.0.1:3001" || hostPort == "localhost:3001" { + return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port) + } + if hostPort == comparableGatewayHostPort(cfg.Gateway.Host, cfg.Gateway.Port) { + return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port) + } + return raw +} + +func embeddedWhatsAppBridgeURL(host string, port int) string { + host = strings.TrimSpace(host) + switch host { + case "", "0.0.0.0", "::", "[::]": + host = "127.0.0.1" + } + return fmt.Sprintf("ws://%s:%d/whatsapp/ws", host, port) +} + +func comparableBridgeHostPort(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if !strings.Contains(raw, "://") { + return strings.ToLower(raw) + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + return strings.ToLower(strings.TrimSpace(u.Host)) +} + +func comparableGatewayHostPort(host string, port int) string { + host = strings.TrimSpace(strings.ToLower(host)) + switch host { + case "", "0.0.0.0", "::", "[::]": + host = "127.0.0.1" + } + return fmt.Sprintf("%s:%d", host, port) } func renderQRCodeSVG(code *qr.Code, scale, quietZone int) string { diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 744e5a4..a1a6ffc 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -7,10 +7,13 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -167,6 +170,76 @@ func TestHandleWebUIWhatsAppStatusWithNestedBridgePath(t *testing.T) { } } +func TestHandleWebUIWhatsAppStatusMapsLegacyBridgeURLToEmbeddedPath(t *testing.T) { + t.Parallel() + + bridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/whatsapp/status": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "state": "connected", + "connected": true, + "logged_in": true, + "bridge_addr": "127.0.0.1:7788", + "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() + + u, err := url.Parse(bridge.URL) + if err != nil { + t.Fatalf("parse bridge url: %v", err) + } + host, portRaw, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("split host port: %v", err) + } + port, err := strconv.Atoi(portRaw) + if err != nil { + t.Fatalf("atoi port: %v", err) + } + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + cfg.Gateway.Host = host + cfg.Gateway.Port = port + cfg.Channels.WhatsApp.Enabled = true + cfg.Channels.WhatsApp.BridgeURL = "ws://localhost:3001" + 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()) + } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + bridgeURL, _ := payload["bridge_url"].(string) + if !strings.HasSuffix(bridgeURL, "/whatsapp/ws") { + t.Fatalf("expected embedded whatsapp bridge url, got: %s", rec.Body.String()) + } +} + func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T) { t.Parallel() diff --git a/pkg/channels/whatsapp_bridge.go b/pkg/channels/whatsapp_bridge.go index dedab8a..5b5c5cd 100644 --- a/pkg/channels/whatsapp_bridge.go +++ b/pkg/channels/whatsapp_bridge.go @@ -450,7 +450,7 @@ func (s *WhatsAppBridgeService) ServeWS(w http.ResponseWriter, r *http.Request) func (s *WhatsAppBridgeService) wrapHandler(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if s.localOnly && !isLoopbackRequest(r) { + if s.localOnly && !isLocalRequest(r) { http.Error(w, "forbidden", http.StatusForbidden) return } @@ -954,14 +954,43 @@ func joinBridgeRoute(basePath, endpoint string) string { return basePath + "/" + strings.TrimPrefix(endpoint, "/") } -func isLoopbackRequest(r *http.Request) bool { +func isLocalRequest(r *http.Request) bool { if r == nil { return false } - host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + addrs, err := net.InterfaceAddrs() if err != nil { - host = strings.TrimSpace(r.RemoteAddr) + return false + } + return isLocalRemoteAddr(strings.TrimSpace(r.RemoteAddr), addrs) +} + +func isLocalRemoteAddr(remoteAddr string, localAddrs []net.Addr) bool { + host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddr)) + if err != nil { + host = strings.TrimSpace(remoteAddr) } ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() + if ip == nil { + return false + } + if ip.IsLoopback() { + return true + } + for _, addr := range localAddrs { + if addr == nil { + continue + } + switch v := addr.(type) { + case *net.IPNet: + if v.IP != nil && v.IP.Equal(ip) { + return true + } + case *net.IPAddr: + if v.IP != nil && v.IP.Equal(ip) { + return true + } + } + } + return false } diff --git a/pkg/channels/whatsapp_bridge_test.go b/pkg/channels/whatsapp_bridge_test.go index 7b5779d..7a8cdaf 100644 --- a/pkg/channels/whatsapp_bridge_test.go +++ b/pkg/channels/whatsapp_bridge_test.go @@ -3,6 +3,7 @@ package channels import ( "context" "encoding/json" + "net" "testing" "time" @@ -63,6 +64,32 @@ func TestBridgeStatusURLWithNestedPath(t *testing.T) { } } +func TestIsLocalRemoteAddr(t *testing.T) { + ipv4Net := &net.IPNet{IP: net.ParseIP("192.168.1.10"), Mask: net.CIDRMask(24, 32)} + ipv6Net := &net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)} + + tests := []struct { + name string + remoteAddr string + want bool + }{ + {name: "loopback", remoteAddr: "127.0.0.1:4321", want: true}, + {name: "local interface ipv4", remoteAddr: "192.168.1.10:4321", want: true}, + {name: "local interface ipv6", remoteAddr: "[fe80::1]:4321", want: true}, + {name: "non local ip", remoteAddr: "192.168.1.11:4321", want: false}, + {name: "invalid host", remoteAddr: "not-an-ip", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isLocalRemoteAddr(tt.remoteAddr, []net.Addr{ipv4Net, ipv6Net}) + if got != tt.want { + t.Fatalf("got %v want %v", got, tt.want) + } + }) + } +} + func TestNormalizeWhatsAppRecipientJID(t *testing.T) { tests := []struct { input string diff --git a/pkg/config/config.go b/pkg/config/config.go index d91aabb..44a2d76 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -528,7 +528,7 @@ func DefaultConfig() *Config { OutboundDedupeWindowSeconds: 12, WhatsApp: WhatsAppConfig{ Enabled: false, - BridgeURL: "ws://localhost:3001", + BridgeURL: "", AllowFrom: []string{}, EnableGroups: true, RequireMentionInGroups: true, diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 527f289..9c4e2c5 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -223,9 +223,6 @@ func Validate(cfg *Config) []error { if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" { errs = append(errs, fmt.Errorf("channels.discord.token is required when channels.discord.enabled=true")) } - if cfg.Channels.WhatsApp.Enabled && cfg.Channels.WhatsApp.BridgeURL == "" { - errs = append(errs, fmt.Errorf("channels.whatsapp.bridge_url is required when channels.whatsapp.enabled=true")) - } if cfg.Channels.DingTalk.Enabled { if cfg.Channels.DingTalk.ClientID == "" { errs = append(errs, fmt.Errorf("channels.dingtalk.client_id is required when channels.dingtalk.enabled=true")) diff --git a/webui/src/components/Header.tsx b/webui/src/components/Header.tsx index 9b55f32..a065a10 100644 --- a/webui/src/components/Header.tsx +++ b/webui/src/components/Header.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Terminal, Globe, Github, Menu, Moon, RefreshCw, SunMedium } from 'lucide-react'; +import { Github, Moon, RefreshCw, SunMedium, Terminal } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; @@ -12,7 +12,7 @@ function normalizeVersion(value: string) { const Header: React.FC = () => { const { t, i18n } = useTranslation(); - const { isGatewayOnline, setSidebarOpen, sidebarCollapsed, gatewayVersion, webuiVersion } = useAppContext(); + const { isGatewayOnline, sidebarCollapsed, gatewayVersion, webuiVersion } = useAppContext(); const { theme, toggleTheme, notify } = useUI(); const [checkingVersion, setCheckingVersion] = React.useState(false); @@ -56,28 +56,23 @@ const Header: React.FC = () => { }; return ( -
+
- -
-
- -
- {!sidebarCollapsed && ( - {t('appName')} - )} +
+
-
- +
+
+ {!sidebarCollapsed && ( + {t('appName')} + )} {t('appName')}
diff --git a/webui/src/components/RecursiveConfig.tsx b/webui/src/components/RecursiveConfig.tsx index 95a17a2..8ccc4aa 100644 --- a/webui/src/components/RecursiveConfig.tsx +++ b/webui/src/components/RecursiveConfig.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react'; +import { Plus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; interface RecursiveConfigProps { @@ -55,11 +56,11 @@ const PrimitiveArrayEditor: React.FC<{ return (
- {value.length === 0 && {t('empty')}} + {value.length === 0 && {t('empty')}} {value.map((item, idx) => ( - + {String(item)} - + ))}
@@ -83,9 +84,11 @@ const PrimitiveArrayEditor: React.FC<{ addValue(draft); setDraft(''); }} - className="ui-button ui-button-neutral px-3 py-2 text-xs rounded-xl" + className="ui-button ui-button-neutral ui-button-icon rounded-xl" + title={t('add')} + aria-label={t('add')} > - {t('add')} + setDraft((prev) => ({ ...prev, [field.key]: e.target.checked }))} - className="h-4 w-4 rounded border-zinc-700/60 text-indigo-500 focus:ring-indigo-500 dark:border-zinc-700" + className="ui-checkbox" /> ); } if (field.type === 'list') { return ( -
+
- + {isWhatsApp && Array.isArray(value) && value.length > 0 && ( {t('entries')}: {value.length} )}
- {helper &&
{helper}
} + {helper &&
{helper}
}