diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 10b53948b..50b2b45c9 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -659,17 +659,20 @@ func gatewayCmd() { fmt.Println("✓ Device event service started") } + // Create the gateway HTTP server first so channels can register webhooks on it + healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + channelManager.RegisterWebhooks(healthServer.Mux()) + if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) } - healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) go func() { if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()}) } }() - fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) + fmt.Printf("✓ Gateway listening on http://%s:%d (health, webhooks)\n", cfg.Gateway.Host, cfg.Gateway.Port) go agentLoop.Run(ctx) diff --git a/pkg/channels/line.go b/pkg/channels/line.go index ffb5533e8..ad00efd4b 100644 --- a/pkg/channels/line.go +++ b/pkg/channels/line.go @@ -44,6 +44,7 @@ type LINEChannel struct { *BaseChannel config config.LINEConfig httpServer *http.Server + useSharedMux bool // true when webhook is registered on the gateway mux botUserID string // Bot's user ID botBasicID string // Bot's basic ID (e.g. @216ru...) botDisplayName string // Bot's display name for text-based mention detection @@ -67,6 +68,20 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha }, nil } +// RegisterWebhook registers the LINE webhook handler on a shared HTTP mux +// (typically the gateway mux) so no separate server is needed. +func (c *LINEChannel) RegisterWebhook(mux *http.ServeMux) { + path := c.config.WebhookPath + if path == "" { + path = "/webhook/line" + } + mux.HandleFunc(path, c.webhookHandler) + c.useSharedMux = true + logger.InfoCF("line", "LINE webhook registered on gateway", map[string]interface{}{ + "path": path, + }) +} + // Start launches the HTTP webhook server. func (c *LINEChannel) Start(ctx context.Context) error { logger.InfoC("line", "Starting LINE channel (Webhook Mode)") @@ -86,6 +101,13 @@ func (c *LINEChannel) Start(ctx context.Context) error { }) } + // If webhook was already registered on the shared gateway mux, skip creating a standalone server + if c.useSharedMux { + c.setRunning(true) + logger.InfoC("line", "LINE channel started (shared gateway webhook)") + return nil + } + mux := http.NewServeMux() path := c.config.WebhookPath if path == "" { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 7f6abc4cb..b97912076 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -9,6 +9,7 @@ package channels import ( "context" "fmt" + "net/http" "sync" "github.com/sipeed/picoclaw/pkg/bus" @@ -314,6 +315,19 @@ func (m *Manager) GetEnabledChannels() []string { return names } +// RegisterWebhooks registers webhook routes for channels that support HTTP webhooks +// on the shared gateway mux, so they share the same port as the health server. +func (m *Manager) RegisterWebhooks(mux *http.ServeMux) { + m.mu.RLock() + defer m.mu.RUnlock() + + if line, ok := m.channels["line"]; ok { + if lc, ok := line.(*LINEChannel); ok { + lc.RegisterWebhook(mux) + } + } +} + func (m *Manager) RegisterChannel(name string, channel Channel) { m.mu.Lock() defer m.mu.Unlock() diff --git a/pkg/health/server.go b/pkg/health/server.go index 77b36034d..6453874e5 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -11,6 +11,7 @@ import ( type Server struct { server *http.Server + mux *http.ServeMux mu sync.RWMutex ready bool checks map[string]Check @@ -30,14 +31,23 @@ type StatusResponse struct { Checks map[string]Check `json:"checks,omitempty"` } +type GatewayInfo struct { + Name string `json:"name"` + Status string `json:"status"` + Uptime string `json:"uptime"` + Version string `json:"version"` +} + func NewServer(host string, port int) *Server { mux := http.NewServeMux() s := &Server{ + mux: mux, ready: false, checks: make(map[string]Check), startTime: time.Now(), } + mux.HandleFunc("/", s.rootHandler) mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/ready", s.readyHandler) @@ -52,6 +62,11 @@ func NewServer(host string, port int) *Server { return s } +// Mux returns the HTTP mux so channels can register webhook routes on the gateway port. +func (s *Server) Mux() *http.ServeMux { + return s.mux +} + func (s *Server) Start() error { s.mu.Lock() s.ready = true @@ -103,6 +118,25 @@ func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) { } } +func (s *Server) rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + uptime := time.Since(s.startTime) + resp := GatewayInfo{ + Name: "PicoClaw Gateway", + Status: "running", + Uptime: uptime.String(), + Version: "1.0.0", + } + + json.NewEncoder(w).Encode(resp) +} + func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK)