import fs from 'fs'; import path from 'path'; import { storage } from '../storage'; import type { Post, SocialPage, Media } from '@shared/schema'; import { imageProcessor } from './imageProcessor'; import { minioService, resolvePublicUrl } from './minio'; interface FacebookPhotoResponse { id: string; post_id: string; } interface FacebookFeedResponse { id: string; } interface FacebookVideoResponse { id: string; post_id?: string; } interface FacebookError { error: { message: string; type: string; code: number; }; } export class FacebookService { private baseUrl = 'https://graph.facebook.com/v23.0'; async publishPost(post: Post, page: SocialPage, postType: string, mediaList: Media[] = []): Promise { if (page.platform !== 'facebook') { throw new Error('This service only supports Facebook pages'); } if (!page.accessToken) { throw new Error('No access token found for this page'); } // Handle different post types // Note: "both" type should never reach here - new posts are split at creation // If it does reach here (legacy data), throw an error if (postType === 'both') { throw new Error('postType "both" is not supported. Posts should be split into separate feed and story entries at creation.'); } if (postType === 'story') { // Publish as story - one story per media if (mediaList.length === 0) { throw new Error('Stories require media (photo or video)'); } // Publish each media as a separate story const storyIds: string[] = []; for (const media of mediaList) { const storyId = await this.publishStory(post, page, media); storyIds.push(storyId); } // Return all story IDs joined together return storyIds.join(','); } else if (postType === 'reel') { // Publish as Reel if (mediaList.length === 0) { throw new Error('Reels require a video'); } const videoMedia = mediaList.find(m => m.type === 'video'); if (!videoMedia) { throw new Error('Reels require a video media'); } // Use public URL so getMediaBuffer can fetch via HTTP if local file is missing const publicUrl = resolvePublicUrl(videoMedia.originalUrl); const nodeBuffer = await this.getMediaBuffer(publicUrl); return await this.publishVideoFromBuffer(page, nodeBuffer, post.content); } else { // Default to feed const imageMedia = mediaList.filter(m => m.type === 'image'); const videoMedia = mediaList.filter(m => m.type === 'video'); if (imageMedia.length > 1) { // Multi-photo carousel (images only) return await this.publishMultiPhotoPost(post, page, mediaList); } else if (imageMedia.length === 1) { // Single image (even if mixed with videos, prioritize the image) return await this.publishPhotoPost(post, page, imageMedia[0]); } else if (videoMedia.length >= 1) { // Video post - use video endpoint return await this.publishVideoPost(post, page, videoMedia[0]); } else { // Text only return await this.publishTextPost(post, page); } } } private async publishTextPost(post: Post, page: SocialPage): Promise { const params = new URLSearchParams({ access_token: page.accessToken!, message: post.content, }); const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`; const response = await fetch(url, { method: 'POST', }); if (!response.ok) { const error = await response.json() as FacebookError; throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); } const data = await response.json() as FacebookFeedResponse; return data.id; } private async getMediaBuffer(originalUrl: string): Promise { const localPath = originalUrl.startsWith('/') ? path.join(process.cwd(), originalUrl) : originalUrl; if (localPath.startsWith('http')) { const response = await fetch(localPath); if (!response.ok) throw new Error(`Failed to fetch media from ${localPath}`); return Buffer.from(await response.arrayBuffer()); } return fs.promises.readFile(localPath); } private async publishPhotoPost(post: Post, page: SocialPage, media: Media): Promise { const buffer = await this.getMediaBuffer(media.originalUrl); const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; const blob = new Blob([buffer], { type: mimeType }); const formData = new FormData(); formData.append('access_token', page.accessToken!); if (post.content) { formData.append('message', post.content); } formData.append('source', blob, path.basename(media.originalUrl)); const url = `${this.baseUrl}/${page.pageId}/photos`; const response = await fetch(url, { method: 'POST', body: formData, }); if (!response.ok) { const error = await response.json() as FacebookError; throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); } const data = await response.json() as FacebookPhotoResponse; // Return the post_id which is the full post identifier return data.post_id || data.id; } /** * Generates a Facebook-compatible video URL with Cloudinary transformations * Facebook requires: H.264 video codec, AAC audio codec, MP4 container */ private getFacebookCompatibleVideoUrl(originalUrl: string): string { // Check if this is a Cloudinary URL const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/video\/upload\//; const match = originalUrl.match(cloudinaryPattern); if (!match) { // Not a Cloudinary URL, return as-is (may fail on Facebook) console.warn('Video URL is not from Cloudinary, cannot apply transformations:', originalUrl); return originalUrl; } const [fullMatch, cloudName] = match; const videoPath = originalUrl.replace(fullMatch, ''); // Apply transformations for Facebook compatibility: // - vc_h264: Video codec H.264 // - ac_aac: Audio codec AAC // - f_mp4: Force MP4 format // - q_auto: Automatic quality optimization const transformations = 'vc_h264,ac_aac,f_mp4,q_auto'; const transformedUrl = `https://res.cloudinary.com/${cloudName}/video/upload/${transformations}/${videoPath}`; console.log('🎬 Transforming video for Facebook compatibility:'); console.log(' Original:', originalUrl); console.log(' Transformed:', transformedUrl); return transformedUrl; } private async publishVideoPost(post: Post, page: SocialPage, media: Media): Promise { // Transform video URL to ensure Facebook compatibility (H.264/AAC/MP4) const videoUrl = this.getFacebookCompatibleVideoUrl(resolvePublicUrl(media.originalUrl)); const params = new URLSearchParams({ access_token: page.accessToken!, description: post.content, file_url: videoUrl, }); const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`; const response = await fetch(url, { method: 'POST', }); if (!response.ok) { const error = await response.json() as FacebookError; throw new Error(`Facebook API error publishing video: ${error.error.message} (code: ${error.error.code})`); } const data = await response.json() as FacebookVideoResponse; // Return the post_id or id return data.post_id || data.id; } private async publishMultiPhotoPost(post: Post, page: SocialPage, mediaList: Media[]): Promise { // Validate that all media are images const imageMedia = mediaList.filter(m => m.type === 'image'); if (imageMedia.length === 0) { throw new Error('Multi-photo posts require at least one image'); } if (imageMedia.length !== mediaList.length) { console.warn(`Filtered out ${mediaList.length - imageMedia.length} non-image media from multi-photo post`); } // Step 1: Upload all photos as unpublished and collect photo IDs const photoIds: string[] = []; for (const media of imageMedia) { if (!media.originalUrl) { console.warn(`Skipping media ${media.id} - no valid URL found`); continue; } const buffer = await this.getMediaBuffer(media.originalUrl); const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; const blob = new Blob([buffer], { type: mimeType }); const formData = new FormData(); formData.append('access_token', page.accessToken!); formData.append('published', 'false'); formData.append('source', blob, path.basename(media.originalUrl)); const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`; const uploadResponse = await fetch(uploadUrl, { method: 'POST', body: formData, }); if (!uploadResponse.ok) { const error = await uploadResponse.json() as FacebookError; throw new Error(`Facebook API error uploading photo: ${error.error.message} (code: ${error.error.code})`); } const uploadData = await uploadResponse.json() as FacebookPhotoResponse; photoIds.push(uploadData.id); } if (photoIds.length === 0) { throw new Error('No photos were successfully uploaded'); } // Step 2: Create feed post with all attached photos const feedParams = new URLSearchParams({ access_token: page.accessToken!, message: post.content, }); // Add each photo as attached_media photoIds.forEach((photoId, index) => { feedParams.append(`attached_media[${index}]`, JSON.stringify({ media_fbid: photoId })); }); const feedUrl = `${this.baseUrl}/${page.pageId}/feed?${feedParams.toString()}`; const feedResponse = await fetch(feedUrl, { method: 'POST', }); if (!feedResponse.ok) { const error = await feedResponse.json() as FacebookError; throw new Error(`Facebook API error creating feed post: ${error.error.message} (code: ${error.error.code})`); } const feedData = await feedResponse.json() as FacebookFeedResponse; return feedData.id; } async schedulePost(post: Post, page: SocialPage, scheduledTime: Date, media?: Media): Promise { if (page.platform !== 'facebook') { throw new Error('This service only supports Facebook pages'); } if (!page.accessToken) { throw new Error('No access token found for this page'); } // Facebook requires Unix timestamp for scheduled posts const scheduledTimestamp = Math.floor(scheduledTime.getTime() / 1000); const now = Math.floor(Date.now() / 1000); // Facebook requires scheduled time to be at least 10 minutes in the future if (scheduledTimestamp < now + 600) { throw new Error('Scheduled time must be at least 10 minutes in the future'); } // Maximum 6 months in the future const sixMonthsFromNow = now + (6 * 30 * 24 * 60 * 60); if (scheduledTimestamp > sixMonthsFromNow) { throw new Error('Scheduled time cannot be more than 6 months in the future'); } // For scheduled posts with photos if (media) { return await this.schedulePhotoPost(post, page, scheduledTimestamp, media); } else { return await this.scheduleTextPost(post, page, scheduledTimestamp); } } private async scheduleTextPost(post: Post, page: SocialPage, scheduledTimestamp: number): Promise { const params = new URLSearchParams({ access_token: page.accessToken!, message: post.content, published: '0', scheduled_publish_time: scheduledTimestamp.toString(), }); const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`; const response = await fetch(url, { method: 'POST', }); if (!response.ok) { const error = await response.json() as FacebookError; throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); } const data = await response.json() as FacebookFeedResponse; return data.id; } private async schedulePhotoPost(post: Post, page: SocialPage, scheduledTimestamp: number, media: Media): Promise { const buffer = await this.getMediaBuffer(media.originalUrl); const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; const blob = new Blob([buffer], { type: mimeType }); const formData = new FormData(); formData.append('access_token', page.accessToken!); if (post.content) { formData.append('message', post.content); } formData.append('published', '0'); formData.append('scheduled_publish_time', scheduledTimestamp.toString()); formData.append('source', blob, path.basename(media.originalUrl)); const url = `${this.baseUrl}/${page.pageId}/photos`; const response = await fetch(url, { method: 'POST', body: formData, }); if (!response.ok) { const error = await response.json() as FacebookError; throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); } const data = await response.json() as FacebookPhotoResponse; return data.post_id || data.id; } private async publishStory(post: Post, page: SocialPage, media: Media): Promise { if (media.type === 'video') { return this.publishVideoStory(post, page, media); } let photoBuffer = await this.getMediaBuffer(media.originalUrl); let originalFilename = path.basename(media.originalUrl); if (post.content && post.content.trim().length > 0) { try { const imageWithText = await imageProcessor.addTextToStoryImage(resolvePublicUrl(media.originalUrl), post.content); await minioService.uploadStoryImageWithText(imageWithText, `story-${media.id}.png`); photoBuffer = imageWithText; originalFilename = `story-${media.id}.png`; console.log('Story image with text generated locally'); } catch (error) { console.error('Error generating story image with text, using original:', error); } } const mimeType = originalFilename.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; const blob = new Blob([photoBuffer], { type: mimeType }); const formData = new FormData(); formData.append('access_token', page.accessToken!); formData.append('published', 'false'); // Important: must be unpublished first formData.append('source', blob, originalFilename); const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`; const uploadResponse = await fetch(uploadUrl, { method: 'POST', body: formData, }); if (!uploadResponse.ok) { const error = await uploadResponse.json() as FacebookError; throw new Error(`Facebook API error uploading photo for story: ${error.error.message} (code: ${error.error.code})`); } const uploadData = await uploadResponse.json() as FacebookPhotoResponse; const photoId = uploadData.id; // Step 2: Publish the photo as a story const storyParams = new URLSearchParams({ access_token: page.accessToken!, photo_id: photoId, }); const storyUrl = `${this.baseUrl}/${page.pageId}/photo_stories?${storyParams.toString()}`; const storyResponse = await fetch(storyUrl, { method: 'POST', }); if (!storyResponse.ok) { const error = await storyResponse.json() as FacebookError; throw new Error(`Facebook API error publishing story: ${error.error.message} (code: ${error.error.code})`); } const storyData = await storyResponse.json() as { success: boolean; post_id: string }; return storyData.post_id; } private async publishVideoStory(post: Post, page: SocialPage, media: Media): Promise { // Video stories require a 3-phase upload process: // 1. START phase - Initialize the upload and get upload_url // 2. UPLOAD phase - Upload the video to upload_url using file_url header // 3. FINISH phase - Finalize and publish the story const videoUrl = resolvePublicUrl(media.originalUrl); const accessToken = page.accessToken!; // Phase 1: START - Initialize the upload const startParams = new URLSearchParams({ access_token: accessToken, upload_phase: 'start', }); const startUrl = `${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`; const startResponse = await fetch(startUrl, { method: 'POST', }); if (!startResponse.ok) { const error = await startResponse.json() as FacebookError; throw new Error(`Facebook API error (START phase): ${error.error.message} (code: ${error.error.code})`); } const startData = await startResponse.json() as { video_id: string; upload_url: string }; const videoId = startData.video_id; const uploadUrl = startData.upload_url; // Phase 2: UPLOAD - Upload the video using the upload_url // IMPORTANT: file_url must be passed as a header, not a query parameter const uploadResponse = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': `OAuth ${accessToken}`, 'file_url': videoUrl, }, }); if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); throw new Error(`Facebook API error (UPLOAD phase): ${errorText}`); } // Phase 3: FINISH - Finalize and publish the story const finishParams = new URLSearchParams({ access_token: accessToken, upload_phase: 'finish', video_id: videoId, }); const finishUrl = `${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`; const finishResponse = await fetch(finishUrl, { method: 'POST', }); if (!finishResponse.ok) { const error = await finishResponse.json() as FacebookError; throw new Error(`Facebook API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`); } const finishData = await finishResponse.json() as { success: boolean; post_id?: string }; // Return the post_id from the finish response return finishData.post_id || videoId; } /** * Publie une vidéo comme Reel Facebook * * Utilise l'endpoint /{page-id}/video_reels avec un processus en 3 phases : * 1. START - Initialise l'upload et récupère l'upload_url * 2. UPLOAD - Upload la vidéo vers rupload.facebook.com * 3. FINISH - Finalise et publie le Reel * * Spécifications vidéo requises : * - Format : MP4, H.264 * - Ratio : 9:16 (vertical) * - Durée : 3-90 secondes * - Résolution recommandée : 1080x1920 * * @param page - Page Facebook cible * @param videoUrl - URL de la vidéo à publier * @param description - Description du Reel (optionnel) * @returns ID du Reel publié */ async publishReel( page: SocialPage, videoUrl: string, description?: string ): Promise { if (page.platform !== 'facebook') { throw new Error('This method only supports Facebook pages'); } if (!page.accessToken) { throw new Error('No access token found for this page'); } console.log('🎬 Publishing Reel to Facebook Feed & Reels via /videos endpoint:', { pageId: page.pageId, pageName: page.pageName, hasDescription: !!description, }); // Use the /videos endpoint instead of /video_reels // This ensures the video appears on the Page Timeline (Feed) AND in Reels (if 9:16) const params = new URLSearchParams({ access_token: page.accessToken, description: description || '', file_url: videoUrl, }); const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`; const response = await fetch(url, { method: 'POST', }); if (!response.ok) { const error = await response.json() as FacebookError; throw new Error(`Facebook API error publishing video/reel: ${error.error.message} (code: ${error.error.code})`); } const data = await response.json() as FacebookVideoResponse; const postId = data.post_id || data.id; console.log('✅ Reel published successfully to Feed & Reels! ID:', postId); return postId; } /** * Publie une vidéo comme Reel Facebook via l'API Reels (start → upload → finish). * Utilise l'endpoint /video_reels pour que la vidéo soit reconnue comme un Reel. * * Spécifications vidéo requises pour les Reels : * - Format : MP4, H.264 * - Ratio : 9:16 (vertical) * - Durée : 3-90 secondes * - Résolution recommandée : 1080x1920 */ async publishVideoFromBuffer( page: SocialPage, videoBuffer: Buffer | ArrayBuffer, description?: string ): Promise { if (!page.accessToken) throw new Error('No access token found for this page'); const buf = Buffer.isBuffer(videoBuffer) ? videoBuffer : Buffer.from(videoBuffer); const fileSize = buf.length; const accessToken = page.accessToken; console.log('🎬 Publishing video as Facebook Reel via /video_reels endpoint:', { pageId: page.pageId, pageName: page.pageName, size: `${(fileSize / 1024 / 1024).toFixed(2)} MB`, }); // ── Phase 1: START - Initialize the Reel upload ────────────────────────── const startParams = new URLSearchParams({ access_token: accessToken, upload_phase: 'start', }); const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`; const startRes = await fetch(startUrl, { method: 'POST' }); const startRaw = await startRes.text(); console.log('📤 Facebook Reel START response:', startRaw); const startData = JSON.parse(startRaw) as any; if (!startRes.ok || startData.error) { const subcode = startData.error?.error_subcode; if (subcode === 1363042) { throw new Error( `Le token Facebook de la page "${page.pageName}" n'a pas la permission "publish_video". ` + `Régénérez le token dans Graph API Explorer en ajoutant les permissions : ` + `pages_manage_posts, pages_read_engagement, publish_video. ` + `(code: ${startData.error?.code}, subcode: ${subcode})` ); } throw new Error(`Facebook Reel START error: ${startData.error?.message ?? startRaw} (code: ${startData.error?.code}, subcode: ${subcode})`); } const videoId = startData.video_id; const uploadUrl = startData.upload_url; console.log(`📤 Reel upload initialized: video_id=${videoId}, upload_url=${uploadUrl}`); // ── Phase 2: UPLOAD - Upload binary data to the upload_url ─────────────── const videoBlob = new Blob([buf], { type: 'video/mp4' }); const uploadRes = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': `OAuth ${accessToken}`, 'Content-Type': 'application/octet-stream', 'offset': '0', 'file_size': fileSize.toString(), }, body: videoBlob, }); if (!uploadRes.ok) { const errorText = await uploadRes.text(); throw new Error(`Facebook Reel UPLOAD error: ${errorText}`); } console.log(`📤 Reel video data uploaded successfully (${fileSize} bytes)`); // ── Phase 3: FINISH - Finalize and publish the Reel ────────────────────── const finishParams = new URLSearchParams({ access_token: accessToken, upload_phase: 'finish', video_id: videoId, video_state: 'PUBLISHED', }); if (description) { finishParams.append('description', description); } const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`; const finishRes = await fetch(finishUrl, { method: 'POST' }); const finishRaw = await finishRes.text(); console.log('📤 Facebook Reel FINISH response:', finishRaw); const finishData = JSON.parse(finishRaw) as any; if (!finishRes.ok || finishData.error) { throw new Error(`Facebook Reel FINISH error: ${finishData.error?.message ?? finishRaw} (code: ${finishData.error?.code})`); } const postId = finishData.post_id || videoId; console.log('✅ Reel published successfully! ID:', postId); return postId; } /** * Publie une vidéo comme Reel Facebook en uploadant directement les données binaires * Utile quand on a la vidéo en base64 (après traitement FFmpeg) * * @param page - Page Facebook cible * @param videoBuffer - Buffer de la vidéo * @param description - Description du Reel (optionnel) * @returns ID du Reel publié */ async publishReelFromBuffer( page: SocialPage, videoBuffer: ArrayBuffer, description?: string ): Promise { if (page.platform !== 'facebook') { throw new Error('This method only supports Facebook pages'); } if (!page.accessToken) { throw new Error('No access token found for this page'); } const accessToken = page.accessToken; const fileSize = videoBuffer.byteLength; console.log('🎬 Publishing Reel from buffer:', { pageId: page.pageId, fileSize: `${(fileSize / 1024 / 1024).toFixed(2)} MB`, }); // Phase 1: START const startParams = new URLSearchParams({ access_token: accessToken, upload_phase: 'start', }); const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`; const startResponse = await fetch(startUrl, { method: 'POST', }); if (!startResponse.ok) { const error = await startResponse.json() as FacebookError; throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`); } const startData = await startResponse.json() as { video_id: string; upload_url: string }; const videoId = startData.video_id; const uploadUrl = startData.upload_url; // Phase 2: UPLOAD - Upload binary data directly const videoBlob = new Blob([videoBuffer], { type: 'video/mp4' }); const uploadResponse = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': `OAuth ${accessToken}`, 'Content-Type': 'application/octet-stream', 'offset': '0', 'file_size': fileSize.toString(), }, body: videoBlob, }); if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`); } // Phase 3: FINISH const finishParams = new URLSearchParams({ access_token: accessToken, upload_phase: 'finish', video_id: videoId, video_state: 'PUBLISHED', }); if (description) { finishParams.append('description', description); } const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`; const finishResponse = await fetch(finishUrl, { method: 'POST', }); if (!finishResponse.ok) { const error = await finishResponse.json() as FacebookError; throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`); } const finishData = await finishResponse.json() as { success: boolean; post_id?: string }; return finishData.post_id || videoId; } } export const facebookService = new FacebookService();