Major refactor to library

This commit is contained in:
2025-12-29 09:51:16 +02:00
parent ae169f81e4
commit 767a9e211f
38 changed files with 1073 additions and 492 deletions

View File

@@ -0,0 +1,288 @@
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/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/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 ""
}