@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
text/typescript
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.
*/
(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;
}