From 0b9192132f5348c774cf84c206db2640a223bc45 Mon Sep 17 00:00:00 2001 From: lpf Date: Mon, 9 Mar 2026 19:35:12 +0800 Subject: [PATCH] fix whatsapp --- .gitignore | 3 +- cmd/clawgo/cmd_channel.go | 271 +++++++ go.mod | 27 +- go.sum | 100 +++ pkg/agent/loop.go | 8 +- pkg/api/server.go | 223 ++++++ pkg/api/server_test.go | 150 ++++ pkg/bus/types.go | 19 +- pkg/channels/whatsapp.go | 52 ++ pkg/channels/whatsapp_bridge.go | 876 +++++++++++++++++++++++ pkg/channels/whatsapp_bridge_test.go | 199 +++++ pkg/channels/whatsapp_test.go | 39 + pkg/config/config.go | 16 +- webui/src/App.tsx | 5 +- webui/src/components/NavItem.tsx | 5 +- webui/src/components/RecursiveConfig.tsx | 18 +- webui/src/components/Sidebar.tsx | 88 ++- webui/src/i18n/index.ts | 50 ++ webui/src/index.css | 84 +++ webui/src/pages/Chat.tsx | 37 +- webui/src/pages/Cron.tsx | 18 +- webui/src/pages/MCP.tsx | 48 +- webui/src/pages/NodeArtifacts.tsx | 22 +- webui/src/pages/Nodes.tsx | 52 +- webui/src/pages/TaskAudit.tsx | 40 +- 25 files changed, 2311 insertions(+), 139 deletions(-) create mode 100644 pkg/channels/whatsapp_bridge.go create mode 100644 pkg/channels/whatsapp_bridge_test.go create mode 100644 pkg/channels/whatsapp_test.go diff --git a/.gitignore b/.gitignore index ba3d2c4..f43bd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ build /clawgo_bin /clawgo_test -.gocache \ No newline at end of file +.gocache +.vscode \ No newline at end of file diff --git a/cmd/clawgo/cmd_channel.go b/cmd/clawgo/cmd_channel.go index 98a9eb1..3a22b54 100644 --- a/cmd/clawgo/cmd_channel.go +++ b/cmd/clawgo/cmd_channel.go @@ -2,11 +2,19 @@ package main import ( "context" + "encoding/json" "fmt" + "net/http" "os" + "path/filepath" + "strings" + "time" "clawgo/pkg/bus" "clawgo/pkg/channels" + "clawgo/pkg/config" + + qrterminal "github.com/mdp/qrterminal/v3" ) func channelCmd() { @@ -20,6 +28,8 @@ func channelCmd() { switch subcommand { case "test": channelTestCmd() + case "whatsapp": + whatsAppChannelCmd() default: fmt.Printf("Unknown channel command: %s\n", subcommand) channelHelp() @@ -29,11 +39,17 @@ func channelCmd() { func channelHelp() { fmt.Println("\nChannel commands:") fmt.Println(" test Send a test message to a specific channel") + fmt.Println(" whatsapp Run and inspect the built-in WhatsApp bridge") fmt.Println() fmt.Println("Test options:") fmt.Println(" --to Recipient ID") fmt.Println(" --channel Channel name (telegram, discord, etc.)") fmt.Println(" -m, --message Message to send") + fmt.Println() + fmt.Println("WhatsApp bridge:") + fmt.Println(" clawgo channel whatsapp bridge run") + fmt.Println(" clawgo channel whatsapp bridge status") + fmt.Println(" clawgo channel whatsapp bridge logout") } func channelTestCmd() { @@ -94,3 +110,258 @@ func channelTestCmd() { fmt.Println("✓ Test message sent successfully!") } + +func whatsAppChannelCmd() { + if len(os.Args) < 4 { + whatsAppChannelHelp() + return + } + if os.Args[3] != "bridge" { + fmt.Printf("Unknown WhatsApp channel command: %s\n", os.Args[3]) + whatsAppChannelHelp() + return + } + if len(os.Args) < 5 { + whatsAppBridgeHelp() + return + } + switch os.Args[4] { + case "run": + whatsAppBridgeRunCmd() + case "status": + whatsAppBridgeStatusCmd() + case "logout": + whatsAppBridgeLogoutCmd() + default: + fmt.Printf("Unknown WhatsApp bridge command: %s\n", os.Args[4]) + whatsAppBridgeHelp() + } +} + +func whatsAppChannelHelp() { + fmt.Println("\nWhatsApp channel commands:") + fmt.Println(" clawgo channel whatsapp bridge run") + fmt.Println(" clawgo channel whatsapp bridge status") + fmt.Println(" clawgo channel whatsapp bridge logout") +} + +func whatsAppBridgeHelp() { + fmt.Println("\nWhatsApp bridge commands:") + fmt.Println(" run Run the built-in local WhatsApp bridge with QR login") + fmt.Println(" status Show current WhatsApp bridge status") + fmt.Println(" logout Unlink the current WhatsApp companion session") + fmt.Println() + fmt.Println("Run options:") + fmt.Println(" --addr Override listen address (defaults to channels.whatsapp.bridge_url host)") + fmt.Println(" --state-dir Override session store directory") + fmt.Println(" --no-print-qr Disable terminal QR rendering") +} + +func whatsAppBridgeRunCmd() { + cfg, _ := loadConfig() + bridgeURL := "ws://127.0.0.1:3001" + if cfg != nil && strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL) != "" { + bridgeURL = cfg.Channels.WhatsApp.BridgeURL + } + addr, err := channels.ParseWhatsAppBridgeListenAddr(bridgeURL) + if err != nil { + fmt.Printf("Error parsing WhatsApp bridge url: %v\n", err) + os.Exit(1) + } + stateDir := filepath.Join(config.GetConfigDir(), "channels", "whatsapp") + printQR := true + args := os.Args[5:] + for i := 0; i < len(args); i++ { + switch args[i] { + case "--addr": + if i+1 < len(args) { + addr = strings.TrimSpace(args[i+1]) + i++ + } + case "--state-dir": + if i+1 < len(args) { + stateDir = strings.TrimSpace(args[i+1]) + i++ + } + case "--no-print-qr": + printQR = false + } + } + + fmt.Printf("Starting WhatsApp bridge on %s\n", addr) + fmt.Printf("Session store: %s\n", stateDir) + statusURL, _ := channels.BridgeStatusURL(addr) + fmt.Printf("Status endpoint: %s\n", statusURL) + if printQR { + fmt.Println("QR codes will be rendered below when login is required.") + } + + svc := channels.NewWhatsAppBridgeService(addr, stateDir, printQR) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go renderWhatsAppBridgeQR(ctx, addr, printQR) + go renderWhatsAppBridgeState(ctx, addr) + if err := svc.Start(ctx); err != nil { + fmt.Printf("WhatsApp bridge stopped with error: %v\n", err) + os.Exit(1) + } +} + +func whatsAppBridgeStatusCmd() { + cfg, _ := loadConfig() + bridgeURL := "ws://127.0.0.1:3001" + if cfg != nil && strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL) != "" { + bridgeURL = cfg.Channels.WhatsApp.BridgeURL + } + args := os.Args[5:] + if len(args) >= 2 && args[0] == "--url" { + bridgeURL = strings.TrimSpace(args[1]) + } + statusURL, err := channels.BridgeStatusURL(bridgeURL) + if err != nil { + fmt.Printf("Error building status url: %v\n", err) + os.Exit(1) + } + status, err := fetchWhatsAppBridgeStatus(statusURL) + if err != nil { + fmt.Printf("Error fetching WhatsApp bridge status: %v\n", err) + os.Exit(1) + } + data, _ := json.MarshalIndent(status, "", " ") + fmt.Println(string(data)) + if status.QRAvailable && strings.TrimSpace(status.QRCode) != "" { + fmt.Println() + fmt.Println("Scan this QR code with WhatsApp:") + qrterminal.GenerateHalfBlock(status.QRCode, qrterminal.L, os.Stdout) + } +} + +func whatsAppBridgeLogoutCmd() { + cfg, _ := loadConfig() + bridgeURL := "ws://127.0.0.1:3001" + if cfg != nil && strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL) != "" { + bridgeURL = cfg.Channels.WhatsApp.BridgeURL + } + args := os.Args[5:] + if len(args) >= 2 && args[0] == "--url" { + bridgeURL = strings.TrimSpace(args[1]) + } + logoutURL, err := channels.BridgeLogoutURL(bridgeURL) + if err != nil { + fmt.Printf("Error building logout url: %v\n", err) + os.Exit(1) + } + req, _ := http.NewRequest(http.MethodPost, logoutURL, nil) + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + fmt.Printf("Error calling WhatsApp bridge logout: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + fmt.Printf("WhatsApp bridge logout failed: %s\n", resp.Status) + os.Exit(1) + } + fmt.Println("WhatsApp bridge logout requested successfully.") +} + +func fetchWhatsAppBridgeStatus(statusURL string) (channels.WhatsAppBridgeStatus, error) { + resp, err := (&http.Client{Timeout: 8 * time.Second}).Get(statusURL) + if err != nil { + return channels.WhatsAppBridgeStatus{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return channels.WhatsAppBridgeStatus{}, fmt.Errorf("status request failed: %s", resp.Status) + } + var status channels.WhatsAppBridgeStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return channels.WhatsAppBridgeStatus{}, err + } + return status, nil +} + +func renderWhatsAppBridgeQR(ctx context.Context, bridgeURL string, enabled bool) { + if !enabled { + return + } + statusURL, err := channels.BridgeStatusURL(bridgeURL) + if err != nil { + return + } + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + lastQR := "" + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + status, err := fetchWhatsAppBridgeStatus(statusURL) + if err != nil { + continue + } + if !status.QRAvailable || strings.TrimSpace(status.QRCode) == "" || status.QRCode == lastQR { + continue + } + lastQR = status.QRCode + fmt.Println() + fmt.Println("Scan this QR code with WhatsApp:") + qrterminal.GenerateHalfBlock(status.QRCode, qrterminal.L, os.Stdout) + fmt.Println() + } + } +} + +func renderWhatsAppBridgeState(ctx context.Context, bridgeURL string) { + statusURL, err := channels.BridgeStatusURL(bridgeURL) + if err != nil { + return + } + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + var lastSig string + var lastPrintedUser string + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + status, err := fetchWhatsAppBridgeStatus(statusURL) + if err != nil { + continue + } + sig := fmt.Sprintf("%s|%t|%t|%s|%s", status.State, status.Connected, status.LoggedIn, status.UserJID, status.LastEvent) + if sig == lastSig { + continue + } + lastSig = sig + + switch { + case status.QRAvailable: + fmt.Println("Waiting for WhatsApp QR scan...") + case status.State == "paired": + fmt.Println("WhatsApp QR scanned. Finalizing companion link...") + case status.Connected && status.LoggedIn: + if status.UserJID != "" && status.UserJID != lastPrintedUser { + fmt.Printf("WhatsApp connected as %s\n", status.UserJID) + lastPrintedUser = status.UserJID + } else { + fmt.Println("WhatsApp bridge connected.") + } + fmt.Println("Bridge is ready. Start or keep `make dev` running to receive messages.") + case status.State == "stored_session": + fmt.Println("Existing WhatsApp session found. Reconnecting...") + case status.State == "disconnected": + fmt.Println("WhatsApp bridge disconnected. Waiting for reconnect...") + case status.State == "logged_out": + fmt.Println("WhatsApp session logged out. Restart bridge to scan a new QR code.") + case status.LastError != "": + fmt.Printf("WhatsApp bridge status: %s (%s)\n", status.State, status.LastError) + case status.State != "": + fmt.Printf("WhatsApp bridge status: %s\n", status.State) + } + } + } +} diff --git a/go.mod b/go.mod index 2658967..d9be4b3 100644 --- a/go.mod +++ b/go.mod @@ -8,27 +8,40 @@ require ( github.com/chzyer/readline v1.5.1 github.com/gorilla/websocket v1.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 + github.com/pion/webrtc/v4 v4.1.6 github.com/robfig/cron/v3 v3.0.1 github.com/tencent-connect/botgo v0.2.1 + go.mau.fi/whatsmeow v0.0.0-20260305215846-fc65416c22c4 golang.org/x/oauth2 v0.35.0 golang.org/x/sync v0.19.0 golang.org/x/time v0.14.0 + modernc.org/sqlite v1.46.1 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/beeper/argo-go v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.7 // indirect github.com/pion/ice/v4 v4.0.10 // indirect @@ -44,7 +57,8 @@ require ( github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.8 // indirect github.com/pion/turn/v4 v4.1.1 // indirect - github.com/pion/webrtc/v4 v4.1.6 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -52,9 +66,20 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fastjson v1.6.7 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/wlynxg/anet v0.0.5 // indirect + go.mau.fi/libsignal v0.2.1 // indirect + go.mau.fi/util v0.9.6 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index e5308e1..739edd6 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,16 @@ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= +github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -21,11 +31,18 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= +github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= @@ -33,6 +50,7 @@ github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -50,6 +68,10 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -59,6 +81,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -74,8 +98,21 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -86,6 +123,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= @@ -119,12 +158,20 @@ github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4 github.com/pion/webrtc/v4 v4.1.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw= github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -156,6 +203,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= +github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -163,6 +212,12 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= +go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= +go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= +go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= +go.mau.fi/whatsmeow v0.0.0-20260305215846-fc65416c22c4 h1:FGA3NtCVNeCJ+C+KBg1pODsrfxC/trM3RHFWIeY7y4c= +go.mau.fi/whatsmeow v0.0.0-20260305215846-fc65416c22c4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= @@ -175,10 +230,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -221,8 +280,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -231,6 +293,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -238,6 +302,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -247,6 +313,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -259,6 +327,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -272,3 +342,33 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 1d63eb8..701bea8 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -931,7 +931,13 @@ func (al *AgentLoop) prepareOutbound(msg bus.InboundMessage, response string) (b if al.shouldSuppressOutbound(msg, clean) { return bus.OutboundMessage{}, false } - outbound := bus.OutboundMessage{Channel: msg.Channel, ChatID: msg.ChatID, Content: clean, ReplyToID: strings.TrimSpace(replyToID)} + outbound := bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: clean, + ReplyToID: strings.TrimSpace(replyToID), + ReplyToSender: strings.TrimSpace(msg.SenderID), + } if msg.Channel == "system" { outbound.Channel, outbound.ChatID = resolveSystemOrigin(msg.ChatID) } diff --git a/pkg/api/server.go b/pkg/api/server.go index 211142f..a54a270 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -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(``, total, total)) + b.WriteString(fmt.Sprintf(``, total, total)) + b.WriteString(``) + 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(``, rx, ry, scale, scale)) + } + } + b.WriteString(``) + 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) diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 9f68a31..ebf58c5 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -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(), " max { diff --git a/pkg/channels/whatsapp_bridge.go b/pkg/channels/whatsapp_bridge.go new file mode 100644 index 0000000..7eade47 --- /dev/null +++ b/pkg/channels/whatsapp_bridge.go @@ -0,0 +1,876 @@ +package channels + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "mime" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + "go.mau.fi/whatsmeow" + waProto "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + waLog "go.mau.fi/whatsmeow/util/log" + "google.golang.org/protobuf/proto" + _ "modernc.org/sqlite" +) + +type WhatsAppBridgeStatus struct { + State string `json:"state"` + Connected bool `json:"connected"` + LoggedIn bool `json:"logged_in"` + BridgeAddr string `json:"bridge_addr"` + UserJID string `json:"user_jid,omitempty"` + PushName string `json:"push_name,omitempty"` + Platform string `json:"platform,omitempty"` + QRCode string `json:"qr_code,omitempty"` + QRAvailable bool `json:"qr_available"` + LastEvent string `json:"last_event,omitempty"` + LastError string `json:"last_error,omitempty"` + UpdatedAt string `json:"updated_at"` + InboundCount int `json:"inbound_count"` + OutboundCount int `json:"outbound_count"` + ReadReceiptCount int `json:"read_receipt_count"` + LastInboundAt string `json:"last_inbound_at,omitempty"` + LastOutboundAt string `json:"last_outbound_at,omitempty"` + LastReadAt string `json:"last_read_at,omitempty"` + LastInboundFrom string `json:"last_inbound_from,omitempty"` + LastOutboundTo string `json:"last_outbound_to,omitempty"` + LastInboundText string `json:"last_inbound_text,omitempty"` + LastOutboundText string `json:"last_outbound_text,omitempty"` +} + +type WhatsAppBridgeService struct { + addr string + stateDir string + printQR bool + httpServer *http.Server + client *whatsmeow.Client + container *sqlstore.Container + rawDB *sql.DB + cancel context.CancelFunc + wsUpgrader websocket.Upgrader + wsClients map[*websocket.Conn]struct{} + statusMu sync.RWMutex + status WhatsAppBridgeStatus + wsClientsMu sync.Mutex + markReadFn func(ctx context.Context, ids []types.MessageID, timestamp time.Time, chat, sender types.JID) error +} + +type whatsappBridgeWSMessage struct { + Type string `json:"type"` + To string `json:"to,omitempty"` + From string `json:"from,omitempty"` + Chat string `json:"chat,omitempty"` + Content string `json:"content,omitempty"` + ReplyToID string `json:"reply_to_id,omitempty"` + ReplyToSender string `json:"reply_to_sender,omitempty"` + ID string `json:"id,omitempty"` + FromName string `json:"from_name,omitempty"` + Media []string `json:"media,omitempty"` +} + +func NewWhatsAppBridgeService(addr, stateDir string, printQR bool) *WhatsAppBridgeService { + return &WhatsAppBridgeService{ + addr: strings.TrimSpace(addr), + stateDir: strings.TrimSpace(stateDir), + printQR: printQR, + wsUpgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + }, + wsClients: map[*websocket.Conn]struct{}{}, + status: WhatsAppBridgeStatus{ + State: "starting", + BridgeAddr: strings.TrimSpace(addr), + UpdatedAt: time.Now().Format(time.RFC3339), + }, + } +} + +func (s *WhatsAppBridgeService) Start(ctx context.Context) error { + if strings.TrimSpace(s.addr) == "" { + return fmt.Errorf("bridge address is required") + } + if strings.TrimSpace(s.stateDir) == "" { + return fmt.Errorf("bridge state directory is required") + } + if err := os.MkdirAll(s.stateDir, 0o755); err != nil { + return fmt.Errorf("create whatsapp state dir: %w", err) + } + if err := s.initClient(ctx); err != nil { + return err + } + + runCtx, cancel := context.WithCancel(ctx) + s.cancel = cancel + + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleWS) + mux.HandleFunc("/ws", s.handleWS) + mux.HandleFunc("/status", s.handleStatus) + mux.HandleFunc("/logout", s.handleLogout) + s.httpServer = &http.Server{ + Addr: s.addr, + Handler: mux, + } + + ln, err := net.Listen("tcp", s.addr) + if err != nil { + return fmt.Errorf("listen whatsapp bridge: %w", err) + } + + go func() { + <-runCtx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.httpServer.Shutdown(shutdownCtx) + s.closeWSClients() + if s.client != nil { + s.client.Disconnect() + } + if s.rawDB != nil { + _ = s.rawDB.Close() + } + }() + + go func() { + _ = s.connectClient(runCtx) + }() + + if err := s.httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +func (s *WhatsAppBridgeService) Stop() { + if s.cancel != nil { + s.cancel() + } +} + +func (s *WhatsAppBridgeService) StatusSnapshot() WhatsAppBridgeStatus { + s.statusMu.RLock() + defer s.statusMu.RUnlock() + return s.status +} + +func (s *WhatsAppBridgeService) initClient(ctx context.Context) error { + dbPath := filepath.Join(s.stateDir, "whatsmeow.sqlite") + rawDB, err := sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("open whatsapp sqlite store: %w", err) + } + if _, err := rawDB.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { + _ = rawDB.Close() + return fmt.Errorf("enable whatsapp sqlite foreign keys: %w", err) + } + container := sqlstore.NewWithDB(rawDB, "sqlite", waLog.Noop) + if err := container.Upgrade(ctx); err != nil { + _ = rawDB.Close() + return fmt.Errorf("upgrade whatsapp sqlite store: %w", err) + } + deviceStore, err := container.GetFirstDevice(ctx) + if err != nil { + _ = rawDB.Close() + return fmt.Errorf("load whatsapp device store: %w", err) + } + client := whatsmeow.NewClient(deviceStore, waLog.Noop) + client.EnableAutoReconnect = true + client.AddEventHandler(s.handleWAEvent) + + s.rawDB = rawDB + s.container = container + s.client = client + s.markReadFn = func(ctx context.Context, ids []types.MessageID, timestamp time.Time, chat, sender types.JID) error { + return client.MarkRead(ctx, ids, timestamp, chat, sender) + } + if deviceStore.ID != nil { + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.LoggedIn = true + st.UserJID = deviceStore.ID.String() + st.State = "stored_session" + st.LastEvent = "stored_session" + }) + } + return nil +} + +func (s *WhatsAppBridgeService) connectClient(ctx context.Context) error { + if s.client == nil { + return fmt.Errorf("whatsapp bridge client is not initialized") + } + + var qrChan <-chan whatsmeow.QRChannelItem + var err error + if s.client.Store.ID == nil { + qrChan, err = s.client.GetQRChannel(ctx) + if err != nil && !errors.Is(err, whatsmeow.ErrQRStoreContainsID) { + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "error" + st.LastError = err.Error() + st.LastEvent = "qr_init_failed" + }) + return err + } + if qrChan != nil { + go s.consumeQRChannel(ctx, qrChan) + } + } + + if err := s.client.Connect(); err != nil { + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "error" + st.Connected = false + st.LastError = err.Error() + st.LastEvent = "connect_failed" + }) + return fmt.Errorf("connect whatsapp bridge: %w", err) + } + return nil +} + +func (s *WhatsAppBridgeService) consumeQRChannel(ctx context.Context, qrChan <-chan whatsmeow.QRChannelItem) { + for { + select { + case <-ctx.Done(): + return + case item, ok := <-qrChan: + if !ok { + return + } + switch item.Event { + case "code": + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "qr_ready" + st.QRCode = item.Code + st.QRAvailable = item.Code != "" + st.LastEvent = "qr_ready" + }) + default: + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.LastEvent = item.Event + if item.Event == whatsmeow.QRChannelSuccess.Event { + st.State = "paired" + st.QRCode = "" + st.QRAvailable = false + } + }) + } + } + } +} + +func (s *WhatsAppBridgeService) handleWAEvent(evt interface{}) { + switch v := evt.(type) { + case *events.Connected: + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "connected" + st.Connected = true + st.LoggedIn = s.client != nil && s.client.Store.ID != nil + st.QRCode = "" + st.QRAvailable = false + st.LastEvent = "connected" + if s.client != nil && s.client.Store.ID != nil { + st.UserJID = s.client.Store.ID.String() + } + }) + case *events.Disconnected: + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.Connected = false + if st.LoggedIn { + st.State = "disconnected" + } else { + st.State = "waiting_qr" + } + st.LastEvent = "disconnected" + }) + case *events.PairSuccess: + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "paired" + st.LoggedIn = true + st.UserJID = v.ID.String() + st.Platform = v.Platform + st.QRCode = "" + st.QRAvailable = false + st.LastEvent = "pair_success" + }) + case *events.LoggedOut: + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "logged_out" + st.Connected = false + st.LoggedIn = false + st.UserJID = "" + st.QRCode = "" + st.QRAvailable = false + st.LastEvent = "logged_out" + }) + case *events.StreamReplaced: + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "stream_replaced" + st.Connected = false + st.LastEvent = "stream_replaced" + }) + case *events.ClientOutdated: + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "client_outdated" + st.Connected = false + st.LastError = "whatsapp web client outdated" + st.LastEvent = "client_outdated" + }) + case *events.ConnectFailure: + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "connect_failed" + st.Connected = false + st.LastError = v.Reason.String() + st.LastEvent = "connect_failure" + }) + case *events.TemporaryBan: + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "temporary_ban" + st.Connected = false + st.LastError = v.String() + st.LastEvent = "temporary_ban" + }) + case *events.Message: + if v.Info.IsFromMe { + return + } + isGroup := v.Info.Chat.Server == types.GroupServer + mentionedSelf, replyToMe := s.matchCurrentUserContext(v.Message) + payload := whatsappBridgeWSMessage{ + Type: "message", + From: v.Info.Sender.ToNonAD().String(), + Chat: v.Info.Chat.ToNonAD().String(), + Content: extractWhatsAppMessageText(v.Message), + ID: v.Info.ID, + FromName: v.Info.PushName, + } + s.broadcastWSMap(map[string]interface{}{ + "type": payload.Type, + "from": payload.From, + "chat": payload.Chat, + "content": payload.Content, + "id": payload.ID, + "from_name": payload.FromName, + "is_group": isGroup, + "mentioned_self": mentionedSelf, + "reply_to_me": replyToMe, + }) + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.InboundCount++ + st.LastInboundAt = time.Now().Format(time.RFC3339) + st.LastInboundFrom = payload.From + st.LastInboundText = truncateString(strings.TrimSpace(payload.Content), 120) + st.LastEvent = "message_inbound" + }) + s.markIncomingReadReceipt(v.Info.Chat.ToNonAD(), v.Info.Sender.ToNonAD(), v.Info.ID, v.Info.Timestamp) + } +} + +func (s *WhatsAppBridgeService) handleWS(w http.ResponseWriter, r *http.Request) { + if !websocket.IsWebSocketUpgrade(r) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "whatsapp bridge websocket endpoint", + }) + return + } + conn, err := s.wsUpgrader.Upgrade(w, r, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.wsClientsMu.Lock() + s.wsClients[conn] = struct{}{} + s.wsClientsMu.Unlock() + defer func() { + s.wsClientsMu.Lock() + delete(s.wsClients, conn) + s.wsClientsMu.Unlock() + _ = conn.Close() + }() + + for { + var msg whatsappBridgeWSMessage + if err := conn.ReadJSON(&msg); err != nil { + return + } + if strings.TrimSpace(msg.Type) != "message" { + continue + } + if err := s.sendOutboundMessage(r.Context(), msg.To, msg.Content, msg.Media, msg.ReplyToID, msg.ReplyToSender); err != nil { + _ = conn.WriteJSON(map[string]string{ + "type": "error", + "error": err.Error(), + }) + continue + } + } +} + +func (s *WhatsAppBridgeService) handleStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.StatusSnapshot()) +} + +func (s *WhatsAppBridgeService) handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.client == nil { + http.Error(w, "whatsapp bridge client is not initialized", http.StatusServiceUnavailable) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + if err := s.client.Logout(ctx); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.State = "logged_out" + st.Connected = false + st.LoggedIn = false + st.UserJID = "" + st.QRCode = "" + st.QRAvailable = false + st.LastEvent = "logout" + }) + go func() { + _ = s.connectClient(context.Background()) + }() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.StatusSnapshot()) +} + +func (s *WhatsAppBridgeService) sendTextMessage(ctx context.Context, rawTo, content, replyToID, replyToSender string) error { + if s.client == nil { + return fmt.Errorf("whatsapp client not initialized") + } + if strings.TrimSpace(content) == "" { + return fmt.Errorf("message content is required") + } + to, err := normalizeWhatsAppRecipientJID(rawTo) + if err != nil { + return err + } + text := strings.TrimSpace(content) + msg := &waProto.Message{ + Conversation: &text, + } + applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) + _, err = s.client.SendMessage(ctx, to, msg) + if err == nil { + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.OutboundCount++ + st.LastOutboundAt = time.Now().Format(time.RFC3339) + st.LastOutboundTo = to.String() + st.LastOutboundText = truncateString(text, 120) + st.LastEvent = "message_outbound" + }) + } + return err +} + +func (s *WhatsAppBridgeService) sendOutboundMessage(ctx context.Context, rawTo, content string, mediaPaths []string, replyToID, replyToSender string) error { + if len(mediaPaths) == 0 { + return s.sendTextMessage(ctx, rawTo, content, replyToID, replyToSender) + } + to, err := normalizeWhatsAppRecipientJID(rawTo) + if err != nil { + return err + } + caption := strings.TrimSpace(content) + for idx, mediaPath := range mediaPaths { + msg, err := s.buildMediaMessage(ctx, to, strings.TrimSpace(mediaPath), caption, replyToID, replyToSender) + if err != nil { + return err + } + if _, err := s.client.SendMessage(ctx, to, msg); err != nil { + return err + } + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.OutboundCount++ + st.LastOutboundAt = time.Now().Format(time.RFC3339) + st.LastOutboundTo = to.String() + st.LastOutboundText = truncateString(strings.TrimSpace(content), 120) + st.LastEvent = "message_outbound" + }) + if idx == 0 { + caption = "" + } + } + return nil +} + +func (s *WhatsAppBridgeService) buildMediaMessage(ctx context.Context, to types.JID, mediaPath, caption, replyToID, replyToSender string) (*waProto.Message, error) { + if s.client == nil { + return nil, fmt.Errorf("whatsapp client not initialized") + } + mediaPath = strings.TrimSpace(mediaPath) + if mediaPath == "" { + return nil, fmt.Errorf("media path is required") + } + data, err := os.ReadFile(mediaPath) + if err != nil { + return nil, fmt.Errorf("read media file: %w", err) + } + kind, mimeType := detectWhatsAppMediaType(mediaPath, data) + uploadType := whatsmeow.MediaDocument + switch kind { + case "image": + uploadType = whatsmeow.MediaImage + case "video": + uploadType = whatsmeow.MediaVideo + case "audio": + uploadType = whatsmeow.MediaAudio + } + resp, err := s.client.Upload(ctx, data, uploadType) + if err != nil { + return nil, fmt.Errorf("upload media: %w", err) + } + fileLength := resp.FileLength + fileName := filepath.Base(mediaPath) + switch kind { + case "image": + msg := &waProto.Message{ + ImageMessage: &waProto.ImageMessage{ + Caption: proto.String(strings.TrimSpace(caption)), + Mimetype: proto.String(mimeType), + URL: proto.String(resp.URL), + DirectPath: proto.String(resp.DirectPath), + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: proto.Uint64(fileLength), + }, + } + applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) + return msg, nil + case "video": + msg := &waProto.Message{ + VideoMessage: &waProto.VideoMessage{ + Caption: proto.String(strings.TrimSpace(caption)), + Mimetype: proto.String(mimeType), + URL: proto.String(resp.URL), + DirectPath: proto.String(resp.DirectPath), + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: proto.Uint64(fileLength), + }, + } + applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) + return msg, nil + case "audio": + msg := &waProto.Message{ + AudioMessage: &waProto.AudioMessage{ + Mimetype: proto.String(mimeType), + URL: proto.String(resp.URL), + DirectPath: proto.String(resp.DirectPath), + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: proto.Uint64(fileLength), + }, + } + applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) + return msg, nil + default: + msg := &waProto.Message{ + DocumentMessage: &waProto.DocumentMessage{ + Caption: proto.String(strings.TrimSpace(caption)), + Mimetype: proto.String(mimeType), + Title: proto.String(fileName), + FileName: proto.String(fileName), + URL: proto.String(resp.URL), + DirectPath: proto.String(resp.DirectPath), + MediaKey: resp.MediaKey, + FileEncSHA256: resp.FileEncSHA256, + FileSHA256: resp.FileSHA256, + FileLength: proto.Uint64(fileLength), + }, + } + applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) + return msg, nil + } +} + +func detectWhatsAppMediaType(path string, data []byte) (kind string, mimeType string) { + ext := strings.ToLower(filepath.Ext(path)) + mimeType = mime.TypeByExtension(ext) + if mimeType == "" && len(data) > 0 { + mimeType = http.DetectContentType(data) + } + if mimeType == "" { + mimeType = "application/octet-stream" + } + switch { + case strings.HasPrefix(mimeType, "image/"): + return "image", mimeType + case strings.HasPrefix(mimeType, "video/"): + return "video", mimeType + case strings.HasPrefix(mimeType, "audio/"): + return "audio", mimeType + default: + return "document", mimeType + } +} + +func (s *WhatsAppBridgeService) matchCurrentUserContext(msg *waProto.Message) (mentionedSelf bool, replyToMe bool) { + if s.client == nil || s.client.Store.ID == nil || msg == nil { + return false, false + } + ctx := extractWhatsAppContextInfo(msg) + if ctx == nil { + return false, false + } + own := s.client.Store.ID.ToNonAD().String() + for _, mentioned := range ctx.GetMentionedJID() { + if normalizeComparableJID(mentioned) == own { + mentionedSelf = true + break + } + } + replyParticipant := normalizeComparableJID(ctx.GetParticipant()) + if replyParticipant != "" && replyParticipant == own { + replyToMe = true + } + return mentionedSelf, replyToMe +} + +func extractWhatsAppContextInfo(msg *waProto.Message) *waProto.ContextInfo { + switch { + case msg == nil: + return nil + case msg.GetExtendedTextMessage() != nil: + return msg.GetExtendedTextMessage().GetContextInfo() + case msg.GetImageMessage() != nil: + return msg.GetImageMessage().GetContextInfo() + case msg.GetVideoMessage() != nil: + return msg.GetVideoMessage().GetContextInfo() + case msg.GetAudioMessage() != nil: + return msg.GetAudioMessage().GetContextInfo() + case msg.GetDocumentMessage() != nil: + return msg.GetDocumentMessage().GetContextInfo() + case msg.GetDocumentWithCaptionMessage() != nil && msg.GetDocumentWithCaptionMessage().GetMessage() != nil: + return extractWhatsAppContextInfo(msg.GetDocumentWithCaptionMessage().GetMessage()) + default: + return nil + } +} + +func normalizeComparableJID(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + jid, err := types.ParseJID(raw) + if err == nil { + return jid.ToNonAD().String() + } + return raw +} + +func applyWhatsAppReplyContext(msg *waProto.Message, chatJID types.JID, replyToID, replyToSender string) { + if msg == nil || strings.TrimSpace(replyToID) == "" { + return + } + ctx := &waProto.ContextInfo{ + StanzaID: proto.String(strings.TrimSpace(replyToID)), + } + if chatJID.Server == types.GroupServer { + ctx.RemoteJID = proto.String(chatJID.ToNonAD().String()) + if sender := normalizeComparableJID(replyToSender); sender != "" { + ctx.Participant = proto.String(sender) + } + } + switch { + case msg.GetExtendedTextMessage() != nil: + msg.GetExtendedTextMessage().ContextInfo = ctx + case msg.GetImageMessage() != nil: + msg.GetImageMessage().ContextInfo = ctx + case msg.GetVideoMessage() != nil: + msg.GetVideoMessage().ContextInfo = ctx + case msg.GetAudioMessage() != nil: + msg.GetAudioMessage().ContextInfo = ctx + case msg.GetDocumentMessage() != nil: + msg.GetDocumentMessage().ContextInfo = ctx + default: + msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{ + Text: proto.String(msg.GetConversation()), + ContextInfo: ctx, + } + msg.Conversation = nil + } +} + +func (s *WhatsAppBridgeService) markIncomingReadReceipt(chat, sender types.JID, id types.MessageID, timestamp time.Time) { + if s == nil || s.markReadFn == nil || id == "" || chat.IsEmpty() { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + effectiveSender := types.EmptyJID + if chat.Server == types.GroupServer { + effectiveSender = sender + } + if err := s.markReadFn(ctx, []types.MessageID{id}, timestamp, chat, effectiveSender); err != nil { + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.LastError = "mark_read_failed: " + err.Error() + st.LastEvent = "mark_read_failed" + }) + return + } + s.updateStatus(func(st *WhatsAppBridgeStatus) { + st.ReadReceiptCount++ + st.LastReadAt = time.Now().Format(time.RFC3339) + st.LastEvent = "mark_read" + }) + }() +} + +func (s *WhatsAppBridgeService) updateStatus(mut func(*WhatsAppBridgeStatus)) { + s.statusMu.Lock() + defer s.statusMu.Unlock() + mut(&s.status) + s.status.UpdatedAt = time.Now().Format(time.RFC3339) +} + +func (s *WhatsAppBridgeService) broadcastWS(payload whatsappBridgeWSMessage) { + s.wsClientsMu.Lock() + defer s.wsClientsMu.Unlock() + for conn := range s.wsClients { + _ = conn.WriteJSON(payload) + } +} + +func (s *WhatsAppBridgeService) broadcastWSMap(payload map[string]interface{}) { + s.wsClientsMu.Lock() + defer s.wsClientsMu.Unlock() + for conn := range s.wsClients { + _ = conn.WriteJSON(payload) + } +} + +func (s *WhatsAppBridgeService) closeWSClients() { + s.wsClientsMu.Lock() + defer s.wsClientsMu.Unlock() + for conn := range s.wsClients { + _ = conn.Close() + delete(s.wsClients, conn) + } +} + +func ParseWhatsAppBridgeListenAddr(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("bridge url is required") + } + if strings.Contains(raw, "://") { + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("parse bridge url: %w", err) + } + if strings.TrimSpace(u.Host) == "" { + return "", fmt.Errorf("bridge url host is required") + } + return u.Host, nil + } + return raw, nil +} + +func BridgeStatusURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("bridge url is required") + } + if !strings.Contains(raw, "://") { + raw = "ws://" + raw + } + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("parse bridge url: %w", err) + } + switch u.Scheme { + case "wss": + u.Scheme = "https" + default: + u.Scheme = "http" + } + u.Path = "/status" + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} + +func BridgeLogoutURL(raw string) (string, error) { + statusURL, err := BridgeStatusURL(raw) + if err != nil { + return "", err + } + return strings.TrimSuffix(statusURL, "/status") + "/logout", nil +} + +func normalizeWhatsAppRecipientJID(raw string) (types.JID, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return types.EmptyJID, fmt.Errorf("recipient is required") + } + if strings.Contains(raw, "@") { + jid, err := types.ParseJID(raw) + if err != nil { + return types.EmptyJID, fmt.Errorf("parse recipient jid: %w", err) + } + return jid.ToNonAD(), nil + } + if strings.Contains(raw, "-") { + return types.NewJID(raw, types.GroupServer), nil + } + return types.NewJID(raw, types.DefaultUserServer), nil +} + +func extractWhatsAppMessageText(msg *waProto.Message) string { + if msg == nil { + return "" + } + switch { + case strings.TrimSpace(msg.GetConversation()) != "": + return msg.GetConversation() + case msg.GetExtendedTextMessage() != nil && strings.TrimSpace(msg.GetExtendedTextMessage().GetText()) != "": + return msg.GetExtendedTextMessage().GetText() + case msg.GetImageMessage() != nil && strings.TrimSpace(msg.GetImageMessage().GetCaption()) != "": + return msg.GetImageMessage().GetCaption() + case msg.GetVideoMessage() != nil && strings.TrimSpace(msg.GetVideoMessage().GetCaption()) != "": + return msg.GetVideoMessage().GetCaption() + case msg.GetDocumentMessage() != nil && strings.TrimSpace(msg.GetDocumentMessage().GetCaption()) != "": + return msg.GetDocumentMessage().GetCaption() + case msg.GetAudioMessage() != nil: + return "[audio]" + case msg.GetStickerMessage() != nil: + return "[sticker]" + case msg.GetImageMessage() != nil: + return "[image]" + case msg.GetVideoMessage() != nil: + return "[video]" + case msg.GetDocumentMessage() != nil: + return "[document]" + default: + return "" + } +} diff --git a/pkg/channels/whatsapp_bridge_test.go b/pkg/channels/whatsapp_bridge_test.go new file mode 100644 index 0000000..8b2fd6b --- /dev/null +++ b/pkg/channels/whatsapp_bridge_test.go @@ -0,0 +1,199 @@ +package channels + +import ( + "context" + "encoding/json" + "testing" + "time" + + "clawgo/pkg/bus" + waProto "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" +) + +func TestParseWhatsAppBridgeListenAddr(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "raw host", input: "127.0.0.1:3001", want: "127.0.0.1:3001"}, + {name: "ws url", input: "ws://localhost:3001", want: "localhost:3001"}, + {name: "ws url path", input: "ws://localhost:3001/ws", want: "localhost:3001"}, + {name: "missing host", input: "ws:///ws", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseWhatsAppBridgeListenAddr(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestBridgeStatusURL(t *testing.T) { + got, err := BridgeStatusURL("ws://localhost:3001/ws") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "http://localhost:3001/status" { + t.Fatalf("got %q", got) + } +} + +func TestNormalizeWhatsAppRecipientJID(t *testing.T) { + tests := []struct { + input string + want string + }{ + {input: "8613012345678", want: "8613012345678@s.whatsapp.net"}, + {input: "1203630-123456789@g.us", want: "1203630-123456789@g.us"}, + {input: "1203630-123456789", want: "1203630-123456789@g.us"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := normalizeWhatsAppRecipientJID(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.String() != tt.want { + t.Fatalf("got %q, want %q", got.String(), tt.want) + } + }) + } +} + +func TestDetectWhatsAppMediaType(t *testing.T) { + tests := []struct { + path string + data []byte + wantKind string + wantMime string + }{ + {path: "photo.jpg", data: []byte{0xff, 0xd8, 0xff, 0xe0}, wantKind: "image", wantMime: "image/jpeg"}, + {path: "clip.mp4", data: []byte("...."), wantKind: "video", wantMime: "video/mp4"}, + {path: "voice.ogg", data: []byte("OggS"), wantKind: "audio", wantMime: "audio/ogg"}, + {path: "report.pdf", data: []byte("%PDF-1.4"), wantKind: "document", wantMime: "application/pdf"}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + gotKind, gotMime := detectWhatsAppMediaType(tt.path, tt.data) + if gotKind != tt.wantKind { + t.Fatalf("kind got %q want %q", gotKind, tt.wantKind) + } + if gotMime != tt.wantMime { + t.Fatalf("mime got %q want %q", gotMime, tt.wantMime) + } + }) + } +} + +func TestWhatsAppSendIncludesMediaPayload(t *testing.T) { + msg := bus.OutboundMessage{ + Channel: "whatsapp", + ChatID: "12345@s.whatsapp.net", + Content: "hello", + Media: "/tmp/demo.png", + ReplyToID: "wamid.demo", + } + payload := map[string]interface{}{ + "type": "message", + "to": msg.ChatID, + "content": msg.Content, + } + if msg.ReplyToID != "" { + payload["reply_to_id"] = msg.ReplyToID + } + if msg.Media != "" { + payload["media"] = []string{msg.Media} + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + media, ok := parsed["media"].([]interface{}) + if !ok || len(media) != 1 || media[0] != msg.Media { + t.Fatalf("unexpected media payload: %#v", parsed["media"]) + } + if parsed["reply_to_id"] != msg.ReplyToID { + t.Fatalf("unexpected reply_to_id payload: %#v", parsed["reply_to_id"]) + } +} + +func TestExtractWhatsAppContextInfo(t *testing.T) { + ctx := &waProto.ContextInfo{MentionedJID: []string{"8613012345678@s.whatsapp.net"}} + msg := &waProto.Message{ + ExtendedTextMessage: &waProto.ExtendedTextMessage{ + Text: proto.String("hi"), + ContextInfo: ctx, + }, + } + got := extractWhatsAppContextInfo(msg) + if got == nil || len(got.GetMentionedJID()) != 1 { + t.Fatalf("expected context info to be extracted") + } +} + +func TestNormalizeComparableJID(t *testing.T) { + jid := types.NewJID("8613012345678", types.DefaultUserServer) + got := normalizeComparableJID(jid.ADString()) + if got != jid.String() { + t.Fatalf("got %q want %q", got, jid.String()) + } +} + +func TestApplyWhatsAppReplyContext(t *testing.T) { + msg := &waProto.Message{Conversation: proto.String("hello")} + applyWhatsAppReplyContext(msg, types.NewJID("12345", types.DefaultUserServer), "wamid.reply", "") + if msg.GetExtendedTextMessage() == nil || msg.GetExtendedTextMessage().GetContextInfo().GetStanzaID() != "wamid.reply" { + t.Fatalf("expected reply context on text message") + } +} + +func TestMarkIncomingReadReceiptUsesSenderOnlyForGroups(t *testing.T) { + service := &WhatsAppBridgeService{} + done := make(chan struct{}, 2) + var gotChat, gotSender types.JID + service.markReadFn = func(ctx context.Context, ids []types.MessageID, timestamp time.Time, chat, sender types.JID) error { + gotChat = chat + gotSender = sender + done <- struct{}{} + return nil + } + + service.markIncomingReadReceipt(types.NewJID("1203630-123456789", types.GroupServer), types.NewJID("8613012345678", types.DefaultUserServer), types.MessageID("abc"), time.Now()) + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatalf("timed out waiting for group mark read") + } + if gotChat.Server != types.GroupServer || gotSender.Server != types.DefaultUserServer { + t.Fatalf("unexpected group mark read args: chat=%s sender=%s", gotChat, gotSender) + } + + service.markIncomingReadReceipt(types.NewJID("8613012345678", types.DefaultUserServer), types.NewJID("8620000000000", types.DefaultUserServer), types.MessageID("def"), time.Now()) + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatalf("timed out waiting for direct mark read") + } + if !gotSender.IsEmpty() { + t.Fatalf("expected empty sender for direct chat, got %s", gotSender) + } +} diff --git a/pkg/channels/whatsapp_test.go b/pkg/channels/whatsapp_test.go new file mode 100644 index 0000000..cf444ed --- /dev/null +++ b/pkg/channels/whatsapp_test.go @@ -0,0 +1,39 @@ +package channels + +import ( + "testing" + + "clawgo/pkg/config" +) + +func TestWhatsAppShouldHandleIncomingMessage(t *testing.T) { + ch := &WhatsAppChannel{ + config: config.WhatsAppConfig{ + EnableGroups: true, + RequireMentionInGroups: true, + }, + } + if !ch.shouldHandleIncomingMessage(false, false, false) { + t.Fatalf("private chats should always be allowed") + } + if ch.shouldHandleIncomingMessage(true, false, false) { + t.Fatalf("group message without mention should be blocked") + } + if !ch.shouldHandleIncomingMessage(true, true, false) { + t.Fatalf("group mention should be allowed") + } + if !ch.shouldHandleIncomingMessage(true, false, true) { + t.Fatalf("reply-to-me should be allowed") + } + + ch.config.EnableGroups = false + if ch.shouldHandleIncomingMessage(true, true, true) { + t.Fatalf("groups should be blocked when disabled") + } + + ch.config.EnableGroups = true + ch.config.RequireMentionInGroups = false + if !ch.shouldHandleIncomingMessage(true, false, false) { + t.Fatalf("group should be allowed when mention is not required") + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 88b8995..d91aabb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -178,9 +178,11 @@ type ChannelsConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"CLAWGO_CHANNELS_WHATSAPP_BRIDGE_URL"` - AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"CLAWGO_CHANNELS_WHATSAPP_BRIDGE_URL"` + AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_WHATSAPP_ALLOW_FROM"` + EnableGroups bool `json:"enable_groups" env:"CLAWGO_CHANNELS_WHATSAPP_ENABLE_GROUPS"` + RequireMentionInGroups bool `json:"require_mention_in_groups" env:"CLAWGO_CHANNELS_WHATSAPP_REQUIRE_MENTION_IN_GROUPS"` } type TelegramConfig struct { @@ -525,9 +527,11 @@ func DefaultConfig() *Config { InboundContentDedupeWindowSeconds: 12, OutboundDedupeWindowSeconds: 12, WhatsApp: WhatsAppConfig{ - Enabled: false, - BridgeURL: "ws://localhost:3001", - AllowFrom: []string{}, + Enabled: false, + BridgeURL: "ws://localhost:3001", + AllowFrom: []string{}, + EnableGroups: true, + RequireMentionInGroups: true, }, Telegram: TelegramConfig{ Enabled: false, diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 8df044e..26db056 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,5 +1,5 @@ import React, { Suspense, lazy } from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AppProvider } from './context/AppContext'; import { UIProvider } from './context/UIContext'; import Layout from './components/Layout'; @@ -11,6 +11,7 @@ const Cron = lazy(() => import('./pages/Cron')); const Logs = lazy(() => import('./pages/Logs')); const Skills = lazy(() => import('./pages/Skills')); const MCP = lazy(() => import('./pages/MCP')); +const ChannelSettings = lazy(() => import('./pages/ChannelSettings')); const Memory = lazy(() => import('./pages/Memory')); const Nodes = lazy(() => import('./pages/Nodes')); const NodeArtifacts = lazy(() => import('./pages/NodeArtifacts')); @@ -40,6 +41,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/webui/src/components/NavItem.tsx b/webui/src/components/NavItem.tsx index 8a12657..85f662b 100644 --- a/webui/src/components/NavItem.tsx +++ b/webui/src/components/NavItem.tsx @@ -6,13 +6,14 @@ interface NavItemProps { label: string; to: string; collapsed?: boolean; + nested?: boolean; } -const NavItem: React.FC = ({ icon, label, to, collapsed = false }) => ( +const NavItem: React.FC = ({ icon, label, to, collapsed = false, nested = false }) => ( `w-full flex items-center ${collapsed ? 'justify-center' : 'gap-3'} px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 border ${ + className={({ isActive }) => `w-full flex items-center ${collapsed ? 'justify-center' : 'gap-3'} ${nested ? 'px-3 py-2' : 'px-3 py-2.5'} rounded-lg text-sm font-medium transition-all duration-200 border ${ isActive ? 'nav-item-active text-indigo-700 border-indigo-500/30' : 'text-zinc-400 border-transparent' diff --git a/webui/src/components/RecursiveConfig.tsx b/webui/src/components/RecursiveConfig.tsx index 198c8c2..c3663b2 100644 --- a/webui/src/components/RecursiveConfig.tsx +++ b/webui/src/components/RecursiveConfig.tsx @@ -57,7 +57,7 @@ const PrimitiveArrayEditor: React.FC<{
{value.length === 0 && {t('empty')}} {value.map((item, idx) => ( - + {String(item)} @@ -70,7 +70,7 @@ const PrimitiveArrayEditor: React.FC<{ value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={t('recursiveAddValuePlaceholder')} - className="w-full bg-zinc-950/70 border border-zinc-800 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20" + className="ui-input rounded-xl px-3 py-2 text-sm" /> {suggestions.map((s) => ( @@ -83,7 +83,7 @@ const PrimitiveArrayEditor: React.FC<{ addValue(draft); setDraft(''); }} - className="px-3 py-2 text-xs rounded-xl bg-zinc-800 hover:bg-zinc-700" + className="ui-button ui-button-neutral px-3 py-2 text-xs rounded-xl" > {t('add')} @@ -95,7 +95,7 @@ const PrimitiveArrayEditor: React.FC<{ setSelected(v); if (v) addValue(v); }} - className="px-3 py-2 text-xs rounded-xl bg-zinc-950/70 border border-zinc-800" + className="ui-select px-3 py-2 text-xs rounded-xl" > {suggestions.filter((s) => !value.includes(s)).map((s) => ( @@ -129,7 +129,7 @@ const RecursiveConfig: React.FC = ({ data, labels, path = {label} {currentPath}
-
+
{allPrimitive ? ( = ({ data, labels, path = // ignore invalid json during typing } }} - className="w-full min-h-28 bg-zinc-950/70 border border-zinc-800 rounded-xl px-3 py-2 text-sm font-mono focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20" + className="ui-textarea w-full min-h-28 rounded-xl px-3 py-2 text-sm font-mono" /> )}
@@ -162,7 +162,7 @@ const RecursiveConfig: React.FC = ({ data, labels, path = {label} -
+
@@ -176,7 +176,7 @@ const RecursiveConfig: React.FC = ({ data, labels, path = {currentPath}
{typeof value === 'boolean' ? ( -