Files
gochat/internal/channel/whatsapp/media.go
T
2026-06-04 15:44:48 +08:00

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
}
}