chore: update project files

This commit is contained in:
lpf
2026-04-03 18:35:04 +08:00
parent 428d944e52
commit ce2263ac8c
54 changed files with 1025 additions and 4613 deletions

View File

@@ -96,7 +96,6 @@ func printHelp() {
fmt.Println(" config Get/set config values")
fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" channel Test and manage messaging channels")
fmt.Println(" node Register remote node metadata and heartbeat")
fmt.Println(" skills Manage skills (install, list, remove)")
if tuiEnabled {
fmt.Println(" tui Chat in terminal using the gateway chat API")

View File

@@ -2,19 +2,11 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/YspCoder/clawgo/pkg/bus"
"github.com/YspCoder/clawgo/pkg/channels"
"github.com/YspCoder/clawgo/pkg/config"
qrterminal "github.com/mdp/qrterminal/v3"
)
func channelCmd() {
@@ -28,8 +20,6 @@ func channelCmd() {
switch subcommand {
case "test":
channelTestCmd()
case "whatsapp":
whatsAppChannelCmd()
default:
fmt.Printf("Unknown channel command: %s\n", subcommand)
channelHelp()
@@ -39,17 +29,11 @@ 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(" --channel Channel name (weixin, feishu, telegram)")
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() {
@@ -110,258 +94,3 @@ 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 <host:port> Override listen address (defaults to channels.whatsapp.bridge_url host)")
fmt.Println(" --state-dir <path> 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)
}
}
}
}

View File

@@ -2,10 +2,10 @@ package main
import (
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
@@ -81,9 +81,6 @@ func gatewayCmd() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if shouldEmbedWhatsAppBridge(cfg) {
cfg.Channels.WhatsApp.BridgeURL = embeddedWhatsAppBridgeURL(cfg)
}
agentLoop, channelManager, err := buildGatewayRuntime(ctx, cfg, msgBus, cronService)
if err != nil {
@@ -175,19 +172,109 @@ func gatewayCmd() {
}
bindAgentLoopHandlers(agentLoop)
var reloadMu sync.Mutex
var applyReload func(forceRuntimeReload bool) error
registryServer.SetConfigAfterHook(func(forceRuntimeReload bool) error {
triggerReload := func(source string, forceRuntimeReload bool) error {
reloadMu.Lock()
defer reloadMu.Unlock()
if applyReload == nil {
return fmt.Errorf("reload handler not ready")
fmt.Printf("\nReloading config (source=%s)...\n", strings.TrimSpace(source))
newCfg, err := config.LoadConfig(getConfigPath())
if err != nil {
return fmt.Errorf("load config: %w", err)
}
return applyReload(forceRuntimeReload)
})
whatsAppBridge, whatsAppEmbedded := setupEmbeddedWhatsAppBridge(ctx, cfg)
if whatsAppBridge != nil {
registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath)
if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") {
applyMaximumPermissionPolicy(newCfg)
}
configureCronServiceRuntime(cronService, newCfg)
heartbeatService.Stop()
heartbeatService = buildHeartbeatService(newCfg, msgBus)
if err := heartbeatService.Start(); err != nil {
fmt.Printf("Error starting heartbeat service: %v\n", err)
}
if !forceRuntimeReload && reflect.DeepEqual(cfg, newCfg) {
fmt.Println("Config unchanged, skip reload")
return nil
}
if cfg.Gateway.Host != newCfg.Gateway.Host || cfg.Gateway.Port != newCfg.Gateway.Port {
fmt.Printf("Warning: gateway host/port change detected (%s:%d -> %s:%d); restart required to rebind listener\n",
cfg.Gateway.Host, cfg.Gateway.Port, newCfg.Gateway.Host, newCfg.Gateway.Port)
}
runtimeSame := reflect.DeepEqual(cfg.Agents, newCfg.Agents) &&
reflect.DeepEqual(cfg.Models, newCfg.Models) &&
reflect.DeepEqual(cfg.Tools, newCfg.Tools) &&
reflect.DeepEqual(cfg.Channels, newCfg.Channels)
if runtimeSame && !forceRuntimeReload {
configureLogging(newCfg)
sentinelService.Stop()
sentinelService = sentinel.NewService(
getConfigPath(),
newCfg.WorkspacePath(),
newCfg.Sentinel.IntervalSec,
newCfg.Sentinel.AutoHeal,
buildSentinelAlertHandler(newCfg, msgBus),
)
if newCfg.Sentinel.Enabled {
sentinelService.SetManager(channelManager)
sentinelService.Start()
}
cfg = newCfg
runtimecfg.Set(cfg)
registryServer.SetToken(cfg.Gateway.Token)
registryServer.SetWorkspacePath(cfg.WorkspacePath())
registryServer.SetLogFilePath(cfg.LogFilePath())
fmt.Println("Config hot-reload applied (logging/metadata only)")
return nil
}
newAgentLoop, newChannelManager, err := buildGatewayRuntime(ctx, newCfg, msgBus, cronService)
if err != nil {
return fmt.Errorf("init runtime: %w", err)
}
channelManager.StopAll(ctx)
agentLoop.Stop()
channelManager = newChannelManager
agentLoop = newAgentLoop
cfg = newCfg
runtimecfg.Set(cfg)
bindAgentLoopHandlers(agentLoop)
configureLogging(newCfg)
registryServer.SetToken(cfg.Gateway.Token)
registryServer.SetWorkspacePath(cfg.WorkspacePath())
registryServer.SetLogFilePath(cfg.LogFilePath())
if rawWeixin, ok := channelManager.GetChannel("weixin"); ok {
if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok {
weixinChannel.SetConfigPath(getConfigPath())
registryServer.SetWeixinChannel(weixinChannel)
}
} else {
registryServer.SetWeixinChannel(nil)
}
sentinelService.Stop()
sentinelService = sentinel.NewService(
getConfigPath(),
newCfg.WorkspacePath(),
newCfg.Sentinel.IntervalSec,
newCfg.Sentinel.AutoHeal,
buildSentinelAlertHandler(newCfg, msgBus),
)
if newCfg.Sentinel.Enabled {
sentinelService.Start()
}
sentinelService.SetManager(channelManager)
if err := channelManager.StartAll(ctx); err != nil {
return fmt.Errorf("start channels: %w", err)
}
go agentLoop.Run(ctx)
fmt.Println("Config hot-reload applied")
return nil
}
registryServer.SetConfigAfterHook(func(forceRuntimeReload bool) error {
return triggerReload("api", forceRuntimeReload)
})
if rawWeixin, ok := channelManager.GetChannel("weixin"); ok {
if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok {
weixinChannel.SetConfigPath(getConfigPath())
@@ -332,9 +419,9 @@ func gatewayCmd() {
}
})
if err := registryServer.Start(ctx); err != nil {
fmt.Printf("Error starting node registry server: %v\n", err)
fmt.Printf("Error starting gateway server: %v\n", err)
} else {
fmt.Printf("Node registry server started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Printf("Gateway server started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
}
if err := channelManager.StartAll(ctx); err != nil {
@@ -345,137 +432,26 @@ func gatewayCmd() {
go runGatewayStartupCompactionCheck(ctx, agentLoop)
go runGatewayBootstrapInit(ctx, cfg, agentLoop)
stopConfigWatcher := startGatewayConfigWatcher(ctx, getConfigPath(), 500*time.Millisecond, 250*time.Millisecond, func() error {
return triggerReload("watcher", false)
})
defer stopConfigWatcher()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, gatewayNotifySignals()...)
applyReload = func(forceRuntimeReload bool) error {
fmt.Println("\nReloading config...")
newCfg, err := config.LoadConfig(getConfigPath())
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") {
applyMaximumPermissionPolicy(newCfg)
}
configureCronServiceRuntime(cronService, newCfg)
heartbeatService.Stop()
heartbeatService = buildHeartbeatService(newCfg, msgBus)
if err := heartbeatService.Start(); err != nil {
fmt.Printf("Error starting heartbeat service: %v\n", err)
}
if !forceRuntimeReload && reflect.DeepEqual(cfg, newCfg) {
fmt.Println("Config unchanged, skip reload")
return nil
}
if cfg.Gateway.Host != newCfg.Gateway.Host || cfg.Gateway.Port != newCfg.Gateway.Port {
fmt.Printf("Warning: gateway host/port change detected (%s:%d -> %s:%d); restart required to rebind listener\n",
cfg.Gateway.Host, cfg.Gateway.Port, newCfg.Gateway.Host, newCfg.Gateway.Port)
}
if shouldEmbedWhatsAppBridge(newCfg) {
newCfg.Channels.WhatsApp.BridgeURL = embeddedWhatsAppBridgeURL(newCfg)
}
runtimeSame := reflect.DeepEqual(cfg.Agents, newCfg.Agents) &&
reflect.DeepEqual(cfg.Models, newCfg.Models) &&
reflect.DeepEqual(cfg.Tools, newCfg.Tools) &&
reflect.DeepEqual(cfg.Channels, newCfg.Channels)
if runtimeSame && !forceRuntimeReload {
configureLogging(newCfg)
sentinelService.Stop()
sentinelService = sentinel.NewService(
getConfigPath(),
newCfg.WorkspacePath(),
newCfg.Sentinel.IntervalSec,
newCfg.Sentinel.AutoHeal,
buildSentinelAlertHandler(newCfg, msgBus),
)
if newCfg.Sentinel.Enabled {
sentinelService.SetManager(channelManager)
sentinelService.Start()
}
cfg = newCfg
runtimecfg.Set(cfg)
registryServer.SetToken(cfg.Gateway.Token)
registryServer.SetWorkspacePath(cfg.WorkspacePath())
registryServer.SetLogFilePath(cfg.LogFilePath())
fmt.Println("Config hot-reload applied (logging/metadata only)")
return nil
}
newAgentLoop, newChannelManager, err := buildGatewayRuntime(ctx, newCfg, msgBus, cronService)
if err != nil {
return fmt.Errorf("init runtime: %w", err)
}
channelManager.StopAll(ctx)
agentLoop.Stop()
if whatsAppBridge != nil {
whatsAppBridge.Stop()
}
newWhatsAppBridge, _ := setupEmbeddedWhatsAppBridge(ctx, newCfg)
channelManager = newChannelManager
agentLoop = newAgentLoop
cfg = newCfg
whatsAppBridge = newWhatsAppBridge
whatsAppEmbedded = newWhatsAppBridge != nil
runtimecfg.Set(cfg)
bindAgentLoopHandlers(agentLoop)
configureLogging(newCfg)
registryServer.SetToken(cfg.Gateway.Token)
registryServer.SetWorkspacePath(cfg.WorkspacePath())
registryServer.SetLogFilePath(cfg.LogFilePath())
registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath)
if rawWeixin, ok := channelManager.GetChannel("weixin"); ok {
if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok {
weixinChannel.SetConfigPath(getConfigPath())
registryServer.SetWeixinChannel(weixinChannel)
}
} else {
registryServer.SetWeixinChannel(nil)
}
sentinelService.Stop()
sentinelService = sentinel.NewService(
getConfigPath(),
newCfg.WorkspacePath(),
newCfg.Sentinel.IntervalSec,
newCfg.Sentinel.AutoHeal,
buildSentinelAlertHandler(newCfg, msgBus),
)
if newCfg.Sentinel.Enabled {
sentinelService.Start()
}
sentinelService.SetManager(channelManager)
if err := channelManager.StartAll(ctx); err != nil {
return fmt.Errorf("start channels: %w", err)
}
go agentLoop.Run(ctx)
fmt.Println("Config hot-reload applied")
return nil
}
for {
select {
case sig := <-sigChan:
switch {
case isGatewayReloadSignal(sig):
reloadMu.Lock()
err := applyReload(false)
reloadMu.Unlock()
err := triggerReload("signal", false)
if err != nil {
fmt.Printf("Reload failed: %v\n", err)
}
default:
fmt.Println("\nShutting down...")
cancel()
if whatsAppEmbedded && whatsAppBridge != nil {
whatsAppBridge.Stop()
}
heartbeatService.Stop()
sentinelService.Stop()
cronService.Stop()
@@ -488,8 +464,6 @@ func gatewayCmd() {
}
}
const embeddedWhatsAppBridgeBasePath = "/whatsapp"
func runGatewayStartupCompactionCheck(parent context.Context, agentLoop *agent.AgentLoop) {
if agentLoop == nil {
return
@@ -542,6 +516,89 @@ func runGatewayBootstrapInit(parent context.Context, cfg *config.Config, agentLo
logger.InfoC("gateway", logger.C0114)
}
type configFileFingerprint struct {
Size int64
ModUnixNano int64
SHA256 [32]byte
}
func readConfigFileFingerprint(path string) (configFileFingerprint, error) {
info, err := os.Stat(path)
if err != nil {
return configFileFingerprint{}, err
}
content, err := os.ReadFile(path)
if err != nil {
return configFileFingerprint{}, err
}
return configFileFingerprint{
Size: info.Size(),
ModUnixNano: info.ModTime().UnixNano(),
SHA256: sha256.Sum256(content),
}, nil
}
func (f configFileFingerprint) sameContent(other configFileFingerprint) bool {
return f.Size == other.Size && f.SHA256 == other.SHA256
}
func startGatewayConfigWatcher(ctx context.Context, configPath string, debounce, pollInterval time.Duration, onContentChanged func() error) func() {
if debounce <= 0 {
debounce = 500 * time.Millisecond
}
if pollInterval <= 0 {
pollInterval = 250 * time.Millisecond
}
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
last, err := readConfigFileFingerprint(configPath)
haveLast := err == nil
pending := false
lastDetectedAt := time.Time{}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
current, err := readConfigFileFingerprint(configPath)
if err != nil {
continue
}
if !haveLast {
last = current
haveLast = true
continue
}
if !current.sameContent(last) {
last = current
pending = true
lastDetectedAt = time.Now()
continue
}
if pending && !lastDetectedAt.IsZero() && time.Since(lastDetectedAt) >= debounce {
pending = false
if onContentChanged != nil {
if err := onContentChanged(); err != nil {
fmt.Printf("Config watcher reload failed: %v\n", err)
}
}
}
}
}
}()
return func() {
select {
case <-done:
case <-time.After(2 * time.Second):
}
}
}
func applyMaximumPermissionPolicy(cfg *config.Config) {
cfg.Tools.Shell.Enabled = true
cfg.Tools.Shell.Sandbox.Enabled = false
@@ -1123,69 +1180,3 @@ func buildHeartbeatService(cfg *config.Config, msgBus *bus.MessageBus) *heartbea
return "queued", nil
}, hbInterval, cfg.Agents.Defaults.Heartbeat.Enabled, cfg.Agents.Defaults.Heartbeat.PromptTemplate)
}
func setupEmbeddedWhatsAppBridge(ctx context.Context, cfg *config.Config) (*channels.WhatsAppBridgeService, bool) {
if !shouldStartEmbeddedWhatsAppBridge(cfg) {
return nil, false
}
cfg.Channels.WhatsApp.BridgeURL = embeddedWhatsAppBridgeURL(cfg)
stateDir := filepath.Join(filepath.Dir(getConfigPath()), "channels", "whatsapp")
svc := channels.NewWhatsAppBridgeService(fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port), stateDir, false)
if err := svc.StartEmbedded(ctx); err != nil {
fmt.Printf("Error starting embedded WhatsApp bridge: %v\n", err)
return nil, false
}
return svc, true
}
func shouldStartEmbeddedWhatsAppBridge(cfg *config.Config) bool {
return cfg != nil && shouldEmbedWhatsAppBridge(cfg)
}
func shouldEmbedWhatsAppBridge(cfg *config.Config) bool {
raw := strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL)
if raw == "" {
return true
}
hostPort := comparableBridgeHostPort(raw)
if hostPort == "" {
return false
}
if hostPort == "127.0.0.1:3001" || hostPort == "localhost:3001" {
return true
}
return hostPort == comparableGatewayHostPort(cfg.Gateway.Host, cfg.Gateway.Port)
}
func embeddedWhatsAppBridgeURL(cfg *config.Config) string {
host := strings.TrimSpace(cfg.Gateway.Host)
switch host {
case "", "0.0.0.0", "::", "[::]":
host = "127.0.0.1"
}
return fmt.Sprintf("ws://%s:%d%s/ws", host, cfg.Gateway.Port, embeddedWhatsAppBridgeBasePath)
}
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)
}

View File

@@ -1,32 +1,117 @@
package main
import (
"context"
"os"
"path/filepath"
"sync/atomic"
"testing"
"github.com/YspCoder/clawgo/pkg/config"
"time"
)
func TestShouldStartEmbeddedWhatsAppBridge(t *testing.T) {
func TestConfigFileFingerprintSameContentIgnoresTouch(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Channels.WhatsApp.Enabled = false
cfg.Channels.WhatsApp.BridgeURL = ""
if !shouldStartEmbeddedWhatsAppBridge(cfg) {
t.Fatalf("expected embedded bridge to start when using default embedded url")
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"a":1}`), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
first, err := readConfigFileFingerprint(path)
if err != nil {
t.Fatalf("read first fingerprint: %v", err)
}
cfg.Channels.WhatsApp.BridgeURL = "ws://127.0.0.1:3001"
if !shouldStartEmbeddedWhatsAppBridge(cfg) {
t.Fatalf("expected embedded bridge to start for legacy local bridge url")
target := time.Now().Add(2 * time.Second)
if err := os.Chtimes(path, target, target); err != nil {
t.Fatalf("touch file: %v", err)
}
second, err := readConfigFileFingerprint(path)
if err != nil {
t.Fatalf("read second fingerprint: %v", err)
}
cfg.Channels.WhatsApp.BridgeURL = "ws://example.com:3001/ws"
if shouldStartEmbeddedWhatsAppBridge(cfg) {
t.Fatalf("expected external bridge url to disable embedded bridge")
if first.sameContent(second) != true {
t.Fatalf("expected touch to keep content fingerprint unchanged")
}
if shouldStartEmbeddedWhatsAppBridge(nil) {
t.Fatalf("expected nil config to disable embedded bridge")
if first.ModUnixNano == second.ModUnixNano {
t.Fatalf("expected touch to change mod time")
}
}
func TestGatewayConfigWatcherReloadOnContentChangeWithDebounce(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"a":1}`), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var reloadCalls atomic.Int32
stop := startGatewayConfigWatcher(ctx, path, 150*time.Millisecond, 30*time.Millisecond, func() error {
reloadCalls.Add(1)
return nil
})
defer stop()
time.Sleep(120 * time.Millisecond)
if err := os.WriteFile(path, []byte(`{"a":2}`), 0644); err != nil {
t.Fatalf("write changed file #1: %v", err)
}
time.Sleep(40 * time.Millisecond)
if err := os.WriteFile(path, []byte(`{"a":3}`), 0644); err != nil {
t.Fatalf("write changed file #2: %v", err)
}
time.Sleep(40 * time.Millisecond)
if err := os.WriteFile(path, []byte(`{"a":4}`), 0644); err != nil {
t.Fatalf("write changed file #3: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for reloadCalls.Load() < 1 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if got := reloadCalls.Load(); got != 1 {
t.Fatalf("expected exactly one reload after debounced content changes, got %d", got)
}
time.Sleep(300 * time.Millisecond)
if got := reloadCalls.Load(); got != 1 {
t.Fatalf("expected no extra reload after debounce settles, got %d", got)
}
}
func TestGatewayConfigWatcherTouchDoesNotReload(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"a":1}`), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var reloadCalls atomic.Int32
stop := startGatewayConfigWatcher(ctx, path, 120*time.Millisecond, 30*time.Millisecond, func() error {
reloadCalls.Add(1)
return nil
})
defer stop()
time.Sleep(120 * time.Millisecond)
target := time.Now().Add(2 * time.Second)
if err := os.Chtimes(path, target, target); err != nil {
t.Fatalf("touch file: %v", err)
}
time.Sleep(400 * time.Millisecond)
if got := reloadCalls.Load(); got != 0 {
t.Fatalf("expected touch-only update to skip reload, got %d", got)
}
}

View File

@@ -8,5 +8,5 @@ var tuiEnabled = false
func tuiCmd() {
fmt.Println("TUI is not included in this build.")
fmt.Println("Install the no-channel variant to use `clawgo tui`.")
fmt.Println("Build with `with_tui` tag to use `clawgo tui`.")
}