364 lines
11 KiB
Go
364 lines
11 KiB
Go
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"
|
|
}
|
|
}
|