package whatsmeow import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "os" "path/filepath" "time" "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" "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" ) // Client represents a WhatsApp connection using whatsmeow type Client struct { id string phoneNumber string sessionPath string client *whatsmeow.Client container *sqlstore.Container eventBus *events.EventBus mediaConfig config.MediaConfig showQR bool keepAliveCancel context.CancelFunc } // NewClient creates a new whatsmeow client func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig) (*Client, error) { if cfg.Type != "whatsmeow" && cfg.Type != "" { return nil, fmt.Errorf("invalid client type for whatsmeow: %s", cfg.Type) } sessionPath := cfg.SessionPath if sessionPath == "" { sessionPath = fmt.Sprintf("./sessions/%s", cfg.ID) } return &Client{ id: cfg.ID, phoneNumber: cfg.PhoneNumber, sessionPath: sessionPath, eventBus: eventBus, mediaConfig: mediaConfig, showQR: cfg.ShowQR, }, nil } // Connect establishes a connection to WhatsApp func (c *Client) Connect(ctx context.Context) error { // Ensure session directory exists if err := os.MkdirAll(c.sessionPath, 0700); err != nil { return fmt.Errorf("failed to create session directory: %w", err) } // Create database container for session storage dbPath := filepath.Join(c.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) } c.container = container // Get device store deviceStore, err := container.GetFirstDevice(ctx) if err != nil { return fmt.Errorf("failed to get device: %w", err) } // Set custom client information deviceStore.Platform = "WhatsHooked" deviceStore.BusinessName = "git.warky.dev/wdevs/whatshooked" // Create client clientLog := waLog.Stdout("Client", "ERROR", true) client := whatsmeow.NewClient(deviceStore, clientLog) c.client = client // Register event handler client.AddEventHandler(func(evt interface{}) { c.handleEvent(evt) }) // Connect if client.Store.ID == nil { // New device, need to pair qrChan, _ := client.GetQRChannel(ctx) if err := client.Connect(); err != nil { c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.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", c.id) // Display QR code in terminal fmt.Println("\n========================================") fmt.Printf("WhatsApp QR Code for account: %s\n", c.id) fmt.Printf("Phone: %s\n", c.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 c.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, c.id, evt.Code)) case "success": logging.Info("Pairing successful", "account_id", c.id, "phone", c.phoneNumber) c.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, c.id)) case "timeout": logging.Warn("QR code timeout", "account_id", c.id) c.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, c.id)) case "error": logging.Error("QR code error", "account_id", c.id, "error", evt.Error) c.eventBus.Publish(events.WhatsAppQRErrorEvent(ctx, c.id, fmt.Errorf("%v", evt.Error))) default: logging.Info("Pairing event", "account_id", c.id, "event", evt.Event) c.eventBus.Publish(events.WhatsAppPairEventGeneric(ctx, c.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", c.phoneNumber) if err := deviceStore.Save(ctx); err != nil { logging.Error("failed to save device store", "account_id", c.id) } } if client.IsConnected() { err := client.SendPresence(ctx, types.PresenceAvailable) if err != nil { logging.Warn("Failed to send presence", "account_id", c.id, "error", err) } else { logging.Debug("Sent presence update", "account_id", c.id) } } // Start keep-alive routine c.startKeepAlive() logging.Info("WhatsApp client connected", "account_id", c.id, "phone", c.phoneNumber) return nil } // Disconnect closes the WhatsApp connection func (c *Client) Disconnect() error { // Stop keep-alive if c.keepAliveCancel != nil { c.keepAliveCancel() } if c.client != nil { c.client.Disconnect() } logging.Info("WhatsApp client disconnected", "account_id", c.id) return nil } // IsConnected returns whether the client is connected func (c *Client) IsConnected() bool { if c.client == nil { return false } return c.client.IsConnected() } // GetID returns the client ID func (c *Client) GetID() string { return c.id } // GetPhoneNumber returns the phone number func (c *Client) GetPhoneNumber() string { return c.phoneNumber } // GetType returns the client type func (c *Client) GetType() string { return "whatsmeow" } // SendTextMessage sends a text message func (c *Client) SendTextMessage(ctx context.Context, jid types.JID, text string) (string, error) { if ctx == nil { ctx = context.Background() } if c.client == nil { err := fmt.Errorf("client not initialized") c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), text, err)) return "", err } msg := &waE2E.Message{ Conversation: proto.String(text), } resp, err := c.client.SendMessage(ctx, jid, msg) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), text, err)) return "", fmt.Errorf("failed to send message: %w", err) } logging.Debug("Message sent", "account_id", c.id, "to", jid.String()) c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), text)) return resp.ID, nil } // SendImage sends an image message func (c *Client) SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (string, error) { if ctx == nil { ctx = context.Background() } if c.client == nil { err := fmt.Errorf("client not initialized") c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) return "", err } // Upload the image uploaded, err := c.client.Upload(ctx, imageData, whatsmeow.MediaImage) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, 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 := c.client.SendMessage(ctx, jid, msg) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) return "", fmt.Errorf("failed to send image: %w", err) } logging.Debug("Image sent", "account_id", c.id, "to", jid.String()) c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption)) return resp.ID, nil } // SendVideo sends a video message func (c *Client) SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (string, error) { if ctx == nil { ctx = context.Background() } if c.client == nil { err := fmt.Errorf("client not initialized") c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) return "", err } // Upload the video uploaded, err := c.client.Upload(ctx, videoData, whatsmeow.MediaVideo) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, 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 := c.client.SendMessage(ctx, jid, msg) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) return "", fmt.Errorf("failed to send video: %w", err) } logging.Debug("Video sent", "account_id", c.id, "to", jid.String()) c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption)) return resp.ID, nil } // SendDocument sends a document message func (c *Client) SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (string, error) { if ctx == nil { ctx = context.Background() } if c.client == nil { err := fmt.Errorf("client not initialized") c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) return "", err } // Upload the document uploaded, err := c.client.Upload(ctx, documentData, whatsmeow.MediaDocument) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, 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 := c.client.SendMessage(ctx, jid, msg) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) return "", fmt.Errorf("failed to send document: %w", err) } logging.Debug("Document sent", "account_id", c.id, "to", jid.String(), "filename", filename) c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption)) return resp.ID, nil } // handleEvent processes WhatsApp events func (c *Client) handleEvent(evt interface{}) { ctx := context.Background() switch v := evt.(type) { case *waEvents.Message: logging.Debug("Message received", "account_id", c.id, "from", v.Info.Sender.String()) // Extract message content based on type var text string var messageType = "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() if img.Caption != nil { text = *img.Caption } // Download image data, err := c.client.Download(ctx, img) if err != nil { logging.Error("Failed to download image", "account_id", c.id, "error", err) } else { filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64) } } // Handle video messages if v.Message.VideoMessage != nil { vid := v.Message.VideoMessage messageType = "video" mimeType = vid.GetMimetype() if vid.Caption != nil { text = *vid.Caption } // Download video data, err := c.client.Download(ctx, vid) if err != nil { logging.Error("Failed to download video", "account_id", c.id, "error", err) } else { filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64) } } // Handle document messages if v.Message.DocumentMessage != nil { doc := v.Message.DocumentMessage messageType = "document" mimeType = doc.GetMimetype() if doc.FileName != nil { filename = *doc.FileName } if doc.Caption != nil { text = *doc.Caption } // Download document data, err := c.client.Download(ctx, doc) if err != nil { logging.Error("Failed to download document", "account_id", c.id, "error", err) } else { filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64) } } // Publish message received event c.eventBus.Publish(events.MessageReceivedEvent( ctx, c.id, 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", c.id) // Get the actual phone number from WhatsApp phoneNumber := "" if c.client.Store.ID != nil { actualPhone := c.client.Store.ID.User phoneNumber = "+" + actualPhone // Update phone number in client if it's different if c.phoneNumber != phoneNumber { c.phoneNumber = phoneNumber logging.Info("Updated phone number from WhatsApp", "account_id", c.id, "phone", phoneNumber) } } else if c.phoneNumber != "" { phoneNumber = c.phoneNumber } c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneNumber)) case *waEvents.Disconnected: logging.Warn("WhatsApp disconnected", "account_id", c.id) c.eventBus.Publish(events.WhatsAppDisconnectedEvent(ctx, c.id, "connection lost")) case *waEvents.Receipt: // Handle delivery and read receipts switch v.Type { case types.ReceiptTypeDelivered: for _, messageID := range v.MessageIDs { logging.Debug("Message delivered", "account_id", c.id, "message_id", messageID, "from", v.Sender.String()) c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp)) } case types.ReceiptTypeRead: for _, messageID := range v.MessageIDs { logging.Debug("Message read", "account_id", c.id, "message_id", messageID, "from", v.Sender.String()) c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp)) } } } } // startKeepAlive starts a goroutine that sends presence updates to keep the connection alive func (c *Client) startKeepAlive() { ctx, cancel := context.WithCancel(context.Background()) c.keepAliveCancel = cancel go func() { ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): logging.Debug("Keep-alive stopped", "account_id", c.id) return case <-ticker.C: if c.client != nil && c.client.IsConnected() { err := c.client.SendPresence(ctx, types.PresenceAvailable) if err != nil { logging.Warn("Failed to send presence", "account_id", c.id, "error", err) } else { logging.Debug("Sent presence update", "account_id", c.id) } } } } }() logging.Info("Keep-alive started", "account_id", c.id) } // processMediaData processes media based on the configured mode func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (filename string, mediaURL string) { mode := c.mediaConfig.Mode // 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 := c.saveMediaFile(messageID, data, mimeType) if err != nil { logging.Error("Failed to save media file", "account_id", c.id, "message_id", messageID, "error", err) } else { // Extract just the filename from the full path filename = filepath.Base(filePath) mediaURL = c.generateMediaURL(messageID, filename) } } return filename, mediaURL } // saveMediaFile saves media data to disk and returns the file path func (c *Client) saveMediaFile(messageID string, data []byte, mimeType string) (string, error) { // Create account-specific media directory mediaDir := filepath.Join(c.mediaConfig.DataPath, c.id) 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]) 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 (c *Client) generateMediaURL(messageID, filename string) string { baseURL := c.mediaConfig.BaseURL if baseURL == "" { baseURL = "http://localhost:8080" } return fmt.Sprintf("%s/api/media/%s/%s", baseURL, c.id, 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 "" }