UNPKG

@memberjunction/actions-bizapps-social

Version:

Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer

751 lines (673 loc) 25.9 kB
import { RegisterClass } from '@memberjunction/global'; import { BaseSocialMediaAction, MediaFile, SocialPost, SearchParams, SocialAnalytics } from '../../base/base-social.action'; import axios, { AxiosInstance, AxiosError } from 'axios'; import { ActionParam, ActionResultSimple, RunActionParams } from '@memberjunction/actions-base'; import { LogStatus, LogError } from '@memberjunction/core'; import FormData from 'form-data'; import { BaseAction } from '@memberjunction/actions'; /** * Base class for all Facebook actions. * Handles Facebook-specific authentication, API interactions, and rate limiting. * Uses Facebook Graph API v18.0 with OAuth 2.0. */ @RegisterClass(BaseAction, 'FacebookBaseAction') export abstract class FacebookBaseAction extends BaseSocialMediaAction { /** * Internal method that must be implemented by derived action classes */ protected abstract InternalRunAction(params: RunActionParams): Promise<ActionResultSimple>; protected get platformName(): string { return 'Facebook'; } protected get apiBaseUrl(): string { return 'https://graph.facebook.com/v18.0'; } /** * API version for cleaner URL building */ protected get apiVersion(): string { return 'v18.0'; } /** * Axios instance for making HTTP requests */ private _axiosInstance: AxiosInstance | null = null; /** * Get or create axios instance with interceptors */ protected get axiosInstance(): AxiosInstance { if (!this._axiosInstance) { this._axiosInstance = axios.create({ baseURL: this.apiBaseUrl, timeout: 30000, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); // Add request interceptor for auth this._axiosInstance.interceptors.request.use( (config) => { const token = this.getAccessToken(); if (token) { // Facebook uses access_token as query parameter config.params = { ...config.params, access_token: token }; } return config; }, (error) => Promise.reject(error) ); // Add response interceptor for rate limit handling this._axiosInstance.interceptors.response.use( (response) => { // Log rate limit info if available const rateLimitInfo = this.parseRateLimitHeaders(response.headers); if (rateLimitInfo) { LogStatus(`Facebook Rate Limit - Remaining: ${rateLimitInfo.remaining}/${rateLimitInfo.limit}`); } return response; }, async (error: AxiosError) => { if (error.response?.status === 429 || this.isFacebookRateLimitError(error)) { // Rate limit exceeded - Facebook returns various codes const waitTime = this.extractRateLimitWaitTime(error) || 60; await this.handleRateLimit(waitTime); // Retry the request return this._axiosInstance!.request(error.config!); } return Promise.reject(error); } ); } return this._axiosInstance; } /** * Check if error is a Facebook rate limit error */ private isFacebookRateLimitError(error: AxiosError): boolean { const errorData = error.response?.data as any; const errorCode = errorData?.error?.code; const errorSubcode = errorData?.error?.error_subcode; // Facebook rate limit error codes return errorCode === 4 || // Application request limit reached errorCode === 17 || // User request limit reached errorCode === 32 || // Page request limit reached errorCode === 613 || // Calls to stream have exceeded the rate limit errorSubcode === 2446079; // Reduce the amount of data you're asking for } /** * Extract wait time from Facebook rate limit error */ private extractRateLimitWaitTime(error: AxiosError): number | null { const errorData = error.response?.data as any; const headers = error.response?.headers; // Check headers first if (headers?.['x-app-usage'] || headers?.['x-page-usage'] || headers?.['x-ad-account-usage']) { // Parse usage headers to determine wait time const usage = JSON.parse(headers['x-app-usage'] || headers['x-page-usage'] || headers['x-ad-account-usage']); if (usage.call_count > 90) { return 300; // Wait 5 minutes if over 90% usage } } // Check error message for wait time const message = errorData?.error?.message; if (message && message.includes('Please retry your request later')) { return 300; // Default to 5 minutes } return null; } /** * Refresh the access token using the refresh token * Note: Facebook uses long-lived tokens that last 60 days */ protected async refreshAccessToken(): Promise<void> { try { const currentToken = this.getAccessToken(); if (!currentToken) { throw new Error('No access token available for Facebook'); } const clientId = this.getCustomAttribute(2) || ''; // App ID stored in CustomAttribute2 const clientSecret = this.getCustomAttribute(3) || ''; // App Secret stored in CustomAttribute3 // Exchange short-lived token for long-lived token const response = await axios.get(`${this.apiBaseUrl}/oauth/access_token`, { params: { grant_type: 'fb_exchange_token', client_id: clientId, client_secret: clientSecret, fb_exchange_token: currentToken } }); const { access_token, expires_in } = response.data; // Update stored tokens await this.updateStoredTokens( access_token, undefined, // Facebook doesn't use refresh tokens expires_in || 5184000 // Default to 60 days if not specified ); LogStatus('Facebook access token refreshed successfully'); } catch (error) { LogError(`Failed to refresh Facebook access token: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Get the authenticated user's pages */ protected async getUserPages(): Promise<FacebookPage[]> { try { const response = await this.axiosInstance.get('/me/accounts', { params: { fields: 'id,name,access_token,category,picture' } }); return response.data.data || []; } catch (error) { LogError(`Failed to get user pages: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Get page access token for a specific page */ protected async getPageAccessToken(pageId: string): Promise<string> { const pages = await this.getUserPages(); const page = pages.find(p => p.id === pageId); if (!page) { throw new Error(`Page ${pageId} not found or user doesn't have access`); } return page.access_token; } /** * Upload media to Facebook */ protected async uploadSingleMedia(file: MediaFile): Promise<string> { try { const fileData = typeof file.data === 'string' ? Buffer.from(file.data, 'base64') : file.data; const formData = new FormData(); formData.append('source', fileData, { filename: file.filename, contentType: file.mimeType }); const isVideo = file.mimeType.startsWith('video/'); const endpoint = isVideo ? '/me/videos' : '/me/photos'; const response = await this.axiosInstance.post(endpoint, formData, { headers: formData.getHeaders(), params: { published: 'false' // Upload as unpublished for later use } }); return response.data.id; } catch (error) { LogError(`Failed to upload media to Facebook: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Upload media to a specific page */ protected async uploadMediaToPage(pageId: string, file: MediaFile): Promise<string> { try { const fileData = typeof file.data === 'string' ? Buffer.from(file.data, 'base64') : file.data; const pageToken = await this.getPageAccessToken(pageId); const formData = new FormData(); formData.append('source', fileData, { filename: file.filename, contentType: file.mimeType }); const isVideo = file.mimeType.startsWith('video/'); const endpoint = `/${pageId}/${isVideo ? 'videos' : 'photos'}`; const response = await axios.post(`${this.apiBaseUrl}${endpoint}`, formData, { headers: formData.getHeaders(), params: { access_token: pageToken, published: 'false' // Upload as unpublished for later use } }); return response.data.id; } catch (error) { LogError(`Failed to upload media to Facebook page: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Validate media file meets Facebook requirements */ protected validateMediaFile(file: MediaFile): void { const supportedImageTypes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/tiff' ]; const supportedVideoTypes = [ 'video/mp4', 'video/quicktime', 'video/x-matroska', 'video/webm' ]; const supportedTypes = [...supportedImageTypes, ...supportedVideoTypes]; if (!supportedTypes.includes(file.mimeType)) { throw new Error(`Unsupported media type: ${file.mimeType}. Supported types: ${supportedTypes.join(', ')}`); } // Facebook media size limits let maxSize: number; if (supportedImageTypes.includes(file.mimeType)) { maxSize = 4 * 1024 * 1024; // 4MB for images } else if (supportedVideoTypes.includes(file.mimeType)) { maxSize = 10 * 1024 * 1024 * 1024; // 10GB for videos } else { maxSize = 4 * 1024 * 1024; // Default 4MB } if (file.size > maxSize) { throw new Error(`File size exceeds limit. Max: ${maxSize / 1024 / 1024}MB, Got: ${file.size / 1024 / 1024}MB`); } } /** * Create a post on Facebook */ protected async createPost(pageId: string, postData: CreatePostData): Promise<FacebookPost> { try { const pageToken = await this.getPageAccessToken(pageId); const response = await axios.post( `${this.apiBaseUrl}/${pageId}/feed`, postData, { params: { access_token: pageToken } } ); // Get the full post data return await this.getPost(response.data.id); } catch (error) { this.handleFacebookError(error as AxiosError); } } /** * Get a specific post */ protected async getPost(postId: string): Promise<FacebookPost> { try { const response = await this.axiosInstance.get(`/${postId}`, { params: { fields: this.getPostFields() } }); return response.data; } catch (error) { LogError(`Failed to get post: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Get posts from a page */ protected async getPagePosts(pageId: string, params: GetPagePostsParams = {}): Promise<FacebookPost[]> { try { const pageToken = await this.getPageAccessToken(pageId); const response = await axios.get(`${this.apiBaseUrl}/${pageId}/posts`, { params: { access_token: pageToken, fields: this.getPostFields(), limit: params.limit || 100, since: params.since ? Math.floor(params.since.getTime() / 1000) : undefined, until: params.until ? Math.floor(params.until.getTime() / 1000) : undefined, ...params } }); return response.data.data || []; } catch (error) { LogError(`Failed to get page posts: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Get paginated results from Facebook */ protected async getPaginatedResults<T>(url: string, params: Record<string, any> = {}, maxResults?: number): Promise<T[]> { const results: T[] = []; let nextUrl: string | null = url; const limit = params.limit || 100; while (nextUrl) { const response = await axios.get(nextUrl, { params: nextUrl === url ? { ...params, limit } : {} }); if (response.data.data && Array.isArray(response.data.data)) { results.push(...response.data.data); } // Check if we've reached max results if (maxResults && results.length >= maxResults) { return results.slice(0, maxResults); } // Get next page URL nextUrl = response.data.paging?.next || null; } return results; } /** * Get default fields for post queries */ protected getPostFields(): string { return 'id,message,created_time,updated_time,from,story,permalink_url,attachments,shares,reactions.summary(true),comments.summary(true),insights.metric(post_impressions,post_impressions_unique,post_engaged_users,post_clicks,post_reactions_by_type_total)'; } /** * Convert Facebook post to common format */ protected normalizePost(fbPost: FacebookPost): SocialPost { const mediaUrls: string[] = []; // Extract media URLs from attachments if (fbPost.attachments?.data) { for (const attachment of fbPost.attachments.data) { if (attachment.media?.image?.src) { mediaUrls.push(attachment.media.image.src); } else if (attachment.media?.source) { mediaUrls.push(attachment.media.source); } } } return { id: fbPost.id, platform: 'Facebook', profileId: fbPost.from?.id || '', content: fbPost.message || fbPost.story || '', mediaUrls, publishedAt: new Date(fbPost.created_time), analytics: this.extractPostAnalytics(fbPost), platformSpecificData: { permalinkUrl: fbPost.permalink_url, story: fbPost.story, attachments: fbPost.attachments, postType: fbPost.attachments?.data?.[0]?.type || 'status' } }; } /** * Extract analytics from Facebook post */ private extractPostAnalytics(fbPost: FacebookPost): SocialAnalytics | undefined { const insights = fbPost.insights?.data; const reactions = fbPost.reactions?.summary?.total_count || 0; const comments = fbPost.comments?.summary?.total_count || 0; const shares = fbPost.shares?.count || 0; // Extract metrics from insights let impressions = 0; let reach = 0; let engagements = 0; let clicks = 0; if (insights) { for (const insight of insights) { switch (insight.name) { case 'post_impressions': impressions = insight.values?.[0]?.value || 0; break; case 'post_impressions_unique': reach = insight.values?.[0]?.value || 0; break; case 'post_engaged_users': engagements = insight.values?.[0]?.value || 0; break; case 'post_clicks': clicks = insight.values?.[0]?.value || 0; break; } } } return { impressions, reach, engagements: engagements || (reactions + comments + shares), clicks, shares, comments, likes: reactions, platformMetrics: { reactions, comments, shares, insights } }; } /** * Normalize Facebook analytics to common format */ protected normalizeAnalytics(fbMetrics: any): SocialAnalytics { return { impressions: fbMetrics.impressions || 0, engagements: fbMetrics.engaged_users || 0, clicks: fbMetrics.clicks || 0, shares: fbMetrics.shares || 0, comments: fbMetrics.comments || 0, likes: fbMetrics.reactions || fbMetrics.likes || 0, reach: fbMetrics.reach || 0, saves: fbMetrics.saves, videoViews: fbMetrics.video_views, platformMetrics: fbMetrics }; } /** * Search for posts - implemented in search action */ protected async searchPosts(params: SearchParams): Promise<SocialPost[]> { // This is implemented in the search-posts.action.ts throw new Error('Search posts is implemented in FacebookSearchPostsAction'); } /** * Handle Facebook-specific errors */ protected handleFacebookError(error: AxiosError): never { if (error.response) { const { status, data } = error.response; const errorData = data as any; const fbError = errorData?.error; if (fbError) { const code = fbError.code; const message = fbError.message; const type = fbError.type; const subcode = fbError.error_subcode; // Map Facebook error codes to user-friendly messages switch (code) { case 1: throw new Error(`API Unknown Error: ${message}`); case 2: throw new Error(`API Service Error: ${message}`); case 4: throw new Error('Application request limit reached. Please try again later.'); case 10: throw new Error('Permission denied. Ensure the app has required Facebook permissions.'); case 17: throw new Error('User request limit reached. Please try again later.'); case 32: throw new Error('Page request limit reached. Please try again later.'); case 100: throw new Error(`Invalid Parameter: ${message}`); case 190: throw new Error('Access token has expired. Please re-authenticate.'); case 200: case 210: case 220: throw new Error(`Permission Error: ${message}`); case 368: throw new Error('Temporarily blocked from posting. Please try again later.'); case 506: throw new Error('Duplicate post. This content has already been posted.'); default: throw new Error(`Facebook API Error (${code}): ${message}`); } } // Generic HTTP errors switch (status) { case 400: throw new Error(`Bad Request: ${errorData.message || 'Invalid request parameters'}`); case 401: throw new Error('Unauthorized: Invalid or expired access token'); case 403: throw new Error('Forbidden: Insufficient permissions'); case 404: throw new Error('Not Found: Resource does not exist'); case 500: throw new Error('Internal Server Error: Facebook service error'); case 503: throw new Error('Service Unavailable: Facebook service temporarily unavailable'); default: throw new Error(`Facebook API Error (${status}): ${errorData.message || 'Unknown error'}`); } } else if (error.request) { throw new Error('Network Error: No response from Facebook'); } else { throw new Error(`Request Error: ${error.message}`); } } /** * Parse Facebook-specific rate limit headers */ protected parseRateLimitHeaders(headers: any): { remaining: number; reset: Date; limit: number; } | null { // Facebook uses different headers for rate limiting const appUsage = headers['x-app-usage']; const pageUsage = headers['x-page-usage']; const adAccountUsage = headers['x-ad-account-usage']; if (appUsage || pageUsage || adAccountUsage) { try { const usage = JSON.parse(appUsage || pageUsage || adAccountUsage); const callCount = usage.call_count || 0; const totalTime = usage.total_time || 0; const totalCPUTime = usage.total_cputime || 0; // Facebook rate limits are percentage-based return { remaining: Math.max(0, 100 - callCount), reset: new Date(Date.now() + 3600000), // Reset in 1 hour limit: 100 }; } catch (error) { LogError(`Failed to parse Facebook rate limit headers: ${error}`); } } return null; } } /** * Facebook-specific interfaces */ export interface FacebookPage { id: string; name: string; access_token: string; category: string; picture?: { data: { url: string; }; }; } export interface CreatePostData { message?: string; link?: string; place?: string; tags?: string[]; privacy?: { value: 'EVERYONE' | 'ALL_FRIENDS' | 'FRIENDS_OF_FRIENDS' | 'SELF'; }; attached_media?: Array<{ media_fbid: string }>; scheduled_publish_time?: number; // Unix timestamp published?: boolean; backdated_time?: number; backdated_time_granularity?: 'year' | 'month' | 'day' | 'hour' | 'minute'; } export interface FacebookPost { id: string; message?: string; story?: string; created_time: string; updated_time: string; from?: { id: string; name: string; }; permalink_url?: string; attachments?: { data: Array<{ type: string; title?: string; description?: string; url?: string; media?: { image?: { src: string; width: number; height: number; }; source?: string; }; }>; }; shares?: { count: number; }; reactions?: { summary: { total_count: number; }; }; comments?: { summary: { total_count: number; }; }; insights?: { data: Array<{ name: string; values: Array<{ value: number; }>; }>; }; } export interface GetPagePostsParams { since?: Date; until?: Date; limit?: number; published?: boolean; } export interface FacebookInsight { name: string; period: string; values: Array<{ value: number | Record<string, number>; end_time?: string; }>; title?: string; description?: string; } export interface FacebookComment { id: string; message: string; created_time: string; from: { id: string; name: string; }; like_count: number; comment_count?: number; parent?: { id: string; }; } export interface FacebookAlbum { id: string; name: string; description?: string; link: string; cover_photo?: { id: string; }; count: number; created_time: string; }