diff --git a/README.md b/README.md index 7f2c314..82592fc 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,19 @@ # WhatsHooked -A service that connects to WhatsApp and forwards messages to registered webhooks. Supports both personal WhatsApp accounts (via whatsmeow) and WhatsApp Business API. Enables two-way communication by allowing webhooks to respond with messages to be sent through WhatsApp. +A Go library and service that connects to WhatsApp and forwards messages to registered webhooks. Supports both personal WhatsApp accounts (via whatsmeow) and WhatsApp Business API. Enables two-way communication by allowing webhooks to respond with messages to be sent through WhatsApp. + +**Use WhatsHooked as:** +- 📦 **Go Library** - Import into your own applications for programmatic WhatsApp integration +- 🚀 **Standalone Server** - Run as a service with HTTP API and CLI management +- 🔧 **Custom Integration** - Mount individual handlers in your existing HTTP servers ![1.00](./assets/image/whatshooked.jpg) - [TODO LIST](TODO.md) - Things I still need to do [Rules when using AI](AI_USE.md) -## Phase 1 Features +## Features - **Multi-Account Support**: Connect to multiple WhatsApp accounts simultaneously - **Dual Client Types**: Support for both personal WhatsApp (whatsmeow) and WhatsApp Business API @@ -20,19 +24,68 @@ A service that connects to WhatsApp and forwards messages to registered webhooks - **CLI Management**: Command-line tool for managing accounts and hooks - **Structured Logging**: JSON-based logging with configurable log levels - **Authentication**: HTTP Basic Auth and API key authentication for server endpoints +- **Event Logging**: Optional event persistence to file, SQLite, or PostgreSQL +- **Library Mode**: Use WhatsHooked as a Go library in your own applications +- **Flexible Handlers**: Mount individual HTTP handlers in custom servers + +## Quick Start + +### As a Library + +```go +import "git.warky.dev/wdevs/whatshooked/pkg/whatshooked" + +// File-based configuration +wh, err := whatshooked.NewFromFile("config.json") +if err != nil { + panic(err) +} +defer wh.Close() + +// Start built-in server +wh.StartServer() +``` + +Or with programmatic configuration: + +```go +wh, err := whatshooked.New( + whatshooked.WithServer("0.0.0.0", 8080), + whatshooked.WithAuth("my-api-key", "", ""), + whatshooked.WithWhatsmeowAccount("personal", "+1234567890", "./session", true), +) +defer wh.Close() +wh.StartServer() +``` + +### As a Standalone Server + +```bash +make build +./bin/whatshook-server -config config.json +``` ## Architecture The project uses an event-driven architecture with the following packages: -- **internal/config**: Configuration management and persistence -- **internal/logging**: Structured logging using Go's slog package -- **internal/events**: Event bus for publish/subscribe messaging between components -- **internal/whatsapp**: WhatsApp client management (supports both whatsmeow and Business API) +### Library Packages (pkg/) + +- **pkg/whatshooked**: Main library entry point with NewFromFile() and New() constructors +- **pkg/config**: Configuration management and persistence +- **pkg/logging**: Pluggable structured logging interface +- **pkg/events**: Event bus for publish/subscribe messaging between components +- **pkg/whatsapp**: WhatsApp client management (supports both whatsmeow and Business API) - **whatsmeow/**: Personal WhatsApp client implementation - **businessapi/**: WhatsApp Business API client implementation -- **internal/hooks**: Webhook management and message forwarding -- **cmd/server**: Main server application +- **pkg/hooks**: Webhook management and message forwarding +- **pkg/handlers**: HTTP handlers that can be mounted in any server +- **pkg/eventlogger**: Event persistence to file/SQLite/PostgreSQL +- **pkg/utils**: Utility functions (phone formatting, etc.) + +### Application Packages (cmd/) + +- **cmd/server**: Standalone server application (thin wrapper around library) - **cmd/cli**: Command-line interface for management ### Event-Driven Architecture @@ -57,7 +110,186 @@ This architecture enables: - Context propagation for cancellation and timeout handling - Proper request lifecycle management across components -## Installation +## Using WhatsHooked as a Library + +### Installation + +```bash +go get git.warky.dev/wdevs/whatshooked/pkg/whatshooked +``` + +### Example 1: Custom Server with Individual Handlers + +Mount WhatsHooked handlers at custom paths in your existing HTTP server: + +```go +package main + +import ( + "net/http" + "git.warky.dev/wdevs/whatshooked/pkg/whatshooked" +) + +func main() { + // Initialize from config file + wh, err := whatshooked.NewFromFile("config.json") + if err != nil { + panic(err) + } + defer wh.Close() + + // Get handlers + h := wh.Handlers() + + // Custom HTTP server with your own routing + mux := http.NewServeMux() + + // Mount WhatsHooked handlers at custom paths + mux.HandleFunc("/api/v1/whatsapp/send", h.Auth(h.SendMessage)) + mux.HandleFunc("/api/v1/whatsapp/send/image", h.Auth(h.SendImage)) + mux.HandleFunc("/api/v1/accounts", h.Auth(h.Accounts)) + mux.HandleFunc("/healthz", h.Health) + + // Your own handlers + mux.HandleFunc("/api/v1/custom", yourCustomHandler) + + http.ListenAndServe(":8080", mux) +} +``` + +### Example 2: Programmatic Configuration + +Configure WhatsHooked entirely in code without a config file: + +```go +wh, err := whatshooked.New( + whatshooked.WithServer("0.0.0.0", 8080), + whatshooked.WithAuth("my-api-key", "", ""), + whatshooked.WithWhatsmeowAccount( + "personal", + "+1234567890", + "./sessions/personal", + true, // show QR + ), + whatshooked.WithBusinessAPIAccount( + "business", + "+9876543210", + "phone-number-id", + "access-token", + "verify-token", + ), + whatshooked.WithHook(config.Hook{ + ID: "webhook1", + Name: "My Webhook", + URL: "https://example.com/webhook", + Method: "POST", + Active: true, + Events: []string{"message.received"}, + }), + whatshooked.WithEventLogger([]string{"file", "sqlite"}, "./events"), + whatshooked.WithLogLevel("debug"), +) +if err != nil { + panic(err) +} +defer wh.Close() + +// Use built-in server +wh.StartServer() +``` + +### Example 3: Embedded Library (No HTTP Server) + +Use WhatsHooked purely as a library for programmatic WhatsApp access: + +```go +wh, err := whatshooked.NewFromFile("config.json") +if err != nil { + panic(err) +} +defer wh.Close() + +// Connect to WhatsApp accounts +ctx := context.Background() +if err := wh.ConnectAll(ctx); err != nil { + panic(err) +} + +// Listen for incoming messages +wh.EventBus().Subscribe(events.EventMessageReceived, func(e events.Event) { + fmt.Printf("Received message: %s from %s\n", + e.Data["text"], e.Data["from"]) + + // Process message in your application + processMessage(e.Data) +}) + +// Send messages programmatically +jid, _ := types.ParseJID("27834606792@s.whatsapp.net") +err = wh.Manager().SendTextMessage(ctx, "account1", jid, "Hello from code!") +``` + +### Example 4: Custom Authentication + +Replace the default authentication with your own (e.g., JWT): + +```go +wh, err := whatshooked.NewFromFile("config.json") +if err != nil { + panic(err) +} + +h := wh.Handlers() + +// Use custom JWT authentication +h.WithAuthConfig(&handlers.AuthConfig{ + Validator: func(r *http.Request) bool { + token := r.Header.Get("Authorization") + return validateJWTToken(token) // your JWT validation + }, +}) + +// Or disable auth entirely +h.WithAuthConfig(&handlers.AuthConfig{ + Disabled: true, +}) +``` + +### Library API + +```go +// Main WhatsHooked instance +type WhatsHooked struct { ... } + +// Constructors +func NewFromFile(configPath string) (*WhatsHooked, error) +func New(opts ...Option) (*WhatsHooked, error) + +// Methods +func (wh *WhatsHooked) Handlers() *handlers.Handlers // Get HTTP handlers +func (wh *WhatsHooked) Manager() *whatsapp.Manager // Get WhatsApp manager +func (wh *WhatsHooked) EventBus() *events.EventBus // Get event bus +func (wh *WhatsHooked) HookManager() *hooks.Manager // Get hook manager +func (wh *WhatsHooked) Config() *config.Config // Get configuration +func (wh *WhatsHooked) ConnectAll(ctx context.Context) error // Connect all accounts +func (wh *WhatsHooked) StartServer() error // Start built-in HTTP server +func (wh *WhatsHooked) StopServer(ctx context.Context) error // Stop server +func (wh *WhatsHooked) Close() error // Graceful shutdown + +// Configuration Options +func WithServer(host string, port int) Option +func WithAuth(apiKey, username, password string) Option +func WithWhatsmeowAccount(id, phoneNumber, sessionPath string, showQR bool) Option +func WithBusinessAPIAccount(id, phoneNumber, phoneNumberID, accessToken, verifyToken string) Option +func WithHook(hook config.Hook) Option +func WithEventLogger(targets []string, fileDir string) Option +func WithMedia(dataPath, mode, baseURL string) Option +func WithLogLevel(level string) Option +func WithDatabase(dbType, host string, port int, username, password, database string) Option +func WithSQLiteDatabase(sqlitePath string) Option +``` + +## Installation (Standalone Server) ### Build from source @@ -161,6 +393,7 @@ Edit the configuration file to add your WhatsApp accounts and webhooks: - `method`: HTTP method (usually "POST") - `headers`: Optional HTTP headers - `active`: Whether this hook is enabled +- `events`: List of event types to subscribe to (optional, defaults to all) - `description`: Optional description ### Server Authentication @@ -566,28 +799,41 @@ The server accepts both full JID format and plain phone numbers. When using plai ``` whatshooked/ ├── cmd/ -│ ├── server/ # Main server application -│ │ ├── main.go -│ │ ├── routes.go -│ │ ├── routes_*.go # Route handlers -│ │ └── routes_businessapi.go # Business API webhooks +│ ├── server/ # Standalone server (thin wrapper) +│ │ └── main.go │ └── cli/ # CLI tool -├── internal/ -│ ├── config/ # Configuration management -│ ├── events/ # Event bus and event types -│ ├── logging/ # Structured logging +│ ├── main.go +│ └── commands_*.go +├── pkg/ # Public library packages +│ ├── whatshooked/ # Main library entry point +│ │ ├── whatshooked.go # NewFromFile(), New() +│ │ ├── options.go # Functional options +│ │ └── server.go # Built-in HTTP server +│ ├── handlers/ # HTTP handlers +│ │ ├── handlers.go # Handler struct +│ │ ├── middleware.go # Auth middleware +│ │ ├── send.go # Send handlers +│ │ ├── accounts.go # Account handlers +│ │ ├── hooks.go # Hook handlers +│ │ ├── media.go # Media handlers +│ │ ├── health.go # Health handler +│ │ └── businessapi.go # Business API webhook +│ ├── config/ # Configuration types +│ ├── events/ # Event bus +│ ├── logging/ # Pluggable logging │ ├── whatsapp/ # WhatsApp client management │ │ ├── interface.go # Client interface │ │ ├── manager.go # Multi-client manager -│ │ ├── whatsmeow/ # Personal WhatsApp (QR code) +│ │ ├── whatsmeow/ # Personal WhatsApp │ │ │ └── client.go │ │ └── businessapi/ # WhatsApp Business API -│ │ ├── client.go # API client -│ │ ├── types.go # Request/response types -│ │ ├── events.go # Webhook processing -│ │ └── media.go # Media upload/download +│ │ ├── client.go +│ │ ├── types.go +│ │ ├── events.go +│ │ └── media.go │ ├── hooks/ # Webhook management -│ └── utils/ # Utility functions (phone formatting, etc.) +│ ├── eventlogger/ # Event persistence +│ └── utils/ # Utility functions ├── config.example.json # Example configuration └── go.mod # Go module definition ``` @@ -628,9 +874,8 @@ go test ./... go build ./... ``` -## Future Phases +## Future Plans -### Phase 2 (Planned) - User level hooks and WhatsApp accounts - Web server with frontend UI - Enhanced authentication with user roles and permissions diff --git a/cmd/cli/commands_accounts.go b/cmd/cli/commands_accounts.go index c454b70..3bf09a9 100644 --- a/cmd/cli/commands_accounts.go +++ b/cmd/cli/commands_accounts.go @@ -3,7 +3,7 @@ package main import ( "fmt" - "git.warky.dev/wdevs/whatshooked/internal/config" + "git.warky.dev/wdevs/whatshooked/pkg/config" "github.com/spf13/cobra" ) diff --git a/cmd/cli/commands_hooks.go b/cmd/cli/commands_hooks.go index 90e92cb..9f6d8c7 100644 --- a/cmd/cli/commands_hooks.go +++ b/cmd/cli/commands_hooks.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "git.warky.dev/wdevs/whatshooked/internal/config" + "git.warky.dev/wdevs/whatshooked/pkg/config" "github.com/spf13/cobra" ) diff --git a/cmd/server/main.go b/cmd/server/main.go index 73c092d..0874f53 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,37 +4,20 @@ 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" + "git.warky.dev/wdevs/whatshooked/pkg/logging" + "git.warky.dev/wdevs/whatshooked/pkg/whatshooked" ) 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) { @@ -44,7 +27,7 @@ func resolveConfigPath(providedPath string) (string, error) { 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) + fmt.Fprintf(os.Stderr, "Provided config path directory does not exist, using default locations: %s\n", providedPath) } // Check for config.json in current directory @@ -78,70 +61,21 @@ func main() { os.Exit(1) } - // Load configuration - cfg, err := config.Load(cfgPath) + // Create WhatsHooked instance from config file + wh, err := whatshooked.NewFromFile(cfgPath) if err != nil { - fmt.Fprintf(os.Stderr, "Failed to load config from %s: %v\n", cfgPath, err) + fmt.Fprintf(os.Stderr, "Failed to initialize WhatsHooked 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) + // Start the built-in HTTP server (non-blocking goroutine) + go func() { + if err := wh.StartServer(); err != nil { + logging.Error("HTTP server error", "error", err) } - } - - // 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) @@ -154,66 +88,15 @@ func main() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if srv.httpServer != nil { - srv.httpServer.Shutdown(shutdownCtx) + // Stop server + if err := wh.StopServer(shutdownCtx); err != nil { + logging.Error("Error stopping server", "error", err) } - 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) - } + // Close WhatsHooked (disconnects WhatsApp, closes event logger, etc.) + if err := wh.Close(); err != nil { + logging.Error("Error closing WhatsHooked", "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) - } -} diff --git a/cmd/server/middleware.go b/cmd/server/middleware.go deleted file mode 100644 index 4bbe050..0000000 --- a/cmd/server/middleware.go +++ /dev/null @@ -1,57 +0,0 @@ -package main - -import ( - "net/http" -) - -// 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) - } -} diff --git a/cmd/server/routes.go b/cmd/server/routes.go deleted file mode 100644 index 25da23a..0000000 --- a/cmd/server/routes.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - - "git.warky.dev/wdevs/whatshooked/internal/logging" -) - -// setupRoutes configures all HTTP routes for the server -func (s *Server) setupRoutes() *http.ServeMux { - 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.handleServeMedia) - - // Business API webhooks (no auth - Meta validates via verify_token) - mux.HandleFunc("/webhooks/whatsapp/", s.handleBusinessAPIWebhook) - - return mux -} - -// startHTTPServer starts the HTTP server for CLI communication -func (s *Server) startHTTPServer() { - mux := s.setupRoutes() - - 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) - } - }() -} diff --git a/cmd/server/routes_accounts.go b/cmd/server/routes_accounts.go deleted file mode 100644 index de6e335..0000000 --- a/cmd/server/routes_accounts.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "net/http" - - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/logging" -) - -// handleAccounts returns the list of all configured WhatsApp accounts -func (s *Server) handleAccounts(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(s.config.WhatsApp) -} - -// handleAddAccount adds a new WhatsApp account to the system -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(s.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"}) -} diff --git a/cmd/server/routes_health.go b/cmd/server/routes_health.go deleted file mode 100644 index b97b6f5..0000000 --- a/cmd/server/routes_health.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http" -) - -// handleHealth handles health check requests -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} diff --git a/internal/logging/logger.go b/internal/logging/logger.go deleted file mode 100644 index 7802956..0000000 --- a/internal/logging/logger.go +++ /dev/null @@ -1,70 +0,0 @@ -package logging - -import ( - "log/slog" - "os" - "strings" -) - -var logger *slog.Logger - -// Init initializes the logger with the specified log level -func Init(level string) { - var logLevel slog.Level - switch strings.ToLower(level) { - case "debug": - logLevel = slog.LevelDebug - case "info": - logLevel = slog.LevelInfo - case "warn", "warning": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo - } - - opts := &slog.HandlerOptions{ - Level: logLevel, - } - - handler := slog.NewJSONHandler(os.Stdout, opts) - logger = slog.New(handler) - slog.SetDefault(logger) -} - -// Debug logs a debug message -func Debug(msg string, args ...any) { - if logger != nil { - logger.Debug(msg, args...) - } -} - -// Info logs an info message -func Info(msg string, args ...any) { - if logger != nil { - logger.Info(msg, args...) - } -} - -// Warn logs a warning message -func Warn(msg string, args ...any) { - if logger != nil { - logger.Warn(msg, args...) - } -} - -// Error logs an error message -func Error(msg string, args ...any) { - if logger != nil { - logger.Error(msg, args...) - } -} - -// With returns a new logger with additional attributes -func With(args ...any) *slog.Logger { - if logger != nil { - return logger.With(args...) - } - return slog.Default() -} diff --git a/internal/config/config.go b/pkg/config/config.go similarity index 100% rename from internal/config/config.go rename to pkg/config/config.go diff --git a/internal/eventlogger/eventlogger.go b/pkg/eventlogger/eventlogger.go similarity index 94% rename from internal/eventlogger/eventlogger.go rename to pkg/eventlogger/eventlogger.go index 656e12d..8b74a55 100644 --- a/internal/eventlogger/eventlogger.go +++ b/pkg/eventlogger/eventlogger.go @@ -5,9 +5,9 @@ import ( "strings" "sync" - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/events" - "git.warky.dev/wdevs/whatshooked/internal/logging" + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/events" + "git.warky.dev/wdevs/whatshooked/pkg/logging" ) // Logger handles event logging to multiple targets diff --git a/internal/eventlogger/file_target.go b/pkg/eventlogger/file_target.go similarity index 96% rename from internal/eventlogger/file_target.go rename to pkg/eventlogger/file_target.go index 5819aec..701d895 100644 --- a/internal/eventlogger/file_target.go +++ b/pkg/eventlogger/file_target.go @@ -7,7 +7,7 @@ import ( "path/filepath" "sync" - "git.warky.dev/wdevs/whatshooked/internal/events" + "git.warky.dev/wdevs/whatshooked/pkg/events" ) // FileTarget logs events to organized file structure diff --git a/internal/eventlogger/postgres_target.go b/pkg/eventlogger/postgres_target.go similarity index 96% rename from internal/eventlogger/postgres_target.go rename to pkg/eventlogger/postgres_target.go index d18c7c3..f18a21f 100644 --- a/internal/eventlogger/postgres_target.go +++ b/pkg/eventlogger/postgres_target.go @@ -8,8 +8,8 @@ import ( "sync" "time" - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/events" + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/events" _ "github.com/lib/pq" // PostgreSQL driver ) diff --git a/internal/eventlogger/sqlite_target.go b/pkg/eventlogger/sqlite_target.go similarity index 96% rename from internal/eventlogger/sqlite_target.go rename to pkg/eventlogger/sqlite_target.go index 58d83b5..9512af8 100644 --- a/internal/eventlogger/sqlite_target.go +++ b/pkg/eventlogger/sqlite_target.go @@ -8,8 +8,8 @@ import ( "path/filepath" "sync" - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/events" + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/events" _ "github.com/mattn/go-sqlite3" // SQLite driver ) diff --git a/internal/events/builders.go b/pkg/events/builders.go similarity index 100% rename from internal/events/builders.go rename to pkg/events/builders.go diff --git a/internal/events/events.go b/pkg/events/events.go similarity index 100% rename from internal/events/events.go rename to pkg/events/events.go diff --git a/pkg/handlers/accounts.go b/pkg/handlers/accounts.go new file mode 100644 index 0000000..e85fe38 --- /dev/null +++ b/pkg/handlers/accounts.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/logging" +) + +// Accounts returns the list of all configured WhatsApp accounts +func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(h.config.WhatsApp) +} + +// AddAccount adds a new WhatsApp account to the system +func (h *Handlers) AddAccount(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 := h.whatsappMgr.Connect(context.Background(), account); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Update config + h.config.WhatsApp = append(h.config.WhatsApp, account) + if h.configPath != "" { + if err := config.Save(h.configPath, h.config); err != nil { + logging.Error("Failed to save config", "error", err) + } + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/cmd/server/routes_businessapi.go b/pkg/handlers/businessapi.go similarity index 81% rename from cmd/server/routes_businessapi.go rename to pkg/handlers/businessapi.go index c18d3d7..c7d1734 100644 --- a/cmd/server/routes_businessapi.go +++ b/pkg/handlers/businessapi.go @@ -1,31 +1,31 @@ -package main +package handlers import ( "net/http" "strings" - "git.warky.dev/wdevs/whatshooked/internal/logging" - "git.warky.dev/wdevs/whatshooked/internal/whatsapp/businessapi" + "git.warky.dev/wdevs/whatshooked/pkg/logging" + "git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi" ) -// handleBusinessAPIWebhook handles both verification (GET) and webhook events (POST) -func (s *Server) handleBusinessAPIWebhook(w http.ResponseWriter, r *http.Request) { +// BusinessAPIWebhook handles both verification (GET) and webhook events (POST) +func (h *Handlers) BusinessAPIWebhook(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - s.handleBusinessAPIWebhookVerify(w, r) + h.businessAPIWebhookVerify(w, r) return } if r.Method == http.MethodPost { - s.handleBusinessAPIWebhookEvent(w, r) + h.businessAPIWebhookEvent(w, r) return } http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } -// handleBusinessAPIWebhookVerify handles webhook verification from Meta +// businessAPIWebhookVerify handles webhook verification from Meta // GET /webhooks/whatsapp/{accountID}?hub.mode=subscribe&hub.verify_token=XXX&hub.challenge=YYY -func (s *Server) handleBusinessAPIWebhookVerify(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) businessAPIWebhookVerify(w http.ResponseWriter, r *http.Request) { // Extract account ID from URL path accountID := extractAccountIDFromPath(r.URL.Path) if accountID == "" { @@ -40,7 +40,7 @@ func (s *Server) handleBusinessAPIWebhookVerify(w http.ResponseWriter, r *http.R VerifyToken string } - for _, cfg := range s.config.WhatsApp { + for _, cfg := range h.config.WhatsApp { if cfg.ID == accountID && cfg.Type == "business-api" { if cfg.BusinessAPI != nil { accountConfig = &struct { @@ -88,9 +88,9 @@ func (s *Server) handleBusinessAPIWebhookVerify(w http.ResponseWriter, r *http.R http.Error(w, "Forbidden", http.StatusForbidden) } -// handleBusinessAPIWebhookEvent handles incoming webhook events from Meta +// businessAPIWebhookEvent handles incoming webhook events from Meta // POST /webhooks/whatsapp/{accountID} -func (s *Server) handleBusinessAPIWebhookEvent(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) businessAPIWebhookEvent(w http.ResponseWriter, r *http.Request) { // Extract account ID from URL path accountID := extractAccountIDFromPath(r.URL.Path) if accountID == "" { @@ -99,7 +99,7 @@ func (s *Server) handleBusinessAPIWebhookEvent(w http.ResponseWriter, r *http.Re } // Get the client from the manager - client, exists := s.whatsappMgr.GetClient(accountID) + client, exists := h.whatsappMgr.GetClient(accountID) if !exists { logging.Error("Client not found for webhook", "account_id", accountID) http.Error(w, "Account not found", http.StatusNotFound) diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go new file mode 100644 index 0000000..cc40cd9 --- /dev/null +++ b/pkg/handlers/handlers.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "net/http" + + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/hooks" + "git.warky.dev/wdevs/whatshooked/pkg/whatsapp" +) + +// Handlers holds all HTTP handlers with their dependencies +type Handlers struct { + whatsappMgr *whatsapp.Manager + hookMgr *hooks.Manager + config *config.Config + configPath string + + // Auth configuration + authConfig *AuthConfig +} + +// AuthConfig configures authentication behavior +type AuthConfig struct { + // Validator is a custom auth validator function + // If nil, uses built-in auth (API key, basic auth) + Validator func(r *http.Request) bool + + // Built-in auth settings + APIKey string + Username string + Password string + + // Skip auth entirely (not recommended for production) + Disabled bool +} + +// New creates a new Handlers instance +func New(mgr *whatsapp.Manager, hookMgr *hooks.Manager, cfg *config.Config, configPath string) *Handlers { + return &Handlers{ + whatsappMgr: mgr, + hookMgr: hookMgr, + config: cfg, + configPath: configPath, + authConfig: &AuthConfig{ + APIKey: cfg.Server.AuthKey, + Username: cfg.Server.Username, + Password: cfg.Server.Password, + }, + } +} + +// WithAuthConfig sets custom auth configuration +func (h *Handlers) WithAuthConfig(cfg *AuthConfig) *Handlers { + h.authConfig = cfg + return h +} diff --git a/pkg/handlers/health.go b/pkg/handlers/health.go new file mode 100644 index 0000000..1fb715f --- /dev/null +++ b/pkg/handlers/health.go @@ -0,0 +1,11 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// Health handles health check requests +func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/cmd/server/routes_hooks.go b/pkg/handlers/hooks.go similarity index 50% rename from cmd/server/routes_hooks.go rename to pkg/handlers/hooks.go index 184ad78..d251a09 100644 --- a/cmd/server/routes_hooks.go +++ b/pkg/handlers/hooks.go @@ -1,22 +1,22 @@ -package main +package handlers import ( "encoding/json" "net/http" - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/logging" + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/logging" ) -// handleHooks returns the list of all configured hooks -func (s *Server) handleHooks(w http.ResponseWriter, r *http.Request) { - hooks := s.hookMgr.ListHooks() +// Hooks returns the list of all configured hooks +func (h *Handlers) Hooks(w http.ResponseWriter, r *http.Request) { + hooks := h.hookMgr.ListHooks() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(hooks) } -// handleAddHook adds a new hook to the system -func (s *Server) handleAddHook(w http.ResponseWriter, r *http.Request) { +// AddHook adds a new hook to the system +func (h *Handlers) AddHook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -28,20 +28,22 @@ func (s *Server) handleAddHook(w http.ResponseWriter, r *http.Request) { return } - s.hookMgr.AddHook(hook) + h.hookMgr.AddHook(hook) // Update config - s.config.Hooks = s.hookMgr.ListHooks() - if err := config.Save(s.configPath, s.config); err != nil { - logging.Error("Failed to save config", "error", err) + h.config.Hooks = h.hookMgr.ListHooks() + if h.configPath != "" { + if err := config.Save(h.configPath, h.config); err != nil { + logging.Error("Failed to save config", "error", err) + } } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } -// handleRemoveHook removes a hook from the system -func (s *Server) handleRemoveHook(w http.ResponseWriter, r *http.Request) { +// RemoveHook removes a hook from the system +func (h *Handlers) RemoveHook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -55,15 +57,17 @@ func (s *Server) handleRemoveHook(w http.ResponseWriter, r *http.Request) { return } - if err := s.hookMgr.RemoveHook(req.ID); err != nil { + if err := h.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(s.configPath, s.config); err != nil { - logging.Error("Failed to save config", "error", err) + h.config.Hooks = h.hookMgr.ListHooks() + if h.configPath != "" { + if err := config.Save(h.configPath, h.config); err != nil { + logging.Error("Failed to save config", "error", err) + } } json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) diff --git a/cmd/server/routes_media.go b/pkg/handlers/media.go similarity index 79% rename from cmd/server/routes_media.go rename to pkg/handlers/media.go index d6bb597..b3ae42d 100644 --- a/cmd/server/routes_media.go +++ b/pkg/handlers/media.go @@ -1,12 +1,12 @@ -package main +package handlers import ( "net/http" "path/filepath" ) -// handleServeMedia serves media files with path traversal protection -func (s *Server) handleServeMedia(w http.ResponseWriter, r *http.Request) { +// ServeMedia serves media files with path traversal protection +func (h *Handlers) ServeMedia(w http.ResponseWriter, r *http.Request) { // Expected path format: /api/media/{accountID}/{filename} path := r.URL.Path[len("/api/media/"):] @@ -26,10 +26,10 @@ func (s *Server) handleServeMedia(w http.ResponseWriter, r *http.Request) { } // Construct full file path - filePath := filepath.Join(s.config.Media.DataPath, accountID, filename) + filePath := filepath.Join(h.config.Media.DataPath, accountID, filename) // Security check: ensure the resolved path is within the media directory - mediaDir := filepath.Join(s.config.Media.DataPath, accountID) + mediaDir := filepath.Join(h.config.Media.DataPath, accountID) absFilePath, err := filepath.Abs(filePath) if err != nil { http.Error(w, "Invalid file path", http.StatusBadRequest) diff --git a/pkg/handlers/middleware.go b/pkg/handlers/middleware.go new file mode 100644 index 0000000..a04e1a1 --- /dev/null +++ b/pkg/handlers/middleware.go @@ -0,0 +1,71 @@ +package handlers + +import "net/http" + +// Auth is the middleware that wraps handlers with authentication +func (h *Handlers) Auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // If auth is disabled + if h.authConfig.Disabled { + next(w, r) + return + } + + // If custom validator is provided + if h.authConfig.Validator != nil { + if h.authConfig.Validator(r) { + next(w, r) + return + } + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Built-in auth logic (API key, basic auth) + if h.validateBuiltinAuth(r) { + next(w, r) + return + } + + w.Header().Set("WWW-Authenticate", `Basic realm="WhatsHooked"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } +} + +// validateBuiltinAuth checks API key or basic auth +func (h *Handlers) validateBuiltinAuth(r *http.Request) bool { + // Check if any authentication is configured + hasAuth := h.authConfig.APIKey != "" || h.authConfig.Username != "" || h.authConfig.Password != "" + if !hasAuth { + // No authentication configured, allow access + return true + } + + // Check for API key authentication (x-api-key header or Authorization bearer token) + if h.authConfig.APIKey != "" { + // Check x-api-key header + apiKey := r.Header.Get("x-api-key") + if apiKey == h.authConfig.APIKey { + return true + } + + // Check Authorization header for bearer token + authHeader := r.Header.Get("Authorization") + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + token := authHeader[7:] + if token == h.authConfig.APIKey { + return true + } + } + } + + // Check for username/password authentication (HTTP Basic Auth) + if h.authConfig.Username != "" && h.authConfig.Password != "" { + username, password, ok := r.BasicAuth() + if ok && username == h.authConfig.Username && password == h.authConfig.Password { + return true + } + } + + return false +} diff --git a/cmd/server/routes_send.go b/pkg/handlers/send.go similarity index 80% rename from cmd/server/routes_send.go rename to pkg/handlers/send.go index c80cb9b..39a9c9b 100644 --- a/cmd/server/routes_send.go +++ b/pkg/handlers/send.go @@ -1,16 +1,16 @@ -package main +package handlers import ( "encoding/base64" "encoding/json" "net/http" - "git.warky.dev/wdevs/whatshooked/internal/utils" + "git.warky.dev/wdevs/whatshooked/pkg/utils" "go.mau.fi/whatsmeow/types" ) -// handleSendMessage sends a text message via WhatsApp -func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) { +// SendMessage sends a text message via WhatsApp +func (h *Handlers) SendMessage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -27,7 +27,7 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) { } // Format phone number to JID format - formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode) + formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode) jid, err := types.ParseJID(formattedJID) if err != nil { @@ -35,7 +35,7 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) { return } - if err := s.whatsappMgr.SendTextMessage(r.Context(), req.AccountID, jid, req.Text); err != nil { + if err := h.whatsappMgr.SendTextMessage(r.Context(), req.AccountID, jid, req.Text); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -43,8 +43,8 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } -// handleSendImage sends an image via WhatsApp -func (s *Server) handleSendImage(w http.ResponseWriter, r *http.Request) { +// SendImage sends an image via WhatsApp +func (h *Handlers) SendImage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -70,7 +70,7 @@ func (s *Server) handleSendImage(w http.ResponseWriter, r *http.Request) { } // Format phone number to JID format - formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode) + formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode) jid, err := types.ParseJID(formattedJID) if err != nil { http.Error(w, "Invalid JID", http.StatusBadRequest) @@ -82,7 +82,7 @@ func (s *Server) handleSendImage(w http.ResponseWriter, r *http.Request) { req.MimeType = "image/jpeg" } - if err := s.whatsappMgr.SendImage(r.Context(), req.AccountID, jid, imageData, req.MimeType, req.Caption); err != nil { + if err := h.whatsappMgr.SendImage(r.Context(), req.AccountID, jid, imageData, req.MimeType, req.Caption); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -90,8 +90,8 @@ func (s *Server) handleSendImage(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } -// handleSendVideo sends a video via WhatsApp -func (s *Server) handleSendVideo(w http.ResponseWriter, r *http.Request) { +// SendVideo sends a video via WhatsApp +func (h *Handlers) SendVideo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -117,7 +117,7 @@ func (s *Server) handleSendVideo(w http.ResponseWriter, r *http.Request) { } // Format phone number to JID format - formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode) + formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode) jid, err := types.ParseJID(formattedJID) if err != nil { http.Error(w, "Invalid JID", http.StatusBadRequest) @@ -129,7 +129,7 @@ func (s *Server) handleSendVideo(w http.ResponseWriter, r *http.Request) { req.MimeType = "video/mp4" } - if err := s.whatsappMgr.SendVideo(r.Context(), req.AccountID, jid, videoData, req.MimeType, req.Caption); err != nil { + if err := h.whatsappMgr.SendVideo(r.Context(), req.AccountID, jid, videoData, req.MimeType, req.Caption); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -137,8 +137,8 @@ func (s *Server) handleSendVideo(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } -// handleSendDocument sends a document via WhatsApp -func (s *Server) handleSendDocument(w http.ResponseWriter, r *http.Request) { +// SendDocument sends a document via WhatsApp +func (h *Handlers) SendDocument(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -165,7 +165,7 @@ func (s *Server) handleSendDocument(w http.ResponseWriter, r *http.Request) { } // Format phone number to JID format - formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode) + formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode) jid, err := types.ParseJID(formattedJID) if err != nil { http.Error(w, "Invalid JID", http.StatusBadRequest) @@ -180,7 +180,7 @@ func (s *Server) handleSendDocument(w http.ResponseWriter, r *http.Request) { req.Filename = "document" } - if err := s.whatsappMgr.SendDocument(r.Context(), req.AccountID, jid, documentData, req.MimeType, req.Filename, req.Caption); err != nil { + if err := h.whatsappMgr.SendDocument(r.Context(), req.AccountID, jid, documentData, req.MimeType, req.Filename, req.Caption); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/hooks/manager.go b/pkg/hooks/manager.go similarity index 98% rename from internal/hooks/manager.go rename to pkg/hooks/manager.go index 3f29e4a..4d9a579 100644 --- a/internal/hooks/manager.go +++ b/pkg/hooks/manager.go @@ -11,9 +11,9 @@ import ( "sync" "time" - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/events" - "git.warky.dev/wdevs/whatshooked/internal/logging" + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/events" + "git.warky.dev/wdevs/whatshooked/pkg/logging" ) // MediaInfo represents media attachment information diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go new file mode 100644 index 0000000..054800e --- /dev/null +++ b/pkg/logging/logging.go @@ -0,0 +1,56 @@ +package logging + +import "log/slog" + +// Logger interface allows users to plug in their own logger +type Logger interface { + Debug(msg string, args ...any) + Info(msg string, args ...any) + Warn(msg string, args ...any) + Error(msg string, args ...any) +} + +var defaultLogger Logger = &slogLogger{logger: slog.Default()} + +// SetLogger allows users to plug in their own logger +func SetLogger(l Logger) { + defaultLogger = l +} + +// Init initializes the default slog logger with a specific log level +func Init(level string) { + var slogLevel slog.Level + switch level { + case "debug": + slogLevel = slog.LevelDebug + case "info": + slogLevel = slog.LevelInfo + case "warn": + slogLevel = slog.LevelWarn + case "error": + slogLevel = slog.LevelError + default: + slogLevel = slog.LevelInfo + } + + handler := slog.NewTextHandler(nil, &slog.HandlerOptions{ + Level: slogLevel, + }) + defaultLogger = &slogLogger{logger: slog.New(handler)} +} + +// Default slog implementation +type slogLogger struct { + logger *slog.Logger +} + +func (s *slogLogger) Debug(msg string, args ...any) { s.logger.Debug(msg, args...) } +func (s *slogLogger) Info(msg string, args ...any) { s.logger.Info(msg, args...) } +func (s *slogLogger) Warn(msg string, args ...any) { s.logger.Warn(msg, args...) } +func (s *slogLogger) Error(msg string, args ...any) { s.logger.Error(msg, args...) } + +// Package-level functions for convenience +func Debug(msg string, args ...any) { defaultLogger.Debug(msg, args...) } +func Info(msg string, args ...any) { defaultLogger.Info(msg, args...) } +func Warn(msg string, args ...any) { defaultLogger.Warn(msg, args...) } +func Error(msg string, args ...any) { defaultLogger.Error(msg, args...) } diff --git a/internal/utils/phone.go b/pkg/utils/phone.go similarity index 100% rename from internal/utils/phone.go rename to pkg/utils/phone.go diff --git a/internal/utils/phone_test.go b/pkg/utils/phone_test.go similarity index 100% rename from internal/utils/phone_test.go rename to pkg/utils/phone_test.go diff --git a/internal/whatsapp/businessapi/client.go b/pkg/whatsapp/businessapi/client.go similarity index 98% rename from internal/whatsapp/businessapi/client.go rename to pkg/whatsapp/businessapi/client.go index 6110f14..c3d5de7 100644 --- a/internal/whatsapp/businessapi/client.go +++ b/pkg/whatsapp/businessapi/client.go @@ -11,9 +11,9 @@ import ( "sync" "time" - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/events" - "git.warky.dev/wdevs/whatshooked/internal/logging" + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/events" + "git.warky.dev/wdevs/whatshooked/pkg/logging" "go.mau.fi/whatsmeow/types" ) diff --git a/internal/whatsapp/businessapi/events.go b/pkg/whatsapp/businessapi/events.go similarity index 98% rename from internal/whatsapp/businessapi/events.go rename to pkg/whatsapp/businessapi/events.go index 27e1147..b008845 100644 --- a/internal/whatsapp/businessapi/events.go +++ b/pkg/whatsapp/businessapi/events.go @@ -14,8 +14,8 @@ import ( "strconv" "time" - "git.warky.dev/wdevs/whatshooked/internal/events" - "git.warky.dev/wdevs/whatshooked/internal/logging" + "git.warky.dev/wdevs/whatshooked/pkg/events" + "git.warky.dev/wdevs/whatshooked/pkg/logging" ) // HandleWebhook processes incoming webhook events from WhatsApp Business API diff --git a/internal/whatsapp/businessapi/media.go b/pkg/whatsapp/businessapi/media.go similarity index 100% rename from internal/whatsapp/businessapi/media.go rename to pkg/whatsapp/businessapi/media.go diff --git a/internal/whatsapp/businessapi/types.go b/pkg/whatsapp/businessapi/types.go similarity index 100% rename from internal/whatsapp/businessapi/types.go rename to pkg/whatsapp/businessapi/types.go diff --git a/internal/whatsapp/interface.go b/pkg/whatsapp/interface.go similarity index 100% rename from internal/whatsapp/interface.go rename to pkg/whatsapp/interface.go diff --git a/internal/whatsapp/manager.go b/pkg/whatsapp/manager.go similarity index 94% rename from internal/whatsapp/manager.go rename to pkg/whatsapp/manager.go index 8b06bd3..f9b3907 100644 --- a/internal/whatsapp/manager.go +++ b/pkg/whatsapp/manager.go @@ -5,11 +5,11 @@ import ( "fmt" "sync" - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/events" - "git.warky.dev/wdevs/whatshooked/internal/logging" - "git.warky.dev/wdevs/whatshooked/internal/whatsapp/businessapi" - "git.warky.dev/wdevs/whatshooked/internal/whatsapp/whatsmeow" + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/events" + "git.warky.dev/wdevs/whatshooked/pkg/logging" + "git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi" + "git.warky.dev/wdevs/whatshooked/pkg/whatsapp/whatsmeow" "go.mau.fi/whatsmeow/types" ) diff --git a/internal/whatsapp/whatsmeow/client.go b/pkg/whatsapp/whatsmeow/client.go similarity index 99% rename from internal/whatsapp/whatsmeow/client.go rename to pkg/whatsapp/whatsmeow/client.go index 8a564d3..85e1a18 100644 --- a/internal/whatsapp/whatsmeow/client.go +++ b/pkg/whatsapp/whatsmeow/client.go @@ -10,9 +10,9 @@ import ( "path/filepath" "time" - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/events" - "git.warky.dev/wdevs/whatshooked/internal/logging" + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/events" + "git.warky.dev/wdevs/whatshooked/pkg/logging" qrterminal "github.com/mdp/qrterminal/v3" "go.mau.fi/whatsmeow" diff --git a/pkg/whatshooked/options.go b/pkg/whatshooked/options.go new file mode 100644 index 0000000..a5a8634 --- /dev/null +++ b/pkg/whatshooked/options.go @@ -0,0 +1,133 @@ +package whatshooked + +import "git.warky.dev/wdevs/whatshooked/pkg/config" + +// Option is a functional option for configuring WhatsHooked +type Option func(*config.Config) + +// WithServer configures server settings +func WithServer(host string, port int) Option { + return func(c *config.Config) { + c.Server.Host = host + c.Server.Port = port + } +} + +// WithAuth configures authentication +func WithAuth(apiKey, username, password string) Option { + return func(c *config.Config) { + c.Server.AuthKey = apiKey + c.Server.Username = username + c.Server.Password = password + } +} + +// WithDefaultCountryCode sets the default country code for phone numbers +func WithDefaultCountryCode(code string) Option { + return func(c *config.Config) { + c.Server.DefaultCountryCode = code + } +} + +// WithWhatsAppAccount adds a WhatsApp account +func WithWhatsAppAccount(account config.WhatsAppConfig) Option { + return func(c *config.Config) { + c.WhatsApp = append(c.WhatsApp, account) + } +} + +// WithWhatsmeowAccount adds a Whatsmeow account (convenience) +func WithWhatsmeowAccount(id, phoneNumber, sessionPath string, showQR bool) Option { + return func(c *config.Config) { + c.WhatsApp = append(c.WhatsApp, config.WhatsAppConfig{ + ID: id, + Type: "whatsmeow", + PhoneNumber: phoneNumber, + SessionPath: sessionPath, + ShowQR: showQR, + }) + } +} + +// WithBusinessAPIAccount adds a Business API account (convenience) +func WithBusinessAPIAccount(id, phoneNumber, phoneNumberID, accessToken, verifyToken string) Option { + return func(c *config.Config) { + c.WhatsApp = append(c.WhatsApp, config.WhatsAppConfig{ + ID: id, + Type: "business-api", + PhoneNumber: phoneNumber, + BusinessAPI: &config.BusinessAPIConfig{ + PhoneNumberID: phoneNumberID, + AccessToken: accessToken, + VerifyToken: verifyToken, + APIVersion: "v21.0", + }, + }) + } +} + +// WithHook adds a webhook +func WithHook(hook config.Hook) Option { + return func(c *config.Config) { + c.Hooks = append(c.Hooks, hook) + } +} + +// WithEventLogger enables event logging +func WithEventLogger(targets []string, fileDir string) Option { + return func(c *config.Config) { + c.EventLogger.Enabled = true + c.EventLogger.Targets = targets + c.EventLogger.FileDir = fileDir + } +} + +// WithEventLoggerConfig configures event logger with full config +func WithEventLoggerConfig(cfg config.EventLoggerConfig) Option { + return func(c *config.Config) { + c.EventLogger = cfg + } +} + +// WithMedia configures media settings +func WithMedia(dataPath, mode, baseURL string) Option { + return func(c *config.Config) { + c.Media.DataPath = dataPath + c.Media.Mode = mode + c.Media.BaseURL = baseURL + } +} + +// WithMediaConfig configures media with full config +func WithMediaConfig(cfg config.MediaConfig) Option { + return func(c *config.Config) { + c.Media = cfg + } +} + +// WithLogLevel sets the log level +func WithLogLevel(level string) Option { + return func(c *config.Config) { + c.LogLevel = level + } +} + +// WithDatabase configures database settings +func WithDatabase(dbType, host string, port int, username, password, database string) Option { + return func(c *config.Config) { + c.Database.Type = dbType + c.Database.Host = host + c.Database.Port = port + c.Database.Username = username + c.Database.Password = password + c.Database.Database = database + } +} + +// WithSQLiteDatabase configures SQLite database +func WithSQLiteDatabase(sqlitePath string) Option { + return func(c *config.Config) { + c.Database.Type = "sqlite" + c.Database.SQLitePath = sqlitePath + } +} diff --git a/pkg/whatshooked/server.go b/pkg/whatshooked/server.go new file mode 100644 index 0000000..6323a99 --- /dev/null +++ b/pkg/whatshooked/server.go @@ -0,0 +1,157 @@ +package whatshooked + +import ( + "context" + "fmt" + "net/http" + "time" + + "git.warky.dev/wdevs/whatshooked/pkg/events" + "git.warky.dev/wdevs/whatshooked/pkg/hooks" + "git.warky.dev/wdevs/whatshooked/pkg/logging" + "git.warky.dev/wdevs/whatshooked/pkg/utils" + "go.mau.fi/whatsmeow/types" +) + +// Server is the optional built-in HTTP server +type Server struct { + wh *WhatsHooked + httpServer *http.Server +} + +// NewServer creates a new HTTP server instance +func NewServer(wh *WhatsHooked) *Server { + return &Server{ + wh: wh, + } +} + +// Start starts the HTTP server +func (s *Server) Start() error { + // Subscribe to hook success events for two-way communication + s.wh.EventBus().Subscribe(events.EventHookSuccess, s.handleHookResponse) + + // Setup routes + mux := s.setupRoutes() + + addr := fmt.Sprintf("%s:%d", s.wh.config.Server.Host, s.wh.config.Server.Port) + s.httpServer = &http.Server{ + Addr: addr, + Handler: mux, + } + + logging.Info("Starting HTTP server", + "host", s.wh.config.Server.Host, + "port", s.wh.config.Server.Port, + "address", addr) + + // Connect to WhatsApp accounts + go func() { + time.Sleep(100 * time.Millisecond) // Give HTTP server a moment to start + logging.Info("HTTP server ready, connecting to WhatsApp accounts") + if err := s.wh.ConnectAll(context.Background()); err != nil { + logging.Error("Failed to connect to WhatsApp accounts", "error", err) + } + }() + + // Start server (blocking) + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + + return nil +} + +// Stop stops the HTTP server gracefully +func (s *Server) Stop(ctx context.Context) error { + if s.httpServer != nil { + return s.httpServer.Shutdown(ctx) + } + return nil +} + +// setupRoutes configures all HTTP routes +func (s *Server) setupRoutes() *http.ServeMux { + mux := http.NewServeMux() + h := s.wh.Handlers() + + // Health check (no auth required) + mux.HandleFunc("/health", h.Health) + + // Hook management (with auth) + mux.HandleFunc("/api/hooks", h.Auth(h.Hooks)) + mux.HandleFunc("/api/hooks/add", h.Auth(h.AddHook)) + mux.HandleFunc("/api/hooks/remove", h.Auth(h.RemoveHook)) + + // Account management (with auth) + mux.HandleFunc("/api/accounts", h.Auth(h.Accounts)) + mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount)) + + // Send messages (with auth) + mux.HandleFunc("/api/send", h.Auth(h.SendMessage)) + mux.HandleFunc("/api/send/image", h.Auth(h.SendImage)) + mux.HandleFunc("/api/send/video", h.Auth(h.SendVideo)) + mux.HandleFunc("/api/send/document", h.Auth(h.SendDocument)) + + // Serve media files (with auth) + mux.HandleFunc("/api/media/", h.ServeMedia) + + // Business API webhooks (no auth - Meta validates via verify_token) + mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook) + + logging.Info("HTTP server endpoints configured", + "health", "/health", + "hooks", "/api/hooks", + "accounts", "/api/accounts", + "send", "/api/send") + + return mux +} + +// handleHookResponse processes hook success events for 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.wh.config.WhatsApp) > 0 { + targetAccountID = s.wh.config.WhatsApp[0].ID + } + + // Format phone number to JID format + formattedJID := utils.FormatPhoneToJID(resp.To, s.wh.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.wh.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) + } +} diff --git a/pkg/whatshooked/whatshooked.go b/pkg/whatshooked/whatshooked.go new file mode 100644 index 0000000..cfd1542 --- /dev/null +++ b/pkg/whatshooked/whatshooked.go @@ -0,0 +1,169 @@ +package whatshooked + +import ( + "context" + + "git.warky.dev/wdevs/whatshooked/pkg/config" + "git.warky.dev/wdevs/whatshooked/pkg/eventlogger" + "git.warky.dev/wdevs/whatshooked/pkg/events" + "git.warky.dev/wdevs/whatshooked/pkg/handlers" + "git.warky.dev/wdevs/whatshooked/pkg/hooks" + "git.warky.dev/wdevs/whatshooked/pkg/logging" + "git.warky.dev/wdevs/whatshooked/pkg/whatsapp" +) + +// WhatsHooked is the main library instance +type WhatsHooked struct { + config *config.Config + configPath string + eventBus *events.EventBus + whatsappMgr *whatsapp.Manager + hookMgr *hooks.Manager + eventLogger *eventlogger.Logger + handlers *handlers.Handlers + server *Server // Optional built-in server +} + +// NewFromFile creates a WhatsHooked instance from a config file +func NewFromFile(configPath string) (*WhatsHooked, error) { + cfg, err := config.Load(configPath) + if err != nil { + return nil, err + } + + // Initialize logging from config + logging.Init(cfg.LogLevel) + + return newWithConfig(cfg, configPath) +} + +// New creates a WhatsHooked instance with programmatic config +func New(opts ...Option) (*WhatsHooked, error) { + cfg := &config.Config{ + Server: config.ServerConfig{ + Host: "localhost", + Port: 8080, + }, + Media: config.MediaConfig{ + DataPath: "./data/media", + Mode: "link", + }, + LogLevel: "info", + } + + // Apply options + for _, opt := range opts { + opt(cfg) + } + + // Initialize logging from config + logging.Init(cfg.LogLevel) + + return newWithConfig(cfg, "") +} + +// newWithConfig is the internal constructor +func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error) { + wh := &WhatsHooked{ + config: cfg, + configPath: configPath, + eventBus: events.NewEventBus(), + } + + // Initialize WhatsApp manager + wh.whatsappMgr = whatsapp.NewManager( + wh.eventBus, + cfg.Media, + cfg, + configPath, + func(updatedCfg *config.Config) error { + if configPath != "" { + return config.Save(configPath, updatedCfg) + } + return nil + }, + ) + + // Initialize hook manager + wh.hookMgr = hooks.NewManager(wh.eventBus) + wh.hookMgr.LoadHooks(cfg.Hooks) + wh.hookMgr.Start() + + // Initialize event logger if enabled + if cfg.EventLogger.Enabled && len(cfg.EventLogger.Targets) > 0 { + logger, err := eventlogger.NewLogger(cfg.EventLogger, cfg.Database) + if err == nil { + wh.eventLogger = logger + wh.eventBus.SubscribeAll(func(event events.Event) { + wh.eventLogger.Log(event) + }) + logging.Info("Event logger initialized", "targets", cfg.EventLogger.Targets) + } else { + logging.Error("Failed to initialize event logger", "error", err) + } + } + + // Create handlers + wh.handlers = handlers.New(wh.whatsappMgr, wh.hookMgr, cfg, configPath) + + return wh, nil +} + +// ConnectAll connects to all configured WhatsApp accounts +func (wh *WhatsHooked) ConnectAll(ctx context.Context) error { + for _, waCfg := range wh.config.WhatsApp { + if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil { + logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err) + // Continue connecting to other accounts even if one fails + } + } + return nil +} + +// Handlers returns the HTTP handlers instance +func (wh *WhatsHooked) Handlers() *handlers.Handlers { + return wh.handlers +} + +// Manager returns the WhatsApp manager +func (wh *WhatsHooked) Manager() *whatsapp.Manager { + return wh.whatsappMgr +} + +// EventBus returns the event bus +func (wh *WhatsHooked) EventBus() *events.EventBus { + return wh.eventBus +} + +// HookManager returns the hook manager +func (wh *WhatsHooked) HookManager() *hooks.Manager { + return wh.hookMgr +} + +// Config returns the configuration +func (wh *WhatsHooked) Config() *config.Config { + return wh.config +} + +// Close shuts down all components gracefully +func (wh *WhatsHooked) Close() error { + wh.whatsappMgr.DisconnectAll() + if wh.eventLogger != nil { + return wh.eventLogger.Close() + } + return nil +} + +// StartServer starts the built-in HTTP server (convenience method) +func (wh *WhatsHooked) StartServer() error { + wh.server = NewServer(wh) + return wh.server.Start() +} + +// StopServer stops the built-in HTTP server +func (wh *WhatsHooked) StopServer(ctx context.Context) error { + if wh.server != nil { + return wh.server.Stop(ctx) + } + return nil +}