2026-02-25 07:47:45 +00:00
|
|
|
package gateway
|
2026-02-18 17:03:34 +00:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
2026-02-28 08:24:26 +00:00
|
|
|
"log"
|
2026-02-18 17:03:34 +00:00
|
|
|
"os"
|
|
|
|
|
"os/signal"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-02-25 07:47:45 +00:00
|
|
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
2026-02-18 17:03:34 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/agent"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
2026-02-22 19:47:12 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
|
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
|
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
|
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
2026-02-22 20:55:15 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
2026-02-22 19:47:12 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
|
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
|
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
2026-02-27 06:35:52 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
2026-02-19 16:12:01 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-18 17:03:34 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/cron"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/devices"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/health"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-18 17:03:34 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/state"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/tools"
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-25 07:47:45 +00:00
|
|
|
func gatewayCmd(debug bool) error {
|
|
|
|
|
if debug {
|
|
|
|
|
logger.SetLevel(logger.DEBUG)
|
|
|
|
|
fmt.Println("🔍 Debug mode enabled")
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-25 07:47:45 +00:00
|
|
|
cfg, err := internal.LoadConfig()
|
2026-02-18 17:03:34 +00:00
|
|
|
if err != nil {
|
2026-02-25 07:47:45 +00:00
|
|
|
return fmt.Errorf("error loading config: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
provider, modelID, err := providers.CreateProvider(cfg)
|
|
|
|
|
if err != nil {
|
2026-02-25 07:47:45 +00:00
|
|
|
return fmt.Errorf("error creating provider: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
2026-02-25 07:47:45 +00:00
|
|
|
|
2026-02-18 17:03:34 +00:00
|
|
|
// Use the resolved model ID from provider creation
|
|
|
|
|
if modelID != "" {
|
2026-02-23 08:55:06 +00:00
|
|
|
cfg.Agents.Defaults.ModelName = modelID
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msgBus := bus.NewMessageBus()
|
|
|
|
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
|
|
|
|
|
|
|
|
|
// Print agent startup info
|
|
|
|
|
fmt.Println("\n📦 Agent Status:")
|
|
|
|
|
startupInfo := agentLoop.GetStartupInfo()
|
2026-02-20 18:03:11 +00:00
|
|
|
toolsInfo := startupInfo["tools"].(map[string]any)
|
|
|
|
|
skillsInfo := startupInfo["skills"].(map[string]any)
|
2026-02-18 17:03:34 +00:00
|
|
|
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
|
|
|
|
fmt.Printf(" • Skills: %d/%d available\n",
|
|
|
|
|
skillsInfo["available"],
|
|
|
|
|
skillsInfo["total"])
|
|
|
|
|
|
|
|
|
|
// Log to file as well
|
|
|
|
|
logger.InfoCF("agent", "Agent initialized",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
2026-02-18 17:03:34 +00:00
|
|
|
"tools_count": toolsInfo["count"],
|
|
|
|
|
"skills_total": skillsInfo["total"],
|
|
|
|
|
"skills_available": skillsInfo["available"],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Setup cron tool and service
|
|
|
|
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
2026-02-20 18:03:11 +00:00
|
|
|
cronService := setupCronTool(
|
|
|
|
|
agentLoop,
|
|
|
|
|
msgBus,
|
|
|
|
|
cfg.WorkspacePath(),
|
|
|
|
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
|
|
|
|
execTimeout,
|
|
|
|
|
cfg,
|
|
|
|
|
)
|
2026-02-18 17:03:34 +00:00
|
|
|
|
|
|
|
|
heartbeatService := heartbeat.NewHeartbeatService(
|
|
|
|
|
cfg.WorkspacePath(),
|
|
|
|
|
cfg.Heartbeat.Interval,
|
|
|
|
|
cfg.Heartbeat.Enabled,
|
|
|
|
|
)
|
|
|
|
|
heartbeatService.SetBus(msgBus)
|
|
|
|
|
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
|
|
|
|
// Use cli:direct as fallback if no valid channel
|
|
|
|
|
if channel == "" || chatID == "" {
|
|
|
|
|
channel, chatID = "cli", "direct"
|
|
|
|
|
}
|
|
|
|
|
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
2026-02-20 20:35:16 +00:00
|
|
|
var response string
|
|
|
|
|
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
2026-02-18 17:03:34 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
|
|
|
|
}
|
|
|
|
|
if response == "HEARTBEAT_OK" {
|
|
|
|
|
return tools.SilentResult("Heartbeat OK")
|
|
|
|
|
}
|
|
|
|
|
// For heartbeat, always return silent - the subagent result will be
|
|
|
|
|
// sent to user via processSystemMessage when the async task completes
|
|
|
|
|
return tools.SilentResult(response)
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 12:24:32 +00:00
|
|
|
// Create media store for file lifecycle management with TTL cleanup
|
|
|
|
|
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
|
|
|
|
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
|
|
|
|
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
|
|
|
|
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
|
|
|
|
})
|
|
|
|
|
mediaStore.Start()
|
2026-02-22 15:27:55 +00:00
|
|
|
|
|
|
|
|
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
|
2026-02-18 17:03:34 +00:00
|
|
|
if err != nil {
|
2026-02-26 07:22:49 +00:00
|
|
|
mediaStore.Stop()
|
2026-02-25 07:47:45 +00:00
|
|
|
return fmt.Errorf("error creating channel manager: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 15:27:55 +00:00
|
|
|
// Inject channel manager and media store into agent loop
|
2026-02-18 17:03:34 +00:00
|
|
|
agentLoop.SetChannelManager(channelManager)
|
2026-02-22 15:27:55 +00:00
|
|
|
agentLoop.SetMediaStore(mediaStore)
|
2026-02-18 17:03:34 +00:00
|
|
|
|
|
|
|
|
enabledChannels := channelManager.GetEnabledChannels()
|
|
|
|
|
if len(enabledChannels) > 0 {
|
|
|
|
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
|
|
|
|
} else {
|
|
|
|
|
fmt.Println("⚠ Warning: No channels enabled")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
|
|
|
|
fmt.Println("Press Ctrl+C to stop")
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
if err := cronService.Start(); err != nil {
|
|
|
|
|
fmt.Printf("Error starting cron service: %v\n", err)
|
|
|
|
|
}
|
|
|
|
|
fmt.Println("✓ Cron service started")
|
|
|
|
|
|
|
|
|
|
if err := heartbeatService.Start(); err != nil {
|
|
|
|
|
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
|
|
|
|
}
|
|
|
|
|
fmt.Println("✓ Heartbeat service started")
|
|
|
|
|
|
|
|
|
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
|
|
|
|
deviceService := devices.NewService(devices.Config{
|
|
|
|
|
Enabled: cfg.Devices.Enabled,
|
|
|
|
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
|
|
|
|
}, stateManager)
|
|
|
|
|
deviceService.SetBus(msgBus)
|
|
|
|
|
if err := deviceService.Start(ctx); err != nil {
|
|
|
|
|
fmt.Printf("Error starting device service: %v\n", err)
|
|
|
|
|
} else if cfg.Devices.Enabled {
|
|
|
|
|
fmt.Println("✓ Device event service started")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:39:09 +00:00
|
|
|
// Setup shared HTTP server with health endpoints and webhook handlers
|
|
|
|
|
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
|
|
|
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
|
|
|
|
channelManager.SetupHTTPServer(addr, healthServer)
|
|
|
|
|
|
2026-02-18 17:03:34 +00:00
|
|
|
if err := channelManager.StartAll(ctx); err != nil {
|
|
|
|
|
fmt.Printf("Error starting channels: %v\n", err)
|
2026-02-28 08:04:44 +00:00
|
|
|
return err
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
|
|
|
|
|
|
|
|
|
go agentLoop.Run(ctx)
|
|
|
|
|
|
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
|
|
|
|
signal.Notify(sigChan, os.Interrupt)
|
|
|
|
|
<-sigChan
|
|
|
|
|
|
|
|
|
|
fmt.Println("\nShutting down...")
|
2026-02-24 14:33:04 +00:00
|
|
|
if cp, ok := provider.(providers.StatefulProvider); ok {
|
2026-02-22 14:30:53 +00:00
|
|
|
cp.Close()
|
|
|
|
|
}
|
2026-02-18 17:03:34 +00:00
|
|
|
cancel()
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
msgBus.Close()
|
2026-02-22 22:03:23 +00:00
|
|
|
|
|
|
|
|
// Use a fresh context with timeout for graceful shutdown,
|
2026-02-26 15:36:06 +00:00
|
|
|
// since the original ctx is already canceled.
|
2026-02-22 22:03:23 +00:00
|
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
|
|
|
defer shutdownCancel()
|
|
|
|
|
|
|
|
|
|
channelManager.StopAll(shutdownCtx)
|
2026-02-18 17:03:34 +00:00
|
|
|
deviceService.Stop()
|
|
|
|
|
heartbeatService.Stop()
|
|
|
|
|
cronService.Stop()
|
2026-02-24 12:24:32 +00:00
|
|
|
mediaStore.Stop()
|
2026-02-18 17:03:34 +00:00
|
|
|
agentLoop.Stop()
|
|
|
|
|
fmt.Println("✓ Gateway stopped")
|
2026-02-25 07:47:45 +00:00
|
|
|
|
|
|
|
|
return nil
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
func setupCronTool(
|
|
|
|
|
agentLoop *agent.AgentLoop,
|
|
|
|
|
msgBus *bus.MessageBus,
|
|
|
|
|
workspace string,
|
|
|
|
|
restrict bool,
|
|
|
|
|
execTimeout time.Duration,
|
|
|
|
|
cfg *config.Config,
|
|
|
|
|
) *cron.CronService {
|
2026-02-18 17:03:34 +00:00
|
|
|
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
|
|
|
|
|
|
|
|
|
// Create cron service
|
|
|
|
|
cronService := cron.NewCronService(cronStorePath, nil)
|
|
|
|
|
|
|
|
|
|
// Create and register CronTool
|
2026-02-28 08:24:26 +00:00
|
|
|
cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 17:03:34 +00:00
|
|
|
agentLoop.RegisterTool(cronTool)
|
|
|
|
|
|
|
|
|
|
// Set the onJob handler
|
|
|
|
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
|
|
|
|
result := cronTool.ExecuteJob(context.Background(), job)
|
|
|
|
|
return result, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return cronService
|
|
|
|
|
}
|