Major refactor to library

This commit is contained in:
2025-12-29 09:51:16 +02:00
parent ae169f81e4
commit 767a9e211f
38 changed files with 1073 additions and 492 deletions

299
README.md
View File

@@ -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

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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)
}
}
// 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)
}
// Start the built-in HTTP server (non-blocking goroutine)
go func() {
if err := wh.StartServer(); err != nil {
logging.Error("HTTP server error", "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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}()
}

View File

@@ -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"})
}

View File

@@ -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"})
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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
)

47
pkg/handlers/accounts.go Normal file
View File

@@ -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"})
}

View File

@@ -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)

56
pkg/handlers/handlers.go Normal file
View File

@@ -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
}

11
pkg/handlers/health.go Normal file
View File

@@ -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"})
}

View File

@@ -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 {
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,16 +57,18 @@ 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 {
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"})
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

56
pkg/logging/logging.go Normal file
View File

@@ -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...) }

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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"

133
pkg/whatshooked/options.go Normal file
View File

@@ -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
}
}

157
pkg/whatshooked/server.go Normal file
View File

@@ -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)
}
}

View File

@@ -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
}