package facebook // Media handling for Facebook/Instagram channel attachments. // Reference: Chatwoot's attachment handling: // - app/services/facebook/send_on_facebook_service.rb (outgoing attachments) // - app/services/telegram/send_attachments_service.rb (upload + send) // - Facebook Send API attachment upload: https://developers.facebook.com/docs/messenger-platform/send-messages#attachment-upload // - Instagram DM attachment upload: https://developers.facebook.com/docs/instagram-api/guides/messaging#attachment-upload // // Media operations: // 1. Download incoming media from FB/IG CDN URLs (images, videos, audio, files) // 2. Upload outgoing media to FB/IG attachment upload API // 3. Convert between attachment formats (FB URL → gochat Attachment → local file) // 4. Handle media expiration (FB CDN URLs expire after a period) import ( "bytes" "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 Facebook/Instagram media. type MediaService struct { client *resty.Client graphAPIBase string storagePath string // local path for downloaded attachments } // NewMediaService creates a new media service for FB/IG 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 === // DownloadAttachment downloads a media file from a Facebook CDN URL. // FB CDN URLs are temporary and expire, so we download them promptly. // Reference: Chatwoot downloads FB attachments via HTTP GET + saves locally. // // Returns the local file path where the attachment was saved. func (ms *MediaService) DownloadAttachment(ctx context.Context, url string, filename string) (string, error) { if url == "" { return "", fmt.Errorf("empty attachment URL") } // Ensure storage directory exists if err := os.MkdirAll(ms.storagePath, 0755); err != nil { return "", fmt.Errorf("failed to create storage directory: %w", err) } // Generate filename if not provided if filename == "" { filename = generateFilenameFromURL(url) } localPath := filepath.Join(ms.storagePath, filename) // Check if file already exists (avoid re-downloading) if _, err := os.Stat(localPath); err == nil { applogger.L().Debug("Attachment already downloaded", "path", localPath) return localPath, nil } // Download with resty ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() resp, err := ms.client.R(). SetContext(ctx). SetOutput(localPath). Get(url) if err != nil { return "", fmt.Errorf("failed to download attachment from %s: %w", url, err) } if resp.StatusCode() != http.StatusOK { // Clean up failed download os.Remove(localPath) return "", fmt.Errorf("attachment download returned HTTP %d for %s", resp.StatusCode(), url) } applogger.L().Info("Attachment downloaded successfully", "url", url, "local_path", localPath, "size", resp.Size()) return localPath, nil } // DownloadFBAttachment downloads an FBAttachment from its CDN URL and returns // a gochat Attachment with the local file info. func (ms *MediaService) DownloadFBAttachment(ctx context.Context, fbAtt FBAttachment, channelType channel.ChannelType) (*channel.Attachment, error) { url := fbAtt.Payload.URL if url == "" { // Some attachments (stickers, etc.) use Src instead of URL url = fbAtt.Payload.Src } if url == "" { return nil, fmt.Errorf("attachment has no download URL") } // Determine filename filename := fbAtt.Payload.Name if filename == "" { filename = generateFilenameFromURL(url) } localPath, err := ms.DownloadAttachment(ctx, url, filename) if err != nil { return nil, fmt.Errorf("failed to download FB attachment: %w", err) } attachment := &channel.Attachment{ URL: url, ContentType: mimeTypeFromFBType(fbAtt.Type), Filename: filename, FileSize: getFileSize(localPath), Extra: map[string]interface{}{ "local_path": localPath, "fb_attachment_type": fbAtt.Type, "channel_type": string(channelType), }, } // Add thumbnail URL for images/videos if fbAtt.Type == "image" || fbAtt.Type == "video" { // FB CDN URLs for thumbnails are typically the same URL with different sizing attachment.ThumbnailURL = url } // Add location coordinates if applicable if fbAtt.Type == "location" && fbAtt.Payload.Coordinates != nil { attachment.Extra["latitude"] = fbAtt.Payload.Coordinates.Lat attachment.Extra["longitude"] = fbAtt.Payload.Coordinates.Long } return attachment, nil } // === Outgoing Media === // UploadAttachment uploads a local file to the FB/IG attachment upload API. // This returns an attachment_id that can be used in the Send API. // Reference: https://developers.facebook.com/docs/messenger-platform/send-messages#attachment-upload // // Flow: // 1. Upload file to FB attachment upload endpoint → get attachment_id // 2. Send message with attachment_id instead of URL (for large files / reliability) func (ms *MediaService) UploadAttachment(ctx context.Context, pageAccessToken string, filePath string, attachmentType string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() // Read the file fileData, err := os.ReadFile(filePath) if err != nil { return "", fmt.Errorf("failed to read file %s: %w", filePath, err) } filename := filepath.Base(filePath) // Upload to FB Graph API resp, err := ms.client.R(). SetContext(ctx). SetFormData(map[string]string{ "access_token": pageAccessToken, "message": fmt.Sprintf(`{"attachment":{"type":"%s","payload":{"is_reusable":true}}}`, attachmentType), }). SetFileReader("filedata", filename, bytes.NewReader(fileData)). Post(fmt.Sprintf("%s/me/message_attachments", ms.graphAPIBase)) if err != nil { return "", fmt.Errorf("FB attachment upload failed: %w", err) } if resp.StatusCode() != http.StatusOK { return "", fmt.Errorf("FB attachment upload returned HTTP %d: %s", resp.StatusCode(), resp.String()) } // Parse response to get attachment_id var uploadResp FBAttachmentUploadResponse if err := parseJSONResponse(resp.Body(), &uploadResp); err != nil { return "", fmt.Errorf("failed to parse upload response: %w", err) } if uploadResp.AttachmentID == "" { return "", fmt.Errorf("FB attachment upload did not return attachment_id") } applogger.L().Info("FB attachment uploaded successfully", "attachment_id", uploadResp.AttachmentID, "type", attachmentType, "filename", filename) return uploadResp.AttachmentID, nil } // SendAttachmentWithURL sends a message with an attachment using a URL (for small files). // Reference: https://developers.facebook.com/docs/messenger-platform/send-messages#attachment-url func (ms *MediaService) SendAttachmentWithURL(ctx context.Context, pageAccessToken string, recipientID string, attachmentType string, url string) error { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() messagePayload := map[string]interface{}{ "recipient": map[string]string{ "id": recipientID, }, "message": map[string]interface{}{ "attachment": map[string]interface{}{ "type": attachmentType, "payload": map[string]interface{}{ "url": url, }, }, }, } resp, err := ms.client.R(). SetContext(ctx). SetQueryParam("access_token", pageAccessToken). SetBody(messagePayload). Post(fmt.Sprintf("%s/me/messages", ms.graphAPIBase)) if err != nil { return fmt.Errorf("FB send attachment (URL) failed: %w", err) } if resp.StatusCode() != http.StatusOK { return fmt.Errorf("FB send attachment (URL) returned HTTP %d: %s", resp.StatusCode(), resp.String()) } return nil } // SendAttachmentWithID sends a message with a previously uploaded attachment_id. // Reference: https://developers.facebook.com/docs/messenger-platform/send-messages#attachment-id func (ms *MediaService) SendAttachmentWithID(ctx context.Context, pageAccessToken string, recipientID string, attachmentType string, attachmentID string) error { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() messagePayload := map[string]interface{}{ "recipient": map[string]string{ "id": recipientID, }, "message": map[string]interface{}{ "attachment": map[string]interface{}{ "type": attachmentType, "payload": map[string]interface{}{ "attachment_id": attachmentID, }, }, }, } resp, err := ms.client.R(). SetContext(ctx). SetQueryParam("access_token", pageAccessToken). SetBody(messagePayload). Post(fmt.Sprintf("%s/me/messages", ms.graphAPIBase)) if err != nil { return fmt.Errorf("FB send attachment (ID) failed: %w", err) } if resp.StatusCode() != http.StatusOK { return fmt.Errorf("FB send attachment (ID) returned HTTP %d: %s", resp.StatusCode(), resp.String()) } return nil } // === Helper Functions === // generateFilenameFromURL creates a safe filename from a CDN URL. func generateFilenameFromURL(url string) string { // Extract the last path segment as base filename parts := strings.Split(url, "/") if len(parts) > 0 { base := parts[len(parts)-1] // Remove query parameters if idx := strings.Index(base, "?"); idx > 0 { base = base[:idx] } if base != "" { return base } } // Fallback: generate a timestamp-based filename return fmt.Sprintf("fb_attachment_%d", time.Now().UnixMilli()) } // getFileSize returns the file size in bytes, or 0 if the file cannot be accessed. func getFileSize(path string) int64 { info, err := os.Stat(path) if err != nil { return 0 } return info.Size() } // parseJSONResponse parses a JSON byte slice into the target struct. func parseJSONResponse(data []byte, target interface{}) error { return json.Unmarshal(data, target) } // mimeTypeFromFBType maps FB attachment types to MIME types. func mimeTypeFromFBType(fbType string) string { switch fbType { case "image": return "image/jpeg" case "video": return "video/mp4" case "audio": return "audio/mp4" case "file": return "application/octet-stream" case "location": return "application/json" case "template": return "application/json" case "fallback": return "application/octet-stream" default: return "application/octet-stream" } } // DetermineFBAttachmentType maps a gochat ContentType to an FB attachment type string. // Used for outgoing message attachment type mapping. func DetermineFBAttachmentType(contentType channel.ContentType) string { switch contentType { case channel.ContentImage: return "image" case channel.ContentVideo: return "video" case channel.ContentAudio: return "audio" case channel.ContentFile: return "file" case channel.ContentLocation: return "location" default: return "file" } }