Major refactor to library
This commit is contained in:
299
README.md
299
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
|
||||
|
||||

|
||||
|
||||
|
||||
[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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
47
pkg/handlers/accounts.go
Normal 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"})
|
||||
}
|
||||
@@ -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
56
pkg/handlers/handlers.go
Normal 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
11
pkg/handlers/health.go
Normal 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"})
|
||||
}
|
||||
@@ -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"})
|
||||
@@ -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)
|
||||
71
pkg/handlers/middleware.go
Normal file
71
pkg/handlers/middleware.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
56
pkg/logging/logging.go
Normal 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...) }
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
133
pkg/whatshooked/options.go
Normal 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
157
pkg/whatshooked/server.go
Normal 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)
|
||||
}
|
||||
}
|
||||
169
pkg/whatshooked/whatshooked.go
Normal file
169
pkg/whatshooked/whatshooked.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user