452 lines
15 KiB
Go
452 lines
15 KiB
Go
package whatsapp
|
|
|
|
// Media handling for WhatsApp channel attachments.
|
|
// Reference: Chatwoot's attachment handling:
|
|
// - app/services/whatsapp/send_on_whatsapp_service.rb (outgoing attachments)
|
|
// - WhatsApp Cloud API media upload: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media
|
|
// - WhatsApp Cloud API media download: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#downloading-media
|
|
//
|
|
// Media operations:
|
|
// 1. Download incoming media from WhatsApp Cloud API (images, videos, audio, documents, stickers)
|
|
// 2. Upload outgoing media to WhatsApp Cloud API media upload endpoint
|
|
// 3. Convert between attachment formats (WA media → gochat Attachment → local file)
|
|
// 4. Handle media retrieval URL construction (WhatsApp media IDs → download URLs)
|
|
//
|
|
// WhatsApp Cloud API media flow:
|
|
// - Incoming: webhook payload contains media type + media ID → retrieve URL via GET /{media-id} → download from URL
|
|
// - Outgoing: upload media via POST /{phone-number-id}/media → get media ID → send message with media ID
|
|
// - Media URLs expire; must be fetched fresh each time via the retrieve endpoint
|
|
//
|
|
// WhatsApp supported media types:
|
|
// - image/jpeg, image/png (max 5MB for static, 6MB for animated)
|
|
// - video/mp4, video/3gp (max 16MB)
|
|
// - audio/aac, audio/amr, audio/mp3, audio/ogg (opus codec) (max 16MB)
|
|
// - application/pdf, text/plain (max 100MB for documents)
|
|
// - sticker (WebP animated) (max 100KB static, 500KB animated)
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
|
|
"github.com/gochat/gochat/internal/channel"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// MediaService handles download, upload, and conversion of WhatsApp media.
|
|
type MediaService struct {
|
|
client *resty.Client
|
|
graphAPIBase string // e.g. "https://graph.facebook.com/v18.0"
|
|
storagePath string // local path for downloaded attachments
|
|
}
|
|
|
|
// NewMediaService creates a new media service for WhatsApp attachment handling.
|
|
func NewMediaService(graphAPIBase string, storagePath string) *MediaService {
|
|
client := resty.New()
|
|
client.SetTimeout(60 * time.Second) // longer timeout for media downloads
|
|
client.SetRetryCount(2)
|
|
|
|
return &MediaService{
|
|
client: client,
|
|
graphAPIBase: graphAPIBase,
|
|
storagePath: storagePath,
|
|
}
|
|
}
|
|
|
|
// === Incoming Media ===
|
|
|
|
// RetrieveMediaURL fetches the download URL for a WhatsApp media ID.
|
|
// WhatsApp Cloud API: GET /{media-id} returns { "url": "...", "mimeType": "...", "fileSize": ... }
|
|
// The URL is temporary and expires; must be fetched fresh each time.
|
|
func (ms *MediaService) RetrieveMediaURL(ctx context.Context, mediaID string, accessToken string) (*MediaRetrieveResponse, error) {
|
|
url := fmt.Sprintf("%s/%s", ms.graphAPIBase, mediaID)
|
|
|
|
resp, err := ms.client.R().
|
|
SetContext(ctx).
|
|
SetAuthToken(accessToken).
|
|
SetHeader("Content-Type", "application/json").
|
|
Get(url)
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve media URL for ID %s: %w", mediaID, err)
|
|
}
|
|
|
|
if resp.StatusCode() != http.StatusOK {
|
|
return nil, fmt.Errorf("WhatsApp media retrieve API returned status %d: %s", resp.StatusCode(), resp.String())
|
|
}
|
|
|
|
var result MediaRetrieveResponse
|
|
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
|
return nil, fmt.Errorf("failed to parse media retrieve response: %w", err)
|
|
}
|
|
|
|
applogger.L().Info("Retrieved WhatsApp media URL",
|
|
"media_id", mediaID,
|
|
"url", result.URL,
|
|
"mime_type", result.MimeType)
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// DownloadAttachment downloads a media file from a WhatsApp CDN URL to local storage.
|
|
// WhatsApp CDN URLs are temporary — they expire after a period.
|
|
func (ms *MediaService) DownloadAttachment(ctx context.Context, url string, filename string, accessToken string) (string, error) {
|
|
if ms.storagePath == "" {
|
|
ms.storagePath = "/tmp/gochat/whatsapp/media"
|
|
}
|
|
|
|
if err := os.MkdirAll(ms.storagePath, 0755); err != nil {
|
|
return "", fmt.Errorf("failed to create media storage directory: %w", err)
|
|
}
|
|
|
|
// WhatsApp CDN URLs require authorization header
|
|
localPath := filepath.Join(ms.storagePath, filename)
|
|
resp, err := ms.client.R().
|
|
SetContext(ctx).
|
|
SetAuthToken(accessToken).
|
|
SetOutput(localPath).
|
|
Get(url)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to download WhatsApp media from %s: %w", url, err)
|
|
}
|
|
|
|
if resp.StatusCode() != http.StatusOK {
|
|
// Clean up failed download
|
|
os.Remove(localPath)
|
|
return "", fmt.Errorf("WhatsApp CDN returned status %d for media download", resp.StatusCode())
|
|
}
|
|
|
|
applogger.L().Info("WhatsApp attachment downloaded successfully",
|
|
"url", url, "local_path", localPath,
|
|
"size", resp.Size())
|
|
|
|
return localPath, nil
|
|
}
|
|
|
|
// DownloadWhatsAppMedia handles the full incoming media download flow:
|
|
// 1. Retrieve media URL from WhatsApp API using media ID
|
|
// 2. Download the actual media file from the CDN URL
|
|
// 3. Return a gochat Attachment with all metadata
|
|
//
|
|
// mediaType is the WhatsApp message type ("image", "video", "audio", "document", "sticker").
|
|
// mediaContent is the WAMediaContent from the webhook payload containing ID, caption, etc.
|
|
func (ms *MediaService) DownloadWhatsAppMedia(ctx context.Context, mediaType string, mediaContent WAMediaContent, accessToken string) (*channel.Attachment, error) {
|
|
// Step 1: Retrieve the CDN download URL from the media ID
|
|
retrieveResp, err := ms.RetrieveMediaURL(ctx, mediaContent.ID, accessToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve WhatsApp media URL: %w", err)
|
|
}
|
|
|
|
// Step 2: Determine filename
|
|
filename := mediaContent.Caption
|
|
if filename == "" {
|
|
filename = mediaContent.Filename // documents may have a filename
|
|
}
|
|
if filename == "" {
|
|
filename = generateFilenameFromMediaID(mediaContent.ID, mediaType)
|
|
}
|
|
|
|
// Step 3: Download from CDN URL
|
|
localPath, err := ms.DownloadAttachment(ctx, retrieveResp.URL, filename, accessToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to download WhatsApp media: %w", err)
|
|
}
|
|
|
|
// Step 4: Construct gochat Attachment
|
|
contentType := mediaContent.MimeType
|
|
if contentType == "" {
|
|
contentType = mimeTypeFromWhatsAppType(mediaType)
|
|
}
|
|
|
|
attachment := &channel.Attachment{
|
|
URL: retrieveResp.URL,
|
|
ContentType: contentType,
|
|
Filename: filename,
|
|
FileSize: getFileSize(localPath),
|
|
Extra: map[string]interface{}{
|
|
"local_path": localPath,
|
|
"wa_media_id": mediaContent.ID,
|
|
"wa_media_type": mediaType,
|
|
"wa_caption": mediaContent.Caption,
|
|
"wa_sha256": mediaContent.SHA256,
|
|
"wa_file_size": mediaContent.FileSize,
|
|
"retrieved_mime_type": retrieveResp.MimeType,
|
|
},
|
|
}
|
|
|
|
// Add thumbnail URL for images/videos
|
|
if mediaType == "image" || mediaType == "video" {
|
|
attachment.ThumbnailURL = retrieveResp.URL
|
|
}
|
|
|
|
return attachment, nil
|
|
}
|
|
|
|
// DownloadWhatsAppDocument handles the full incoming document download flow.
|
|
// Documents use WADocumentContent which has a Filename field.
|
|
func (ms *MediaService) DownloadWhatsAppDocument(ctx context.Context, docContent WADocumentContent, accessToken string) (*channel.Attachment, error) {
|
|
return ms.DownloadWhatsAppMedia(ctx, "document", WAMediaContent{
|
|
Caption: docContent.Caption,
|
|
ID: docContent.ID,
|
|
MimeType: docContent.MimeType,
|
|
SHA256: docContent.SHA256,
|
|
Filename: docContent.Filename,
|
|
FileSize: docContent.FileSize,
|
|
}, accessToken)
|
|
}
|
|
|
|
// === Outgoing Media ===
|
|
|
|
// UploadMedia uploads a local file to the WhatsApp Cloud API media endpoint.
|
|
// WhatsApp Cloud API: POST /{phone-number-id}/media
|
|
// - Form fields: type (MIME type), messaging_product: "whatsapp"
|
|
// - File: the actual media file
|
|
// Returns the uploaded media ID for use in message sending.
|
|
func (ms *MediaService) UploadMedia(ctx context.Context, phoneNumberID string, accessToken string, filePath string, mimeType string) (string, error) {
|
|
url := fmt.Sprintf("%s/%s/media", ms.graphAPIBase, phoneNumberID)
|
|
|
|
// Open the file
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open file %s: %w", filePath, err)
|
|
}
|
|
defer file.Close()
|
|
|
|
resp, err := ms.client.R().
|
|
SetContext(ctx).
|
|
SetAuthToken(accessToken).
|
|
SetFileReader("file", filepath.Base(filePath), file).
|
|
SetFormData(map[string]string{
|
|
"type": mimeType,
|
|
"messaging_product": "whatsapp",
|
|
}).
|
|
Post(url)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to upload WhatsApp media: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode() != http.StatusOK {
|
|
return "", fmt.Errorf("WhatsApp media upload API returned status %d: %s", resp.StatusCode(), resp.String())
|
|
}
|
|
|
|
var result MediaUploadResponse
|
|
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
|
return "", fmt.Errorf("failed to parse media upload response: %w", err)
|
|
}
|
|
|
|
applogger.L().Info("WhatsApp media uploaded successfully",
|
|
"file_path", filePath,
|
|
"media_id", result.ID,
|
|
"mime_type", mimeType)
|
|
|
|
return result.ID, nil
|
|
}
|
|
|
|
// UploadAttachment uploads a gochat Attachment to WhatsApp and returns the media ID.
|
|
func (ms *MediaService) UploadAttachment(ctx context.Context, phoneNumberID string, accessToken string, attachment *channel.Attachment) (string, error) {
|
|
// Get the local file path from the attachment Extra metadata
|
|
localPath, ok := attachment.Extra["local_path"].(string)
|
|
if !ok || localPath == "" {
|
|
return "", fmt.Errorf("attachment has no local_path for upload")
|
|
}
|
|
|
|
mimeType := attachment.ContentType
|
|
if mimeType == "" {
|
|
mimeType = "application/octet-stream"
|
|
}
|
|
|
|
return ms.UploadMedia(ctx, phoneNumberID, accessToken, localPath, mimeType)
|
|
}
|
|
|
|
// UploadFromURL downloads a file from a URL and uploads it to WhatsApp.
|
|
// Useful for forwarding attachments received from other channels.
|
|
func (ms *MediaService) UploadFromURL(ctx context.Context, phoneNumberID string, accessToken string, sourceURL string, mimeType string) (string, error) {
|
|
// Download the file first
|
|
resp, err := ms.client.R().
|
|
SetContext(ctx).
|
|
Get(sourceURL)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to download source URL %s: %w", sourceURL, err)
|
|
}
|
|
|
|
if resp.StatusCode() != http.StatusOK {
|
|
return "", fmt.Errorf("source URL returned status %d", resp.StatusCode())
|
|
}
|
|
|
|
// Save to temp file
|
|
tmpPath := filepath.Join(ms.storagePath, fmt.Sprintf("upload_%d_%s", time.Now().UnixNano(), generateFilenameFromURL(sourceURL)))
|
|
if err := os.MkdirAll(filepath.Dir(tmpPath), 0755); err != nil {
|
|
return "", fmt.Errorf("failed to create temp directory: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(tmpPath, resp.Body(), 0644); err != nil {
|
|
return "", fmt.Errorf("failed to write temp file: %w", err)
|
|
}
|
|
|
|
defer os.Remove(tmpPath) // Clean up temp file after upload
|
|
|
|
// Upload to WhatsApp
|
|
return ms.UploadMedia(ctx, phoneNumberID, accessToken, tmpPath, mimeType)
|
|
}
|
|
|
|
// === Helper Functions ===
|
|
|
|
// generateFilenameFromMediaID creates a filename from WhatsApp media ID and type.
|
|
func generateFilenameFromMediaID(mediaID string, mediaType string) string {
|
|
ext := extensionFromWhatsAppType(mediaType)
|
|
return fmt.Sprintf("wa_%s%s", mediaID, ext)
|
|
}
|
|
|
|
// generateFilenameFromURL extracts a filename from a URL path.
|
|
func generateFilenameFromURL(url string) string {
|
|
parts := strings.Split(url, "/")
|
|
if len(parts) > 0 {
|
|
name := parts[len(parts)-1]
|
|
// Remove query parameters
|
|
if idx := strings.Index(name, "?"); idx > 0 {
|
|
name = name[:idx]
|
|
}
|
|
if name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return fmt.Sprintf("media_%d", time.Now().UnixNano())
|
|
}
|
|
|
|
// mimeTypeFromWhatsAppType converts WhatsApp media type to MIME type.
|
|
func mimeTypeFromWhatsAppType(waType string) string {
|
|
switch waType {
|
|
case "image":
|
|
return "image/jpeg" // WhatsApp images are typically JPEG
|
|
case "video":
|
|
return "video/mp4" // WhatsApp videos are typically MP4
|
|
case "audio":
|
|
return "audio/ogg; codecs=opus" // WhatsApp voice messages use Opus codec
|
|
case "document":
|
|
return "application/pdf" // WhatsApp documents, could be any type
|
|
case "sticker":
|
|
return "image/webp" // WhatsApp stickers are WebP
|
|
case "location":
|
|
return "text/plain" // Location is not a real media type
|
|
case "contacts":
|
|
return "text/plain" // Contacts are not a real media type
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// extensionFromWhatsAppType returns a file extension for a WhatsApp media type.
|
|
func extensionFromWhatsAppType(waType string) string {
|
|
switch waType {
|
|
case "image":
|
|
return ".jpg"
|
|
case "video":
|
|
return ".mp4"
|
|
case "audio":
|
|
return ".ogg"
|
|
case "document":
|
|
return ".pdf"
|
|
case "sticker":
|
|
return ".webp"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// getFileSize returns the file size in bytes for a local file path.
|
|
func getFileSize(path string) int64 {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return info.Size()
|
|
}
|
|
|
|
// === WhatsApp Cloud API Media Response Types ===
|
|
|
|
// MediaRetrieveResponse is the response from GET /{media-id}.
|
|
type MediaRetrieveResponse struct {
|
|
URL string `json:"url"` // Temporary CDN URL for downloading the media
|
|
MimeType string `json:"mime_type"` // MIME type of the media
|
|
FileSize int64 `json:"file_size"` // File size in bytes
|
|
ID string `json:"id"` // Media ID (same as requested)
|
|
SHA256 string `json:"sha256"` // SHA256 hash of the media file
|
|
}
|
|
|
|
// MediaUploadResponse is the response from POST /{phone-number-id}/media.
|
|
type MediaUploadResponse struct {
|
|
ID string `json:"id"` // The uploaded media ID for use in message sending
|
|
}
|
|
|
|
// === WhatsApp Supported MIME Types ===
|
|
|
|
// WhatsAppSupportedContentTypes returns the list of MIME types WhatsApp supports for media uploads.
|
|
func WhatsAppSupportedContentTypes() []string {
|
|
return []string{
|
|
// Images
|
|
"image/jpeg",
|
|
"image/png",
|
|
// Videos
|
|
"video/mp4",
|
|
"video/3gp",
|
|
// Audio
|
|
"audio/aac",
|
|
"audio/amr",
|
|
"audio/mp3",
|
|
"audio/ogg",
|
|
"audio/opus",
|
|
// Documents
|
|
"application/pdf",
|
|
"text/plain",
|
|
"application/vnd.ms-excel",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"application/msword",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"application/vnd.ms-powerpoint",
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
// Stickers
|
|
"image/webp",
|
|
}
|
|
}
|
|
|
|
// IsWhatsAppSupportedContentType checks if a MIME type is supported by WhatsApp.
|
|
func IsWhatsAppSupportedContentType(mimeType string) bool {
|
|
for _, supported := range WhatsAppSupportedContentTypes() {
|
|
if strings.EqualFold(mimeType, supported) {
|
|
return true
|
|
}
|
|
}
|
|
// WhatsApp also accepts any application/* content type as document
|
|
if strings.HasPrefix(mimeType, "application/") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// WhatsAppMaxMediaSize returns the maximum allowed size for a given WhatsApp media type in bytes.
|
|
func WhatsAppMaxMediaSize(waType string) int64 {
|
|
switch waType {
|
|
case "image":
|
|
return 5 * 1024 * 1024 // 5MB (static), 6MB (animated)
|
|
case "video":
|
|
return 16 * 1024 * 1024 // 16MB
|
|
case "audio":
|
|
return 16 * 1024 * 1024 // 16MB
|
|
case "document":
|
|
return 100 * 1024 * 1024 // 100MB
|
|
case "sticker":
|
|
return 500 * 1024 // 500KB (animated), 100KB (static)
|
|
default:
|
|
return 16 * 1024 * 1024 // Default to 16MB
|
|
}
|
|
} |