package whatsapp import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "os" "path/filepath" "sync" "time" "git.warky.dev/wdevs/whatshooked/internal/config" "git.warky.dev/wdevs/whatshooked/internal/events" "git.warky.dev/wdevs/whatshooked/internal/logging" qrterminal "github.com/mdp/qrterminal/v3" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" waEvents "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" "google.golang.org/protobuf/proto" _ "github.com/mattn/go-sqlite3" ) // Manager manages multiple WhatsApp client connections type Manager struct { clients map[string]*Client mu sync.RWMutex eventBus *events.EventBus mediaConfig config.MediaConfig config *config.Config configPath string onConfigUpdate func(*config.Config) error } // Client represents a single WhatsApp connection type Client struct { ID string PhoneNumber string Client *whatsmeow.Client Container *sqlstore.Container keepAliveCancel context.CancelFunc } // NewManager creates a new WhatsApp manager func NewManager(eventBus *events.EventBus, mediaConfig config.MediaConfig, cfg *config.Config, configPath string, onConfigUpdate func(*config.Config) error) *Manager { return &Manager{ clients: make(map[string]*Client), eventBus: eventBus, mediaConfig: mediaConfig, config: cfg, configPath: configPath, onConfigUpdate: onConfigUpdate, } } // Connect establishes a connection to a WhatsApp account 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 { return fmt.Errorf("client %s already connected", cfg.ID) } // Ensure session directory exists if err := os.MkdirAll(cfg.SessionPath, 0700); err != nil { return fmt.Errorf("failed to create session directory: %w", err) } // Create database container for session storage dbPath := filepath.Join(cfg.SessionPath, "session.db") dbLog := waLog.Stdout("Database", "ERROR", true) container, err := sqlstore.New(ctx, "sqlite3", "file:"+dbPath+"?_foreign_keys=on", dbLog) if err != nil { return fmt.Errorf("failed to create database container: %w", err) } // Get device store deviceStore, err := container.GetFirstDevice(ctx) if err != nil { return fmt.Errorf("failed to get device: %w", err) } // Set custom client information //if deviceStore.ID == nil { // Only set for new devices deviceStore.Platform = "WhatsHooked" deviceStore.BusinessName = "git.warky.dev/wdevs/whatshooked" //} // Create client clientLog := waLog.Stdout("Client", "ERROR", true) client := whatsmeow.NewClient(deviceStore, clientLog) // Register event handler client.AddEventHandler(func(evt interface{}) { m.handleEvent(cfg.ID, evt) }) // Connect if client.Store.ID == nil { // New device, need to pair qrChan, _ := client.GetQRChannel(ctx) if err := client.Connect(); err != nil { m.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, cfg.ID, err)) return fmt.Errorf("failed to connect: %w", err) } // Wait for QR code for evt := range qrChan { switch evt.Event { case "code": logging.Info("QR code received for pairing", "account_id", cfg.ID) // Always display QR code in terminal fmt.Println("\n========================================") fmt.Printf("WhatsApp QR Code for account: %s\n", cfg.ID) fmt.Printf("Phone: %s\n", cfg.PhoneNumber) fmt.Println("========================================") fmt.Println("Scan this QR code with WhatsApp on your phone:") qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) fmt.Println("========================================") // Publish QR code event m.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, cfg.ID, evt.Code)) case "success": logging.Info("Pairing successful", "account_id", cfg.ID, "phone", cfg.PhoneNumber) m.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, cfg.ID)) case "timeout": logging.Warn("QR code timeout", "account_id", cfg.ID) m.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, cfg.ID)) case "error": logging.Error("QR code error", "account_id", cfg.ID, "error", evt.Error) m.eventBus.Publish(events.WhatsAppQRErrorEvent(ctx, cfg.ID, fmt.Errorf("%v", evt.Error))) default: logging.Info("Pairing event", "account_id", cfg.ID, "event", evt.Event) m.eventBus.Publish(events.WhatsAppPairEventGeneric(ctx, cfg.ID, evt.Event, map[string]any{ "code": evt.Code, })) } } } else { // Already paired, just connect if err := client.Connect(); err != nil { return fmt.Errorf("failed to connect: %w", err) } } if deviceStore.PushName == "" { deviceStore.PushName = fmt.Sprintf("WhatsHooked %s", cfg.PhoneNumber) if err := deviceStore.Save(ctx); err != nil { logging.Error("failed to save device store %s", cfg.ID) } } waClient := &Client{ ID: cfg.ID, PhoneNumber: cfg.PhoneNumber, Client: client, Container: container, } m.clients[cfg.ID] = waClient if client.IsConnected() { err := client.SendPresence(ctx, types.PresenceAvailable) if err != nil { logging.Warn("Failed to send presence", "account_id", cfg.ID, "error", err) } else { logging.Debug("Sent presence update", "account_id", cfg.ID) } } // Start keep-alive routine m.startKeepAlive(waClient) logging.Info("WhatsApp client connected", "account_id", cfg.ID, "phone", cfg.PhoneNumber) return nil } // Disconnect disconnects a WhatsApp client func (m *Manager) Disconnect(id string) error { m.mu.Lock() defer m.mu.Unlock() client, exists := m.clients[id] if !exists { return fmt.Errorf("client %s not found", id) } // Stop keep-alive if client.keepAliveCancel != nil { client.keepAliveCancel() } client.Client.Disconnect() delete(m.clients, id) logging.Info("WhatsApp client disconnected", "account_id", id) return nil } // DisconnectAll disconnects all WhatsApp clients func (m *Manager) DisconnectAll() { m.mu.Lock() defer m.mu.Unlock() for id, client := range m.clients { // Stop keep-alive if client.keepAliveCancel != nil { client.keepAliveCancel() } client.Client.Disconnect() logging.Info("WhatsApp client disconnected", "account_id", id) } m.clients = make(map[string]*Client) } // SendTextMessage sends a text message from a specific account func (m *Manager) SendTextMessage(ctx context.Context, accountID string, jid types.JID, text string) error { if ctx == nil { ctx = context.Background() } m.mu.RLock() client, exists := m.clients[accountID] m.mu.RUnlock() if !exists { err := fmt.Errorf("client %s not found", accountID) m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), text, err)) return err } msg := &waE2E.Message{ Conversation: proto.String(text), } resp, err := client.Client.SendMessage(ctx, jid, msg) if err != nil { m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), text, err)) return fmt.Errorf("failed to send message: %w", err) } logging.Debug("Message sent", "account_id", accountID, "to", jid.String()) m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), text)) return nil } // SendImage sends an image message from a specific account func (m *Manager) SendImage(ctx context.Context, accountID string, jid types.JID, imageData []byte, mimeType string, caption string) error { if ctx == nil { ctx = context.Background() } m.mu.RLock() client, exists := m.clients[accountID] m.mu.RUnlock() if !exists { err := fmt.Errorf("client %s not found", accountID) m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) return err } // Upload the image uploaded, err := client.Client.Upload(ctx, imageData, whatsmeow.MediaImage) if err != nil { m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) return fmt.Errorf("failed to upload image: %w", err) } // Create image message msg := &waE2E.Message{ ImageMessage: &waE2E.ImageMessage{ URL: proto.String(uploaded.URL), DirectPath: proto.String(uploaded.DirectPath), MediaKey: uploaded.MediaKey, Mimetype: proto.String(mimeType), FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(imageData))), }, } // Add caption if provided if caption != "" { msg.ImageMessage.Caption = proto.String(caption) } // Send the message resp, err := client.Client.SendMessage(ctx, jid, msg) if err != nil { m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) return fmt.Errorf("failed to send image: %w", err) } logging.Debug("Image sent", "account_id", accountID, "to", jid.String()) m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption)) return nil } // SendVideo sends a video message from a specific account func (m *Manager) SendVideo(ctx context.Context, accountID string, jid types.JID, videoData []byte, mimeType string, caption string) error { if ctx == nil { ctx = context.Background() } m.mu.RLock() client, exists := m.clients[accountID] m.mu.RUnlock() if !exists { err := fmt.Errorf("client %s not found", accountID) m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) return err } // Upload the video uploaded, err := client.Client.Upload(ctx, videoData, whatsmeow.MediaVideo) if err != nil { m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) return fmt.Errorf("failed to upload video: %w", err) } // Create video message msg := &waE2E.Message{ VideoMessage: &waE2E.VideoMessage{ URL: proto.String(uploaded.URL), DirectPath: proto.String(uploaded.DirectPath), MediaKey: uploaded.MediaKey, Mimetype: proto.String(mimeType), FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(videoData))), }, } // Add caption if provided if caption != "" { msg.VideoMessage.Caption = proto.String(caption) } // Send the message resp, err := client.Client.SendMessage(ctx, jid, msg) if err != nil { m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) return fmt.Errorf("failed to send video: %w", err) } logging.Debug("Video sent", "account_id", accountID, "to", jid.String()) m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption)) return nil } // SendDocument sends a document message from a specific account func (m *Manager) SendDocument(ctx context.Context, accountID string, jid types.JID, documentData []byte, mimeType string, filename string, caption string) error { if ctx == nil { ctx = context.Background() } m.mu.RLock() client, exists := m.clients[accountID] m.mu.RUnlock() if !exists { err := fmt.Errorf("client %s not found", accountID) m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) return err } // Upload the document uploaded, err := client.Client.Upload(ctx, documentData, whatsmeow.MediaDocument) if err != nil { m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) return fmt.Errorf("failed to upload document: %w", err) } // Create document message msg := &waE2E.Message{ DocumentMessage: &waE2E.DocumentMessage{ URL: proto.String(uploaded.URL), DirectPath: proto.String(uploaded.DirectPath), MediaKey: uploaded.MediaKey, Mimetype: proto.String(mimeType), FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(documentData))), FileName: proto.String(filename), }, } // Add caption if provided if caption != "" { msg.DocumentMessage.Caption = proto.String(caption) } // Send the message resp, err := client.Client.SendMessage(ctx, jid, msg) if err != nil { m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) return fmt.Errorf("failed to send document: %w", err) } logging.Debug("Document sent", "account_id", accountID, "to", jid.String(), "filename", filename) m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption)) return nil } // GetClient returns a client by ID func (m *Manager) GetClient(id string) (*Client, bool) { m.mu.RLock() defer m.mu.RUnlock() client, exists := m.clients[id] return client, exists } // handleEvent processes WhatsApp events func (m *Manager) handleEvent(accountID string, evt interface{}) { ctx := context.Background() switch v := evt.(type) { case *waEvents.Message: logging.Debug("Message received", "account_id", accountID, "from", v.Info.Sender.String()) // Get the client for downloading media m.mu.RLock() client, exists := m.clients[accountID] m.mu.RUnlock() if !exists { logging.Error("Client not found for message event", "account_id", accountID) return } // Extract message content based on type var text string var messageType string = "text" var mimeType string var filename string var mediaBase64 string var mediaURL string // Handle text messages if v.Message.Conversation != nil { text = *v.Message.Conversation messageType = "text" } else if v.Message.ExtendedTextMessage != nil && v.Message.ExtendedTextMessage.Text != nil { text = *v.Message.ExtendedTextMessage.Text messageType = "text" } // Handle image messages if v.Message.ImageMessage != nil { img := v.Message.ImageMessage messageType = "image" mimeType = img.GetMimetype() // Use filename from caption or default if img.Caption != nil { text = *img.Caption } // Download image data, err := client.Client.Download(ctx, img) if err != nil { logging.Error("Failed to download image", "account_id", accountID, "error", err) } else { filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64) } } // Handle video messages if v.Message.VideoMessage != nil { vid := v.Message.VideoMessage messageType = "video" mimeType = vid.GetMimetype() // Use filename from caption or default if vid.Caption != nil { text = *vid.Caption } // Download video data, err := client.Client.Download(ctx, vid) if err != nil { logging.Error("Failed to download video", "account_id", accountID, "error", err) } else { filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64) } } // Handle document messages if v.Message.DocumentMessage != nil { doc := v.Message.DocumentMessage messageType = "document" mimeType = doc.GetMimetype() // Use provided filename or generate one if doc.FileName != nil { filename = *doc.FileName } // Use caption as text if provided if doc.Caption != nil { text = *doc.Caption } // Download document data, err := client.Client.Download(ctx, doc) if err != nil { logging.Error("Failed to download document", "account_id", accountID, "error", err) } else { filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64) } } // Publish message received event m.eventBus.Publish(events.MessageReceivedEvent( ctx, accountID, v.Info.ID, v.Info.Sender.String(), v.Info.Chat.String(), text, v.Info.Timestamp, v.Info.IsGroup, "", // group name - TODO: extract from message "", // sender name - TODO: extract from message messageType, mimeType, filename, mediaBase64, mediaURL, )) case *waEvents.Connected: logging.Info("WhatsApp connected", "account_id", accountID) // Get phone number and client for account m.mu.RLock() client, exists := m.clients[accountID] m.mu.RUnlock() phoneNumber := "" if exists { // Get the actual phone number from WhatsApp if client.Client.Store.ID != nil { actualPhone := client.Client.Store.ID.User phoneNumber = "+" + actualPhone // Update phone number in client and config if it's different if client.PhoneNumber != phoneNumber { client.PhoneNumber = phoneNumber logging.Info("Updated phone number from WhatsApp", "account_id", accountID, "phone", phoneNumber) // Update config m.updateConfigPhoneNumber(accountID, phoneNumber) } } else if client.PhoneNumber != "" { phoneNumber = client.PhoneNumber } } m.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, accountID, phoneNumber)) case *waEvents.Disconnected: logging.Warn("WhatsApp disconnected", "account_id", accountID) m.eventBus.Publish(events.WhatsAppDisconnectedEvent(ctx, accountID, "connection lost")) case *waEvents.Receipt: // Handle delivery and read receipts if v.Type == types.ReceiptTypeDelivered { for _, messageID := range v.MessageIDs { logging.Debug("Message delivered", "account_id", accountID, "message_id", messageID, "from", v.Sender.String()) m.eventBus.Publish(events.MessageDeliveredEvent(ctx, accountID, messageID, v.Sender.String(), v.Timestamp)) } } else if v.Type == types.ReceiptTypeRead { for _, messageID := range v.MessageIDs { logging.Debug("Message read", "account_id", accountID, "message_id", messageID, "from", v.Sender.String()) m.eventBus.Publish(events.MessageReadEvent(ctx, accountID, messageID, v.Sender.String(), v.Timestamp)) } } } } // startKeepAlive starts a goroutine that sends presence updates to keep the connection alive func (m *Manager) startKeepAlive(client *Client) { ctx, cancel := context.WithCancel(context.Background()) client.keepAliveCancel = cancel go func() { ticker := time.NewTicker(60 * time.Second) // Send presence every 60 seconds defer ticker.Stop() for { select { case <-ctx.Done(): logging.Debug("Keep-alive stopped", "account_id", client.ID) return case <-ticker.C: // Send presence as "available" if client.Client.IsConnected() { err := client.Client.SendPresence(ctx, types.PresenceAvailable) if err != nil { logging.Warn("Failed to send presence", "account_id", client.ID, "error", err) } else { logging.Debug("Sent presence update", "account_id", client.ID) } } } } }() logging.Info("Keep-alive started", "account_id", client.ID) } // updateConfigPhoneNumber updates the phone number for an account in the config and saves it func (m *Manager) updateConfigPhoneNumber(accountID, phoneNumber string) { if m.config == nil || m.onConfigUpdate == nil { return } // Find and update the account in the config for i := range m.config.WhatsApp { if m.config.WhatsApp[i].ID == accountID { m.config.WhatsApp[i].PhoneNumber = phoneNumber // Save the updated config if err := m.onConfigUpdate(m.config); err != nil { logging.Error("Failed to save updated config", "account_id", accountID, "error", err) } else { logging.Info("Config updated with phone number", "account_id", accountID, "phone", phoneNumber) } break } } } // processMediaData processes media based on the configured mode // Returns filename and mediaURL, and optionally sets mediaBase64 func (m *Manager) processMediaData(accountID, messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) { mode := m.mediaConfig.Mode var filename string var mediaURL string // Generate filename ext := getExtensionFromMimeType(mimeType) hash := sha256.Sum256(data) hashStr := hex.EncodeToString(hash[:8]) filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext) // Handle base64 mode if mode == "base64" || mode == "both" { *mediaBase64 = base64.StdEncoding.EncodeToString(data) } // Handle link mode if mode == "link" || mode == "both" { // Save file to disk filePath, err := m.saveMediaFile(accountID, messageID, data, mimeType) if err != nil { logging.Error("Failed to save media file", "account_id", accountID, "message_id", messageID, "error", err) } else { // Extract just the filename from the full path filename = filepath.Base(filePath) mediaURL = m.generateMediaURL(accountID, messageID, filename) } } return filename, mediaURL } // saveMediaFile saves media data to disk and returns the file path func (m *Manager) saveMediaFile(accountID, messageID string, data []byte, mimeType string) (string, error) { // Create account-specific media directory mediaDir := filepath.Join(m.mediaConfig.DataPath, accountID) if err := os.MkdirAll(mediaDir, 0755); err != nil { return "", fmt.Errorf("failed to create media directory: %w", err) } // Generate unique filename using message ID and hash hash := sha256.Sum256(data) hashStr := hex.EncodeToString(hash[:8]) // Use first 8 bytes of hash ext := getExtensionFromMimeType(mimeType) filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext) // Full path to file filePath := filepath.Join(mediaDir, filename) // Write file if err := os.WriteFile(filePath, data, 0644); err != nil { return "", fmt.Errorf("failed to write media file: %w", err) } return filePath, nil } // generateMediaURL generates a URL for accessing stored media func (m *Manager) generateMediaURL(accountID, messageID, filename string) string { baseURL := m.mediaConfig.BaseURL if baseURL == "" { baseURL = "http://localhost:8080" // default } return fmt.Sprintf("%s/api/media/%s/%s", baseURL, accountID, filename) } // getExtensionFromMimeType returns the file extension for a given MIME type func getExtensionFromMimeType(mimeType string) string { extensions := map[string]string{ // Images "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/bmp": ".bmp", "image/svg+xml": ".svg", // Videos "video/mp4": ".mp4", "video/mpeg": ".mpeg", "video/quicktime": ".mov", "video/x-msvideo": ".avi", "video/webm": ".webm", "video/3gpp": ".3gp", // Documents "application/pdf": ".pdf", "application/msword": ".doc", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", "application/vnd.ms-excel": ".xls", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", "application/vnd.ms-powerpoint": ".ppt", "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", "text/plain": ".txt", "text/html": ".html", "application/zip": ".zip", "application/x-rar-compressed": ".rar", "application/x-7z-compressed": ".7z", "application/json": ".json", "application/xml": ".xml", // Audio "audio/mpeg": ".mp3", "audio/ogg": ".ogg", "audio/wav": ".wav", "audio/aac": ".aac", "audio/x-m4a": ".m4a", } if ext, ok := extensions[mimeType]; ok { return ext } return "" // No extension if mime type is unknown }