diff --git a/pkg/config/config.go b/pkg/config/config.go index 368eb55..92295d4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -29,8 +29,8 @@ type ServerConfig struct { // TLSConfig holds TLS/HTTPS configuration type TLSConfig struct { - Enabled bool `json:"enabled"` // Enable HTTPS - Mode string `json:"mode"` // "self-signed", "custom", or "autocert" + Enabled bool `json:"enabled"` // Enable HTTPS + Mode string `json:"mode"` // "self-signed", "custom", or "autocert" CertFile string `json:"cert_file,omitempty"` // Path to certificate file (for custom mode) KeyFile string `json:"key_file,omitempty"` // Path to key file (for custom mode) @@ -38,10 +38,10 @@ type TLSConfig struct { CertDir string `json:"cert_dir,omitempty"` // Directory to store generated certificates // Let's Encrypt / autocert options - Domain string `json:"domain,omitempty"` // Domain name for Let's Encrypt - Email string `json:"email,omitempty"` // Email for Let's Encrypt notifications - CacheDir string `json:"cache_dir,omitempty"` // Cache directory for autocert - Production bool `json:"production,omitempty"` // Use Let's Encrypt production (default: staging) + Domain string `json:"domain,omitempty"` // Domain name for Let's Encrypt + Email string `json:"email,omitempty"` // Email for Let's Encrypt notifications + CacheDir string `json:"cache_dir,omitempty"` // Cache directory for autocert + Production bool `json:"production,omitempty"` // Use Let's Encrypt production (default: staging) } // WhatsAppConfig holds configuration for a WhatsApp account diff --git a/pkg/events/events.go b/pkg/events/events.go index 72fa1ad..b236dec 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -79,6 +79,10 @@ func (eb *EventBus) SubscribeAll(subscriber Subscriber) { EventWhatsAppDisconnected, EventWhatsAppPairSuccess, EventWhatsAppPairFailed, + EventWhatsAppQRCode, + EventWhatsAppQRTimeout, + EventWhatsAppQRError, + EventWhatsAppPairEvent, EventMessageReceived, EventMessageSent, EventMessageFailed, diff --git a/pkg/handlers/accounts.go b/pkg/handlers/accounts.go index 203b62f..d090868 100644 --- a/pkg/handlers/accounts.go +++ b/pkg/handlers/accounts.go @@ -38,10 +38,63 @@ func (h *Handlers) AddAccount(w http.ResponseWriter, r *http.Request) { 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) + logging.Error("Failed to save config after adding account", "account_id", account.ID, "error", err) + } else { + logging.Info("Config saved after adding account", "account_id", account.ID) } } w.WriteHeader(http.StatusCreated) + writeJSON(w, map[string]string{"status": "ok", "account_id": account.ID}) +} + +// RemoveAccount removes a WhatsApp account from the system +func (h *Handlers) RemoveAccount(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 + } + + // Disconnect the account + if err := h.whatsappMgr.Disconnect(req.ID); err != nil { + logging.Warn("Failed to disconnect account during removal", "account_id", req.ID, "error", err) + // Continue with removal even if disconnect fails + } + + // Remove from config + found := false + newAccounts := make([]config.WhatsAppConfig, 0) + for _, acc := range h.config.WhatsApp { + if acc.ID != req.ID { + newAccounts = append(newAccounts, acc) + } else { + found = true + } + } + + if !found { + http.Error(w, "Account not found", http.StatusNotFound) + return + } + + h.config.WhatsApp = newAccounts + + // Save config + if h.configPath != "" { + if err := config.Save(h.configPath, h.config); err != nil { + logging.Error("Failed to save config after removing account", "account_id", req.ID, "error", err) + } else { + logging.Info("Config saved after removing account", "account_id", req.ID) + } + } + writeJSON(w, map[string]string{"status": "ok"}) } diff --git a/pkg/handlers/hooks.go b/pkg/handlers/hooks.go index 316d967..fb31844 100644 --- a/pkg/handlers/hooks.go +++ b/pkg/handlers/hooks.go @@ -34,12 +34,14 @@ func (h *Handlers) AddHook(w http.ResponseWriter, r *http.Request) { 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) + logging.Error("Failed to save config after adding hook", "hook_id", hook.ID, "error", err) + } else { + logging.Info("Config saved after adding hook", "hook_id", hook.ID) } } w.WriteHeader(http.StatusCreated) - writeJSON(w, map[string]string{"status": "ok"}) + writeJSON(w, map[string]string{"status": "ok", "hook_id": hook.ID}) } // RemoveHook removes a hook from the system @@ -66,7 +68,9 @@ func (h *Handlers) RemoveHook(w http.ResponseWriter, r *http.Request) { 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) + logging.Error("Failed to save config after removing hook", "hook_id", req.ID, "error", err) + } else { + logging.Info("Config saved after removing hook", "hook_id", req.ID) } } diff --git a/pkg/whatsapp/manager.go b/pkg/whatsapp/manager.go index f9b3907..55c3f53 100644 --- a/pkg/whatsapp/manager.go +++ b/pkg/whatsapp/manager.go @@ -40,9 +40,8 @@ func NewManager(eventBus *events.EventBus, mediaConfig config.MediaConfig, cfg * // Connect establishes a connection to a WhatsApp account using the appropriate client type func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error { m.mu.Lock() - defer m.mu.Unlock() - if _, exists := m.clients[cfg.ID]; exists { + m.mu.Unlock() return fmt.Errorf("client %s already connected", cfg.ID) } @@ -54,21 +53,39 @@ func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error case "business-api": client, err = businessapi.NewClient(cfg, m.eventBus, m.mediaConfig) case "whatsmeow", "": - client, err = whatsmeow.NewClient(cfg, m.eventBus, m.mediaConfig) + // Create callback for phone number updates + onPhoneUpdate := func(accountID, phoneNumber string) { + m.updatePhoneNumberInConfig(accountID, phoneNumber) + } + client, err = whatsmeow.NewClient(cfg, m.eventBus, m.mediaConfig, onPhoneUpdate) default: + m.mu.Unlock() return fmt.Errorf("unknown client type: %s", cfg.Type) } if err != nil { + m.mu.Unlock() return fmt.Errorf("failed to create client: %w", err) } - if err := client.Connect(ctx); err != nil { - return fmt.Errorf("failed to connect: %w", err) - } - + // Register client immediately so it's available for QR code serving during pairing m.clients[cfg.ID] = client - logging.Info("Client connected", "account_id", cfg.ID, "type", client.GetType()) + m.mu.Unlock() + + // Connect in background (this can block during QR pairing) + go func() { + if err := client.Connect(ctx); err != nil { + logging.Error("Failed to connect client", "account_id", cfg.ID, "error", err) + // Remove client if connection fails + m.mu.Lock() + delete(m.clients, cfg.ID) + m.mu.Unlock() + m.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, cfg.ID, err)) + } else { + logging.Info("Client connected", "account_id", cfg.ID, "type", client.GetType()) + } + }() + return nil } @@ -169,3 +186,29 @@ func (m *Manager) GetClient(id string) (Client, bool) { client, exists := m.clients[id] return client, exists } + +// updatePhoneNumberInConfig updates the phone number for an account in config and saves it +func (m *Manager) updatePhoneNumberInConfig(accountID, phoneNumber string) { + m.mu.Lock() + defer m.mu.Unlock() + + // Find and update the account in config + for i := range m.config.WhatsApp { + if m.config.WhatsApp[i].ID == accountID { + if m.config.WhatsApp[i].PhoneNumber != phoneNumber { + m.config.WhatsApp[i].PhoneNumber = phoneNumber + logging.Info("Updated phone number in config", "account_id", accountID, "phone", phoneNumber) + + // Save config if callback is available + if m.onConfigUpdate != nil { + if err := m.onConfigUpdate(m.config); err != nil { + logging.Error("Failed to save config after phone update", "account_id", accountID, "error", err) + } else { + logging.Debug("Config saved with updated phone number", "account_id", accountID) + } + } + } + break + } + } +} diff --git a/pkg/whatsapp/whatsmeow/client.go b/pkg/whatsapp/whatsmeow/client.go index 12a223d..308e2e1 100644 --- a/pkg/whatsapp/whatsmeow/client.go +++ b/pkg/whatsapp/whatsmeow/client.go @@ -1,14 +1,11 @@ package whatsmeow import ( - "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" - "image" - "image/png" "os" "path/filepath" "sync" @@ -20,7 +17,9 @@ import ( qrterminal "github.com/mdp/qrterminal/v3" "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waCompanionReg" "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" waEvents "go.mau.fi/whatsmeow/types/events" @@ -43,11 +42,13 @@ type Client struct { showQR bool keepAliveCancel context.CancelFunc qrCode string + qrCodePNG []byte // Cached PNG data qrCodeMutex sync.RWMutex + onPhoneUpdate func(accountID, phoneNumber string) // Callback when phone number is updated } // NewClient creates a new whatsmeow client -func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig) (*Client, error) { +func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig, onPhoneUpdate func(accountID, phoneNumber string)) (*Client, error) { if cfg.Type != "whatsmeow" && cfg.Type != "" { return nil, fmt.Errorf("invalid client type for whatsmeow: %s", cfg.Type) } @@ -58,12 +59,13 @@ func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig } return &Client{ - id: cfg.ID, - phoneNumber: cfg.PhoneNumber, - sessionPath: sessionPath, - eventBus: eventBus, - mediaConfig: mediaConfig, - showQR: cfg.ShowQR, + id: cfg.ID, + phoneNumber: cfg.PhoneNumber, + sessionPath: sessionPath, + eventBus: eventBus, + mediaConfig: mediaConfig, + showQR: cfg.ShowQR, + onPhoneUpdate: onPhoneUpdate, }, nil } @@ -73,6 +75,10 @@ func (c *Client) Connect(ctx context.Context) error { if err := os.MkdirAll(c.sessionPath, 0700); err != nil { return fmt.Errorf("failed to create session directory: %w", err) } + // store.SetOSInfo("Linux", store.GetWAVersion()) + store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_CLOUD_API.Enum() + store.DeviceProps.Os = proto.String("whatshooked.warky.dev") + store.DeviceProps.RequireFullSync = proto.Bool(false) // Create database container for session storage dbPath := filepath.Join(c.sessionPath, "session.db") @@ -89,9 +95,18 @@ func (c *Client) Connect(ctx context.Context) error { return fmt.Errorf("failed to get device: %w", err) } - // Set custom client information - deviceStore.Platform = "WhatsHooked" - deviceStore.BusinessName = "git.warky.dev/wdevs/whatshooked" + // Set custom client information that will be shown in WhatsApp + deviceStore.Platform = "git.warky.dev/wdevs/whatshooked" + + // Set PushName BEFORE pairing - this is what shows up in WhatsApp linked devices + if deviceStore.PushName == "" { + deviceStore.PushName = fmt.Sprintf("WhatsHooked %s", c.phoneNumber) + } + + // Save device store to persist device info before pairing + if err := deviceStore.Save(ctx); err != nil { + logging.Warn("Failed to save device store", "account_id", c.id, "error", err) + } // Create client clientLog := waLog.Stdout("Client", "ERROR", true) @@ -118,11 +133,21 @@ func (c *Client) Connect(ctx context.Context) error { case "code": logging.Info("QR code received for pairing", "account_id", c.id) - // Store QR code + // Generate PNG (this regenerates on each new QR code) + pngData, err := c.generateQRCodePNG(evt.Code) + if err != nil { + logging.Error("Failed to generate QR code PNG", "account_id", c.id, "error", err) + } + + // Store QR code and PNG (updates cached version) c.qrCodeMutex.Lock() c.qrCode = evt.Code + c.qrCodePNG = pngData + qrCodeSize := len(pngData) c.qrCodeMutex.Unlock() + logging.Debug("QR code PNG updated", "account_id", c.id, "size_bytes", qrCodeSize) + // Generate QR code URL qrURL := c.generateQRCodeURL() @@ -143,10 +168,24 @@ func (c *Client) Connect(ctx context.Context) error { case "success": logging.Info("Pairing successful", "account_id", c.id, "phone", c.phoneNumber) + + // Clear cached QR code after successful pairing + c.qrCodeMutex.Lock() + c.qrCode = "" + c.qrCodePNG = nil + c.qrCodeMutex.Unlock() + c.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, c.id)) case "timeout": logging.Warn("QR code timeout", "account_id", c.id) + + // Clear cached QR code on timeout + c.qrCodeMutex.Lock() + c.qrCode = "" + c.qrCodePNG = nil + c.qrCodeMutex.Unlock() + c.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, c.id)) case "error": @@ -167,12 +206,7 @@ func (c *Client) Connect(ctx context.Context) error { } } - if deviceStore.PushName == "" { - deviceStore.PushName = fmt.Sprintf("WhatsHooked %s", c.phoneNumber) - if err := deviceStore.Save(ctx); err != nil { - logging.Error("failed to save device store", "account_id", c.id) - } - } + // PushName is already set before pairing, no need to set it again here if client.IsConnected() { err := client.SendPresence(ctx, types.PresenceAvailable) @@ -521,6 +555,11 @@ func (c *Client) handleEvent(evt interface{}) { if c.phoneNumber != phoneNumber { c.phoneNumber = phoneNumber logging.Info("Updated phone number from WhatsApp", "account_id", c.id, "phone", phoneNumber) + + // Trigger config update callback to persist the phone number + if c.onPhoneUpdate != nil { + c.onPhoneUpdate(c.id, phoneNumber) + } } } else if c.phoneNumber != "" { phoneNumber = c.phoneNumber @@ -703,49 +742,31 @@ func (c *Client) generateQRCodeURL() string { return fmt.Sprintf("%s/api/qr/%s", baseURL, c.id) } -// GetQRCodePNG generates a PNG image of the current QR code +// GetQRCodePNG returns the cached PNG image of the current QR code func (c *Client) GetQRCodePNG() ([]byte, error) { c.qrCodeMutex.RLock() - qrCodeData := c.qrCode - c.qrCodeMutex.RUnlock() + defer c.qrCodeMutex.RUnlock() - if qrCodeData == "" { + if c.qrCode == "" { return nil, fmt.Errorf("no QR code available") } + if c.qrCodePNG == nil { + return nil, fmt.Errorf("QR code PNG not yet generated") + } + + return c.qrCodePNG, nil +} + +// generateQRCodePNG generates a PNG image from QR code data +func (c *Client) generateQRCodePNG(qrCodeData string) ([]byte, error) { // Generate QR code using rsc.io/qr - code, err := qr.Encode(qrCodeData, qr.L) + code, err := qr.Encode(qrCodeData, qr.H) if err != nil { return nil, fmt.Errorf("failed to encode QR code: %w", err) } - // Scale the QR code for better visibility (8x scale) - img := code.Image() - scale := 8 - bounds := img.Bounds() - scaledWidth := bounds.Dx() * scale - scaledHeight := bounds.Dy() * scale - - // Create a new image with scaled dimensions - scaledImg := image.NewRGBA(image.Rect(0, 0, scaledWidth, scaledHeight)) - - // Scale the image - for y := 0; y < bounds.Dy(); y++ { - for x := 0; x < bounds.Dx(); x++ { - pixel := img.At(x, y) - for sy := 0; sy < scale; sy++ { - for sx := 0; sx < scale; sx++ { - scaledImg.Set(x*scale+sx, y*scale+sy, pixel) - } - } - } - } - - // Encode to PNG - var buf bytes.Buffer - if err := png.Encode(&buf, scaledImg); err != nil { - return nil, fmt.Errorf("failed to encode PNG: %w", err) - } - - return buf.Bytes(), nil + // Use the library's built-in PNG method with a scale factor + // Scale of 8 means each QR module is 8x8 pixels + return code.PNG(), nil } diff --git a/pkg/whatshooked/server.go b/pkg/whatshooked/server.go index 8b21ed4..fcdebfb 100644 --- a/pkg/whatshooked/server.go +++ b/pkg/whatshooked/server.go @@ -155,10 +155,10 @@ func (s *Server) startAutocertTLS(tlsConfig *config.TLSConfig, addr string) erro // Create autocert manager certManager := &autocert.Manager{ - Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(tlsConfig.Domain), - Cache: autocert.DirCache(tlsConfig.CacheDir), - Email: tlsConfig.Email, + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(tlsConfig.Domain), + Cache: autocert.DirCache(tlsConfig.CacheDir), + Email: tlsConfig.Email, } // Configure TLS @@ -215,6 +215,7 @@ func (s *Server) setupRoutes() *http.ServeMux { // Account management (with auth) mux.HandleFunc("/api/accounts", h.Auth(h.Accounts)) mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount)) + mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount)) // Send messages (with auth) mux.HandleFunc("/api/send", h.Auth(h.SendMessage))