Server qr fixes.
Some checks failed
CI / Test (1.23) (push) Failing after -25m23s
CI / Test (1.22) (push) Failing after -25m21s
CI / Build (push) Failing after -25m59s
CI / Lint (push) Successful in -25m51s

This commit is contained in:
2025-12-29 22:44:10 +02:00
parent 94fc899bab
commit fd2527219e
7 changed files with 202 additions and 76 deletions

View File

@@ -29,8 +29,8 @@ type ServerConfig struct {
// TLSConfig holds TLS/HTTPS configuration // TLSConfig holds TLS/HTTPS configuration
type TLSConfig struct { type TLSConfig struct {
Enabled bool `json:"enabled"` // Enable HTTPS Enabled bool `json:"enabled"` // Enable HTTPS
Mode string `json:"mode"` // "self-signed", "custom", or "autocert" Mode string `json:"mode"` // "self-signed", "custom", or "autocert"
CertFile string `json:"cert_file,omitempty"` // Path to certificate file (for custom mode) 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) 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 CertDir string `json:"cert_dir,omitempty"` // Directory to store generated certificates
// Let's Encrypt / autocert options // Let's Encrypt / autocert options
Domain string `json:"domain,omitempty"` // Domain name for Let's Encrypt Domain string `json:"domain,omitempty"` // Domain name for Let's Encrypt
Email string `json:"email,omitempty"` // Email for Let's Encrypt notifications Email string `json:"email,omitempty"` // Email for Let's Encrypt notifications
CacheDir string `json:"cache_dir,omitempty"` // Cache directory for autocert CacheDir string `json:"cache_dir,omitempty"` // Cache directory for autocert
Production bool `json:"production,omitempty"` // Use Let's Encrypt production (default: staging) Production bool `json:"production,omitempty"` // Use Let's Encrypt production (default: staging)
} }
// WhatsAppConfig holds configuration for a WhatsApp account // WhatsAppConfig holds configuration for a WhatsApp account

View File

@@ -79,6 +79,10 @@ func (eb *EventBus) SubscribeAll(subscriber Subscriber) {
EventWhatsAppDisconnected, EventWhatsAppDisconnected,
EventWhatsAppPairSuccess, EventWhatsAppPairSuccess,
EventWhatsAppPairFailed, EventWhatsAppPairFailed,
EventWhatsAppQRCode,
EventWhatsAppQRTimeout,
EventWhatsAppQRError,
EventWhatsAppPairEvent,
EventMessageReceived, EventMessageReceived,
EventMessageSent, EventMessageSent,
EventMessageFailed, EventMessageFailed,

View File

@@ -38,10 +38,63 @@ func (h *Handlers) AddAccount(w http.ResponseWriter, r *http.Request) {
h.config.WhatsApp = append(h.config.WhatsApp, account) h.config.WhatsApp = append(h.config.WhatsApp, account)
if h.configPath != "" { if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil { 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) 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"}) writeJSON(w, map[string]string{"status": "ok"})
} }

View File

@@ -34,12 +34,14 @@ func (h *Handlers) AddHook(w http.ResponseWriter, r *http.Request) {
h.config.Hooks = h.hookMgr.ListHooks() h.config.Hooks = h.hookMgr.ListHooks()
if h.configPath != "" { if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil { 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) 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 // 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() h.config.Hooks = h.hookMgr.ListHooks()
if h.configPath != "" { if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil { 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)
} }
} }

View File

@@ -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 // Connect establishes a connection to a WhatsApp account using the appropriate client type
func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error { func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.clients[cfg.ID]; exists { if _, exists := m.clients[cfg.ID]; exists {
m.mu.Unlock()
return fmt.Errorf("client %s already connected", cfg.ID) 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": case "business-api":
client, err = businessapi.NewClient(cfg, m.eventBus, m.mediaConfig) client, err = businessapi.NewClient(cfg, m.eventBus, m.mediaConfig)
case "whatsmeow", "": 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: default:
m.mu.Unlock()
return fmt.Errorf("unknown client type: %s", cfg.Type) return fmt.Errorf("unknown client type: %s", cfg.Type)
} }
if err != nil { if err != nil {
m.mu.Unlock()
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
if err := client.Connect(ctx); err != nil { // Register client immediately so it's available for QR code serving during pairing
return fmt.Errorf("failed to connect: %w", err)
}
m.clients[cfg.ID] = client 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 return nil
} }
@@ -169,3 +186,29 @@ func (m *Manager) GetClient(id string) (Client, bool) {
client, exists := m.clients[id] client, exists := m.clients[id]
return client, exists 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
}
}
}

View File

@@ -1,14 +1,11 @@
package whatsmeow package whatsmeow
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"image"
"image/png"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -20,7 +17,9 @@ import (
qrterminal "github.com/mdp/qrterminal/v3" qrterminal "github.com/mdp/qrterminal/v3"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
waEvents "go.mau.fi/whatsmeow/types/events" waEvents "go.mau.fi/whatsmeow/types/events"
@@ -43,11 +42,13 @@ type Client struct {
showQR bool showQR bool
keepAliveCancel context.CancelFunc keepAliveCancel context.CancelFunc
qrCode string qrCode string
qrCodePNG []byte // Cached PNG data
qrCodeMutex sync.RWMutex qrCodeMutex sync.RWMutex
onPhoneUpdate func(accountID, phoneNumber string) // Callback when phone number is updated
} }
// NewClient creates a new whatsmeow client // 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 != "" { if cfg.Type != "whatsmeow" && cfg.Type != "" {
return nil, fmt.Errorf("invalid client type for whatsmeow: %s", 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{ return &Client{
id: cfg.ID, id: cfg.ID,
phoneNumber: cfg.PhoneNumber, phoneNumber: cfg.PhoneNumber,
sessionPath: sessionPath, sessionPath: sessionPath,
eventBus: eventBus, eventBus: eventBus,
mediaConfig: mediaConfig, mediaConfig: mediaConfig,
showQR: cfg.ShowQR, showQR: cfg.ShowQR,
onPhoneUpdate: onPhoneUpdate,
}, nil }, nil
} }
@@ -73,6 +75,10 @@ func (c *Client) Connect(ctx context.Context) error {
if err := os.MkdirAll(c.sessionPath, 0700); err != nil { if err := os.MkdirAll(c.sessionPath, 0700); err != nil {
return fmt.Errorf("failed to create session directory: %w", err) 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 // Create database container for session storage
dbPath := filepath.Join(c.sessionPath, "session.db") 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) return fmt.Errorf("failed to get device: %w", err)
} }
// Set custom client information // Set custom client information that will be shown in WhatsApp
deviceStore.Platform = "WhatsHooked" deviceStore.Platform = "git.warky.dev/wdevs/whatshooked"
deviceStore.BusinessName = "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 // Create client
clientLog := waLog.Stdout("Client", "ERROR", true) clientLog := waLog.Stdout("Client", "ERROR", true)
@@ -118,11 +133,21 @@ func (c *Client) Connect(ctx context.Context) error {
case "code": case "code":
logging.Info("QR code received for pairing", "account_id", c.id) 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.qrCodeMutex.Lock()
c.qrCode = evt.Code c.qrCode = evt.Code
c.qrCodePNG = pngData
qrCodeSize := len(pngData)
c.qrCodeMutex.Unlock() c.qrCodeMutex.Unlock()
logging.Debug("QR code PNG updated", "account_id", c.id, "size_bytes", qrCodeSize)
// Generate QR code URL // Generate QR code URL
qrURL := c.generateQRCodeURL() qrURL := c.generateQRCodeURL()
@@ -143,10 +168,24 @@ func (c *Client) Connect(ctx context.Context) error {
case "success": case "success":
logging.Info("Pairing successful", "account_id", c.id, "phone", c.phoneNumber) 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)) c.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, c.id))
case "timeout": case "timeout":
logging.Warn("QR code timeout", "account_id", c.id) 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)) c.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, c.id))
case "error": case "error":
@@ -167,12 +206,7 @@ func (c *Client) Connect(ctx context.Context) error {
} }
} }
if deviceStore.PushName == "" { // PushName is already set before pairing, no need to set it again here
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)
}
}
if client.IsConnected() { if client.IsConnected() {
err := client.SendPresence(ctx, types.PresenceAvailable) err := client.SendPresence(ctx, types.PresenceAvailable)
@@ -521,6 +555,11 @@ func (c *Client) handleEvent(evt interface{}) {
if c.phoneNumber != phoneNumber { if c.phoneNumber != phoneNumber {
c.phoneNumber = phoneNumber c.phoneNumber = phoneNumber
logging.Info("Updated phone number from WhatsApp", "account_id", c.id, "phone", 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 != "" { } else if c.phoneNumber != "" {
phoneNumber = c.phoneNumber phoneNumber = c.phoneNumber
@@ -703,49 +742,31 @@ func (c *Client) generateQRCodeURL() string {
return fmt.Sprintf("%s/api/qr/%s", baseURL, c.id) 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) { func (c *Client) GetQRCodePNG() ([]byte, error) {
c.qrCodeMutex.RLock() c.qrCodeMutex.RLock()
qrCodeData := c.qrCode defer c.qrCodeMutex.RUnlock()
c.qrCodeMutex.RUnlock()
if qrCodeData == "" { if c.qrCode == "" {
return nil, fmt.Errorf("no QR code available") 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 // Generate QR code using rsc.io/qr
code, err := qr.Encode(qrCodeData, qr.L) code, err := qr.Encode(qrCodeData, qr.H)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encode QR code: %w", err) return nil, fmt.Errorf("failed to encode QR code: %w", err)
} }
// Scale the QR code for better visibility (8x scale) // Use the library's built-in PNG method with a scale factor
img := code.Image() // Scale of 8 means each QR module is 8x8 pixels
scale := 8 return code.PNG(), nil
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
} }

View File

@@ -155,10 +155,10 @@ func (s *Server) startAutocertTLS(tlsConfig *config.TLSConfig, addr string) erro
// Create autocert manager // Create autocert manager
certManager := &autocert.Manager{ certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS, Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(tlsConfig.Domain), HostPolicy: autocert.HostWhitelist(tlsConfig.Domain),
Cache: autocert.DirCache(tlsConfig.CacheDir), Cache: autocert.DirCache(tlsConfig.CacheDir),
Email: tlsConfig.Email, Email: tlsConfig.Email,
} }
// Configure TLS // Configure TLS
@@ -215,6 +215,7 @@ func (s *Server) setupRoutes() *http.ServeMux {
// Account management (with auth) // Account management (with auth)
mux.HandleFunc("/api/accounts", h.Auth(h.Accounts)) mux.HandleFunc("/api/accounts", h.Auth(h.Accounts))
mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount)) mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount))
mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount))
// Send messages (with auth) // Send messages (with auth)
mux.HandleFunc("/api/send", h.Auth(h.SendMessage)) mux.HandleFunc("/api/send", h.Auth(h.SendMessage))