package main import ( "context" "flag" "fmt" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "git.warky.dev/wdevs/whatshooked/internal/config" "git.warky.dev/wdevs/whatshooked/internal/eventlogger" "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", "", "Path to configuration file (optional, defaults to user home directory)") ) type Server struct { config *config.Config configPath string whatsappMgr *whatsapp.Manager hookMgr *hooks.Manager httpServer *http.Server eventBus *events.EventBus eventLogger *eventlogger.Logger } // resolveConfigPath determines the config file path to use // Priority: 1) provided path (if exists), 2) config.json in current dir, 3) .whatshooked/config.json in user home func resolveConfigPath(providedPath string) (string, error) { // If a path was explicitly provided, check if it exists if providedPath != "" { if _, err := os.Stat(providedPath); err == nil { return providedPath, nil } // Directory doesn't exist, fall through to default locations logging.Info("Provided config path directory does not exist, using default locations", "path", providedPath) } // Check for config.json in current directory currentDirConfig := "config.json" if _, err := os.Stat(currentDirConfig); err == nil { return currentDirConfig, nil } // Fall back to user home directory homeDir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get user home directory: %w", err) } // Create .whatshooked directory if it doesn't exist configDir := filepath.Join(homeDir, ".whatshooked") if err := os.MkdirAll(configDir, 0755); err != nil { return "", fmt.Errorf("failed to create config directory: %w", err) } return filepath.Join(configDir, "config.json"), nil } func main() { flag.Parse() // Resolve config path cfgPath, err := resolveConfigPath(*configPath) if err != nil { fmt.Fprintf(os.Stderr, "Failed to resolve config path: %v\n", err) os.Exit(1) } // Load configuration cfg, err := config.Load(cfgPath) if err != nil { fmt.Fprintf(os.Stderr, "Failed to load config from %s: %v\n", cfgPath, err) os.Exit(1) } // Initialize logging logging.Init(cfg.LogLevel) logging.Info("Starting WhatsHooked server", "config_path", cfgPath) // Create event bus eventBus := events.NewEventBus() // Create server with config update callback srv := &Server{ config: cfg, configPath: cfgPath, eventBus: eventBus, whatsappMgr: whatsapp.NewManager(eventBus, cfg.Media, cfg, cfgPath, func(updatedCfg *config.Config) error { return config.Save(cfgPath, updatedCfg) }), hookMgr: hooks.NewManager(eventBus), } // Initialize event logger if enabled if cfg.EventLogger.Enabled && len(cfg.EventLogger.Targets) > 0 { evtLogger, err := eventlogger.NewLogger(cfg.EventLogger, cfg.Database) if err != nil { logging.Error("Failed to initialize event logger", "error", err) } else { srv.eventLogger = evtLogger // Subscribe to all events srv.eventBus.SubscribeAll(func(event events.Event) { srv.eventLogger.Log(event) }) logging.Info("Event logger initialized", "targets", cfg.EventLogger.Targets) } } // 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() // Close event logger if srv.eventLogger != nil { if err := srv.eventLogger.Close(); err != nil { logging.Error("Failed to close event logger", "error", err) } } 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) } }