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

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

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,151 +0,0 @@
package main
import (
"net/http"
"strings"
"git.warky.dev/wdevs/whatshooked/internal/logging"
"git.warky.dev/wdevs/whatshooked/internal/whatsapp/businessapi"
)
// handleBusinessAPIWebhook handles both verification (GET) and webhook events (POST)
func (s *Server) handleBusinessAPIWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
s.handleBusinessAPIWebhookVerify(w, r)
return
}
if r.Method == http.MethodPost {
s.handleBusinessAPIWebhookEvent(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// handleBusinessAPIWebhookVerify 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) {
// Extract account ID from URL path
accountID := extractAccountIDFromPath(r.URL.Path)
if accountID == "" {
http.Error(w, "Account ID required in path", http.StatusBadRequest)
return
}
// Get the account configuration
var accountConfig *struct {
ID string
Type string
VerifyToken string
}
for _, cfg := range s.config.WhatsApp {
if cfg.ID == accountID && cfg.Type == "business-api" {
if cfg.BusinessAPI != nil {
accountConfig = &struct {
ID string
Type string
VerifyToken string
}{
ID: cfg.ID,
Type: cfg.Type,
VerifyToken: cfg.BusinessAPI.VerifyToken,
}
break
}
}
}
if accountConfig == nil {
logging.Error("Business API account not found or not configured", "account_id", accountID)
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Get query parameters
mode := r.URL.Query().Get("hub.mode")
token := r.URL.Query().Get("hub.verify_token")
challenge := r.URL.Query().Get("hub.challenge")
logging.Info("Webhook verification request",
"account_id", accountID,
"mode", mode,
"has_challenge", challenge != "")
// Verify the token matches
if mode == "subscribe" && token == accountConfig.VerifyToken {
logging.Info("Webhook verification successful", "account_id", accountID)
w.WriteHeader(http.StatusOK)
w.Write([]byte(challenge))
return
}
logging.Warn("Webhook verification failed",
"account_id", accountID,
"mode", mode,
"token_match", token == accountConfig.VerifyToken)
http.Error(w, "Forbidden", http.StatusForbidden)
}
// handleBusinessAPIWebhookEvent handles incoming webhook events from Meta
// POST /webhooks/whatsapp/{accountID}
func (s *Server) handleBusinessAPIWebhookEvent(w http.ResponseWriter, r *http.Request) {
// Extract account ID from URL path
accountID := extractAccountIDFromPath(r.URL.Path)
if accountID == "" {
http.Error(w, "Account ID required in path", http.StatusBadRequest)
return
}
// Get the client from the manager
client, exists := s.whatsappMgr.GetClient(accountID)
if !exists {
logging.Error("Client not found for webhook", "account_id", accountID)
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Verify it's a Business API client
if client.GetType() != "business-api" {
logging.Error("Account is not a Business API client", "account_id", accountID, "type", client.GetType())
http.Error(w, "Not a Business API account", http.StatusBadRequest)
return
}
// Cast to Business API client to access HandleWebhook
baClient, ok := client.(*businessapi.Client)
if !ok {
logging.Error("Failed to cast to Business API client", "account_id", accountID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Process the webhook
if err := baClient.HandleWebhook(r); err != nil {
logging.Error("Failed to process webhook", "account_id", accountID, "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Return 200 OK to acknowledge receipt
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// extractAccountIDFromPath extracts the account ID from the URL path
// Example: /webhooks/whatsapp/business -> "business"
func extractAccountIDFromPath(path string) string {
// Remove trailing slash if present
path = strings.TrimSuffix(path, "/")
// Split by /
parts := strings.Split(path, "/")
// Expected format: /webhooks/whatsapp/{accountID}
if len(parts) >= 4 {
return parts[3]
}
return ""
}

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 main
import (
"encoding/json"
"net/http"
"git.warky.dev/wdevs/whatshooked/internal/config"
"git.warky.dev/wdevs/whatshooked/internal/logging"
)
// handleHooks returns the list of all configured hooks
func (s *Server) handleHooks(w http.ResponseWriter, r *http.Request) {
hooks := s.hookMgr.ListHooks()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hooks)
}
// handleAddHook adds a new hook to the system
func (s *Server) handleAddHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var hook config.Hook
if err := json.NewDecoder(r.Body).Decode(&hook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.hookMgr.AddHook(hook)
// Update config
s.config.Hooks = s.hookMgr.ListHooks()
if err := config.Save(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"})
}
// handleRemoveHook removes a hook from the system
func (s *Server) handleRemoveHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.hookMgr.RemoveHook(req.ID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Update config
s.config.Hooks = s.hookMgr.ListHooks()
if err := config.Save(s.configPath, s.config); err != nil {
logging.Error("Failed to save config", "error", err)
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

View File

@@ -1,52 +0,0 @@
package main
import (
"net/http"
"path/filepath"
)
// handleServeMedia serves media files with path traversal protection
func (s *Server) handleServeMedia(w http.ResponseWriter, r *http.Request) {
// Expected path format: /api/media/{accountID}/{filename}
path := r.URL.Path[len("/api/media/"):]
// Split path into accountID and filename
var accountID, filename string
for i, ch := range path {
if ch == '/' {
accountID = path[:i]
filename = path[i+1:]
break
}
}
if accountID == "" || filename == "" {
http.Error(w, "Invalid media path", http.StatusBadRequest)
return
}
// Construct full file path
filePath := filepath.Join(s.config.Media.DataPath, accountID, filename)
// Security check: ensure the resolved path is within the media directory
mediaDir := filepath.Join(s.config.Media.DataPath, accountID)
absFilePath, err := filepath.Abs(filePath)
if err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest)
return
}
absMediaDir, err := filepath.Abs(mediaDir)
if err != nil {
http.Error(w, "Invalid media directory", http.StatusInternalServerError)
return
}
// Check if file path is within media directory (prevent directory traversal)
if len(absFilePath) < len(absMediaDir) || absFilePath[:len(absMediaDir)] != absMediaDir {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Serve the file
http.ServeFile(w, r, absFilePath)
}

View File

@@ -1,189 +0,0 @@
package main
import (
"encoding/base64"
"encoding/json"
"net/http"
"git.warky.dev/wdevs/whatshooked/internal/utils"
"go.mau.fi/whatsmeow/types"
)
// handleSendMessage sends a text message via WhatsApp
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
if err := s.whatsappMgr.SendTextMessage(r.Context(), req.AccountID, jid, req.Text); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleSendImage sends an image via WhatsApp
func (s *Server) handleSendImage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
ImageData string `json:"image_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 image data
imageData, err := base64.StdEncoding.DecodeString(req.ImageData)
if err != nil {
http.Error(w, "Invalid base64 image data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default mime type if not provided
if req.MimeType == "" {
req.MimeType = "image/jpeg"
}
if err := s.whatsappMgr.SendImage(r.Context(), req.AccountID, jid, imageData, req.MimeType, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleSendVideo sends a video via WhatsApp
func (s *Server) handleSendVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
VideoData string `json:"video_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 video data
videoData, err := base64.StdEncoding.DecodeString(req.VideoData)
if err != nil {
http.Error(w, "Invalid base64 video data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default mime type if not provided
if req.MimeType == "" {
req.MimeType = "video/mp4"
}
if err := s.whatsappMgr.SendVideo(r.Context(), req.AccountID, jid, videoData, req.MimeType, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleSendDocument sends a document via WhatsApp
func (s *Server) handleSendDocument(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
Filename string `json:"filename"`
DocumentData string `json:"document_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 document data
documentData, err := base64.StdEncoding.DecodeString(req.DocumentData)
if err != nil {
http.Error(w, "Invalid base64 document data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, s.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default values if not provided
if req.MimeType == "" {
req.MimeType = "application/octet-stream"
}
if req.Filename == "" {
req.Filename = "document"
}
if err := s.whatsappMgr.SendDocument(r.Context(), req.AccountID, jid, documentData, req.MimeType, req.Filename, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}