package businessapi import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "time" "git.warky.dev/wdevs/whatshooked/internal/events" "git.warky.dev/wdevs/whatshooked/internal/logging" ) // HandleWebhook processes incoming webhook events from WhatsApp Business API func (c *Client) HandleWebhook(r *http.Request) error { body, err := io.ReadAll(r.Body) if err != nil { return fmt.Errorf("failed to read request body: %w", err) } var payload WebhookPayload if err := json.Unmarshal(body, &payload); err != nil { return fmt.Errorf("failed to parse webhook payload: %w", err) } // Process each entry for _, entry := range payload.Entry { for _, change := range entry.Changes { c.processChange(change) } } return nil } // processChange processes a webhook change func (c *Client) processChange(change WebhookChange) { ctx := context.Background() // Process messages for _, msg := range change.Value.Messages { c.processMessage(ctx, msg, change.Value.Contacts) } // Process statuses for _, status := range change.Value.Statuses { c.processStatus(ctx, status) } } // processMessage processes an incoming message func (c *Client) processMessage(ctx context.Context, msg WebhookMessage, contacts []WebhookContact) { // Get sender name from contacts senderName := "" for _, contact := range contacts { if contact.WaID == msg.From { senderName = contact.Profile.Name break } } // Parse timestamp timestamp := c.parseTimestamp(msg.Timestamp) var text string var messageType string var mimeType string var filename string var mediaBase64 string var mediaURL string // Process based on message type switch msg.Type { case "text": if msg.Text != nil { text = msg.Text.Body } messageType = "text" case "image": if msg.Image != nil { messageType = "image" mimeType = msg.Image.MimeType text = msg.Image.Caption // Download and process media data, _, err := c.downloadMedia(ctx, msg.Image.ID) if err != nil { logging.Error("Failed to download image", "account_id", c.id, "media_id", msg.Image.ID, "error", err) } else { filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64) } } case "video": if msg.Video != nil { messageType = "video" mimeType = msg.Video.MimeType text = msg.Video.Caption // Download and process media data, _, err := c.downloadMedia(ctx, msg.Video.ID) if err != nil { logging.Error("Failed to download video", "account_id", c.id, "media_id", msg.Video.ID, "error", err) } else { filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64) } } case "document": if msg.Document != nil { messageType = "document" mimeType = msg.Document.MimeType text = msg.Document.Caption filename = msg.Document.Filename // Download and process media data, _, err := c.downloadMedia(ctx, msg.Document.ID) if err != nil { logging.Error("Failed to download document", "account_id", c.id, "media_id", msg.Document.ID, "error", err) } else { filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64) } } default: logging.Warn("Unsupported message type", "account_id", c.id, "type", msg.Type) return } // Publish message received event c.eventBus.Publish(events.MessageReceivedEvent( ctx, c.id, msg.ID, msg.From, msg.From, // For Business API, chat is same as sender for individual messages text, timestamp, false, // Business API doesn't indicate groups in this webhook "", senderName, messageType, mimeType, filename, mediaBase64, mediaURL, )) logging.Debug("Message received via Business API", "account_id", c.id, "from", msg.From, "type", messageType) } // processStatus processes a message status update func (c *Client) processStatus(ctx context.Context, status WebhookStatus) { timestamp := c.parseTimestamp(status.Timestamp) switch status.Status { case "sent": c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, status.ID, status.RecipientID, "")) logging.Debug("Message sent status", "account_id", c.id, "message_id", status.ID) case "delivered": c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, status.ID, status.RecipientID, timestamp)) logging.Debug("Message delivered", "account_id", c.id, "message_id", status.ID) case "read": c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, status.ID, status.RecipientID, timestamp)) logging.Debug("Message read", "account_id", c.id, "message_id", status.ID) case "failed": errMsg := "unknown error" if len(status.Errors) > 0 { errMsg = fmt.Sprintf("%s (code: %d)", status.Errors[0].Title, status.Errors[0].Code) } c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, status.RecipientID, "", fmt.Errorf("%s", errMsg))) logging.Error("Message failed", "account_id", c.id, "message_id", status.ID, "error", errMsg) default: logging.Debug("Unknown status type", "account_id", c.id, "status", status.Status) } } // parseTimestamp parses a Unix timestamp string to time.Time func (c *Client) parseTimestamp(ts string) time.Time { unix, err := strconv.ParseInt(ts, 10, 64) if err != nil { logging.Warn("Failed to parse timestamp", "timestamp", ts, "error", err) return time.Now() } return time.Unix(unix, 0) } // processMediaData processes media based on the configured mode func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) { mode := c.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 := 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 { filename = filepath.Base(filePath) mediaURL = c.generateMediaURL(messageID, filename) } } return filename, mediaURL } // saveMediaFile saves media data to disk func (c *Client) saveMediaFile(messageID string, data []byte, mimeType string) (string, error) { 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) } hash := sha256.Sum256(data) hashStr := hex.EncodeToString(hash[:8]) ext := getExtensionFromMimeType(mimeType) filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext) filePath := filepath.Join(mediaDir, filename) 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{ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "video/mp4": ".mp4", "video/mpeg": ".mpeg", "video/webm": ".webm", "video/3gpp": ".3gp", "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", "text/plain": ".txt", "application/json": ".json", "audio/mpeg": ".mp3", "audio/ogg": ".ogg", } if ext, ok := extensions[mimeType]; ok { return ext } return "" }