package main import ( "context" "encoding/base64" "encoding/json" "flag" "fmt" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "git.warky.dev/wdevs/whatshooked/internal/config" "git.warky.dev/wdevs/whatshooked/internal/events" "git.warky.dev/wdevs/whatshooked/internal/hooks" "git.warky.dev/wdevs/whatshooked/internal/logging" "git.warky.dev/wdevs/whatshooked/internal/utils" "git.warky.dev/wdevs/whatshooked/internal/whatsapp" "go.mau.fi/whatsmeow/types" ) var ( configPath = flag.String("config", "config.json", "Path to configuration file") ) type Server struct { config *config.Config whatsappMgr *whatsapp.Manager hookMgr *hooks.Manager httpServer *http.Server eventBus *events.EventBus } func main() { flag.Parse() // Load configuration cfg, err := config.Load(*configPath) if err != nil { fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) os.Exit(1) } // Initialize logging logging.Init(cfg.LogLevel) logging.Info("Starting WhatsHooked server") // Create event bus eventBus := events.NewEventBus() // Create server with config update callback srv := &Server{ config: cfg, eventBus: eventBus, whatsappMgr: whatsapp.NewManager(eventBus, cfg.Media, cfg, *configPath, func(updatedCfg *config.Config) error { return config.Save(*configPath, updatedCfg) }), hookMgr: hooks.NewManager(eventBus), } // Load hooks srv.hookMgr.LoadHooks(cfg.Hooks) // Start hook manager to listen for events srv.hookMgr.Start() // Subscribe to hook success events to handle webhook responses srv.eventBus.Subscribe(events.EventHookSuccess, srv.handleHookResponse) // Start HTTP server for CLI BEFORE connecting to WhatsApp // This ensures all infrastructure is ready before events start flowing srv.startHTTPServer() // Give HTTP server a moment to start time.Sleep(100 * time.Millisecond) logging.Info("HTTP server ready, connecting to WhatsApp accounts") // Connect to WhatsApp accounts ctx := context.Background() for _, waCfg := range cfg.WhatsApp { if err := srv.whatsappMgr.Connect(ctx, waCfg); err != nil { logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err) } } // Wait for interrupt signal sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) <-sigChan logging.Info("Shutting down server") // Graceful shutdown shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if srv.httpServer != nil { srv.httpServer.Shutdown(shutdownCtx) } srv.whatsappMgr.DisconnectAll() logging.Info("Server stopped") } // handleHookResponse processes hook success events to handle two-way communication func (s *Server) handleHookResponse(event events.Event) { // Use event context for sending message ctx := event.Context if ctx == nil { ctx = context.Background() } // Extract response from event data responseData, ok := event.Data["response"] if !ok || responseData == nil { return } // Try to cast to HookResponse resp, ok := responseData.(hooks.HookResponse) if !ok { return } if !resp.SendMessage { return } // Determine which account to use - default to first available if not specified targetAccountID := resp.AccountID if targetAccountID == "" && len(s.config.WhatsApp) > 0 { targetAccountID = s.config.WhatsApp[0].ID } // Format phone number to JID format formattedJID := utils.FormatPhoneToJID(resp.To, s.config.Server.DefaultCountryCode) // Parse JID jid, err := types.ParseJID(formattedJID) if err != nil { logging.Error("Invalid JID in hook response", "jid", formattedJID, "error", err) return } // Send message with context if err := s.whatsappMgr.SendTextMessage(ctx, targetAccountID, jid, resp.Text); err != nil { logging.Error("Failed to send message from hook response", "error", err) } else { logging.Info("Message sent from hook response", "account_id", targetAccountID, "to", resp.To) } } // authMiddleware validates authentication credentials func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Check if any authentication is configured hasAuth := s.config.Server.Username != "" || s.config.Server.Password != "" || s.config.Server.AuthKey != "" if !hasAuth { // No authentication configured, allow access next(w, r) return } authenticated := false // Check for API key authentication (x-api-key header or Authorization bearer token) if s.config.Server.AuthKey != "" { // Check x-api-key header apiKey := r.Header.Get("x-api-key") if apiKey == s.config.Server.AuthKey { authenticated = true } // Check Authorization header for bearer token if !authenticated { authHeader := r.Header.Get("Authorization") if len(authHeader) > 7 && authHeader[:7] == "Bearer " { token := authHeader[7:] if token == s.config.Server.AuthKey { authenticated = true } } } } // Check for username/password authentication (HTTP Basic Auth) if !authenticated && s.config.Server.Username != "" && s.config.Server.Password != "" { username, password, ok := r.BasicAuth() if ok && username == s.config.Server.Username && password == s.config.Server.Password { authenticated = true } } if !authenticated { w.Header().Set("WWW-Authenticate", `Basic realm="WhatsHooked Server"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } next(w, r) } } // startHTTPServer starts the HTTP server for CLI communication func (s *Server) startHTTPServer() { mux := http.NewServeMux() // Health check (no auth required) mux.HandleFunc("/health", s.handleHealth) // Hook management (with auth) mux.HandleFunc("/api/hooks", s.authMiddleware(s.handleHooks)) mux.HandleFunc("/api/hooks/add", s.authMiddleware(s.handleAddHook)) mux.HandleFunc("/api/hooks/remove", s.authMiddleware(s.handleRemoveHook)) // Account management (with auth) mux.HandleFunc("/api/accounts", s.authMiddleware(s.handleAccounts)) mux.HandleFunc("/api/accounts/add", s.authMiddleware(s.handleAddAccount)) // Send messages (with auth) mux.HandleFunc("/api/send", s.authMiddleware(s.handleSendMessage)) mux.HandleFunc("/api/send/image", s.authMiddleware(s.handleSendImage)) mux.HandleFunc("/api/send/video", s.authMiddleware(s.handleSendVideo)) mux.HandleFunc("/api/send/document", s.authMiddleware(s.handleSendDocument)) // Serve media files (with auth) mux.HandleFunc("/api/media/", s.authMiddleware(s.handleServeMedia)) addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port) s.httpServer = &http.Server{ Addr: addr, Handler: mux, } go func() { logging.Info("Starting HTTP server", "host", s.config.Server.Host, "port", s.config.Server.Port, "address", addr, ) logging.Info("HTTP server endpoints available", "health", "/health", "hooks", "/api/hooks", "accounts", "/api/accounts", "send", "/api/send", ) if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { logging.Error("HTTP server error", "error", err) } }() } // HTTP Handlers func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (s *Server) handleHooks(w http.ResponseWriter, r *http.Request) { hooks := s.hookMgr.ListHooks() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(hooks) } func (s *Server) handleAddHook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var hook config.Hook if err := json.NewDecoder(r.Body).Decode(&hook); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } s.hookMgr.AddHook(hook) // Update config s.config.Hooks = s.hookMgr.ListHooks() if err := config.Save(*configPath, s.config); err != nil { logging.Error("Failed to save config", "error", err) } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (s *Server) handleRemoveHook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { ID string `json:"id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := s.hookMgr.RemoveHook(req.ID); err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } // Update config s.config.Hooks = s.hookMgr.ListHooks() if err := config.Save(*configPath, s.config); err != nil { logging.Error("Failed to save config", "error", err) } json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (s *Server) handleAccounts(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(s.config.WhatsApp) } func (s *Server) handleAddAccount(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var account config.WhatsAppConfig if err := json.NewDecoder(r.Body).Decode(&account); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Connect to the account if err := s.whatsappMgr.Connect(context.Background(), account); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Update config s.config.WhatsApp = append(s.config.WhatsApp, account) if err := config.Save(*configPath, s.config); err != nil { logging.Error("Failed to save config", "error", err) } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { AccountID string `json:"account_id"` To string `json:"to"` Text string `json:"text"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Format phone number to JID format formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode) jid, err := types.ParseJID(formattedJID) if err != nil { http.Error(w, "Invalid JID", http.StatusBadRequest) return } if err := s.whatsappMgr.SendTextMessage(r.Context(), req.AccountID, jid, req.Text); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (s *Server) handleSendImage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { AccountID string `json:"account_id"` To string `json:"to"` Caption string `json:"caption"` MimeType string `json:"mime_type"` ImageData string `json:"image_data"` // base64 encoded } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Decode base64 image data imageData, err := base64.StdEncoding.DecodeString(req.ImageData) if err != nil { http.Error(w, "Invalid base64 image data", http.StatusBadRequest) return } // Format phone number to JID format formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode) jid, err := types.ParseJID(formattedJID) if err != nil { http.Error(w, "Invalid JID", http.StatusBadRequest) return } // Default mime type if not provided if req.MimeType == "" { req.MimeType = "image/jpeg" } if err := s.whatsappMgr.SendImage(r.Context(), req.AccountID, jid, imageData, req.MimeType, req.Caption); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (s *Server) handleSendVideo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { AccountID string `json:"account_id"` To string `json:"to"` Caption string `json:"caption"` MimeType string `json:"mime_type"` VideoData string `json:"video_data"` // base64 encoded } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Decode base64 video data videoData, err := base64.StdEncoding.DecodeString(req.VideoData) if err != nil { http.Error(w, "Invalid base64 video data", http.StatusBadRequest) return } // Format phone number to JID format formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode) jid, err := types.ParseJID(formattedJID) if err != nil { http.Error(w, "Invalid JID", http.StatusBadRequest) return } // Default mime type if not provided if req.MimeType == "" { req.MimeType = "video/mp4" } if err := s.whatsappMgr.SendVideo(r.Context(), req.AccountID, jid, videoData, req.MimeType, req.Caption); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (s *Server) handleSendDocument(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { AccountID string `json:"account_id"` To string `json:"to"` Caption string `json:"caption"` MimeType string `json:"mime_type"` Filename string `json:"filename"` DocumentData string `json:"document_data"` // base64 encoded } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Decode base64 document data documentData, err := base64.StdEncoding.DecodeString(req.DocumentData) if err != nil { http.Error(w, "Invalid base64 document data", http.StatusBadRequest) return } // Format phone number to JID format formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode) jid, err := types.ParseJID(formattedJID) if err != nil { http.Error(w, "Invalid JID", http.StatusBadRequest) return } // Default values if not provided if req.MimeType == "" { req.MimeType = "application/octet-stream" } if req.Filename == "" { req.Filename = "document" } if err := s.whatsappMgr.SendDocument(r.Context(), req.AccountID, jid, documentData, req.MimeType, req.Filename, req.Caption); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func (s *Server) handleServeMedia(w http.ResponseWriter, r *http.Request) { // Expected path format: /api/media/{accountID}/{filename} path := r.URL.Path[len("/api/media/"):] // Split path into accountID and filename var accountID, filename string for i, ch := range path { if ch == '/' { accountID = path[:i] filename = path[i+1:] break } } if accountID == "" || filename == "" { http.Error(w, "Invalid media path", http.StatusBadRequest) return } // Construct full file path filePath := filepath.Join(s.config.Media.DataPath, accountID, filename) // Security check: ensure the resolved path is within the media directory mediaDir := filepath.Join(s.config.Media.DataPath, accountID) absFilePath, err := filepath.Abs(filePath) if err != nil { http.Error(w, "Invalid file path", http.StatusBadRequest) return } absMediaDir, err := filepath.Abs(mediaDir) if err != nil { http.Error(w, "Invalid media directory", http.StatusInternalServerError) return } // Check if file path is within media directory (prevent directory traversal) if len(absFilePath) < len(absMediaDir) || absFilePath[:len(absMediaDir)] != absMediaDir { http.Error(w, "Access denied", http.StatusForbidden) return } // Serve the file http.ServeFile(w, r, absFilePath) }