mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 22:09:37 +08:00
chore: update project files
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user