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

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