@memberjunction/actions-bizapps-social
Version:
Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer
567 lines • 22.9 kB
JavaScript
;
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.FacebookBaseAction = void 0;
const global_1 = require("@memberjunction/global");
const base_social_action_1 = require("../../base/base-social.action");
const axios_1 = __importDefault(require("axios"));
const core_1 = require("@memberjunction/core");
const form_data_1 = __importDefault(require("form-data"));
const actions_1 = require("@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.
*/
let FacebookBaseAction = class FacebookBaseAction extends base_social_action_1.BaseSocialMediaAction {
get platformName() {
return 'Facebook';
}
get apiBaseUrl() {
return 'https://graph.facebook.com/v18.0';
}
/**
* API version for cleaner URL building
*/
get apiVersion() {
return 'v18.0';
}
/**
* Axios instance for making HTTP requests
*/
_axiosInstance = null;
/**
* Get or create axios instance with interceptors
*/
get axiosInstance() {
if (!this._axiosInstance) {
this._axiosInstance = axios_1.default.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) {
(0, core_1.LogStatus)(`Facebook Rate Limit - Remaining: ${rateLimitInfo.remaining}/${rateLimitInfo.limit}`);
}
return response;
}, async (error) => {
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
*/
isFacebookRateLimitError(error) {
const errorData = error.response?.data;
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
*/
extractRateLimitWaitTime(error) {
const errorData = error.response?.data;
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
*/
async refreshAccessToken() {
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_1.default.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
);
(0, core_1.LogStatus)('Facebook access token refreshed successfully');
}
catch (error) {
(0, core_1.LogError)(`Failed to refresh Facebook access token: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Get the authenticated user's pages
*/
async getUserPages() {
try {
const response = await this.axiosInstance.get('/me/accounts', {
params: {
fields: 'id,name,access_token,category,picture'
}
});
return response.data.data || [];
}
catch (error) {
(0, core_1.LogError)(`Failed to get user pages: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Get page access token for a specific page
*/
async getPageAccessToken(pageId) {
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
*/
async uploadSingleMedia(file) {
try {
const fileData = typeof file.data === 'string'
? Buffer.from(file.data, 'base64')
: file.data;
const formData = new form_data_1.default();
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) {
(0, core_1.LogError)(`Failed to upload media to Facebook: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Upload media to a specific page
*/
async uploadMediaToPage(pageId, file) {
try {
const fileData = typeof file.data === 'string'
? Buffer.from(file.data, 'base64')
: file.data;
const pageToken = await this.getPageAccessToken(pageId);
const formData = new form_data_1.default();
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_1.default.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) {
(0, core_1.LogError)(`Failed to upload media to Facebook page: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Validate media file meets Facebook requirements
*/
validateMediaFile(file) {
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;
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
*/
async createPost(pageId, postData) {
try {
const pageToken = await this.getPageAccessToken(pageId);
const response = await axios_1.default.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);
}
}
/**
* Get a specific post
*/
async getPost(postId) {
try {
const response = await this.axiosInstance.get(`/${postId}`, {
params: {
fields: this.getPostFields()
}
});
return response.data;
}
catch (error) {
(0, core_1.LogError)(`Failed to get post: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Get posts from a page
*/
async getPagePosts(pageId, params = {}) {
try {
const pageToken = await this.getPageAccessToken(pageId);
const response = await axios_1.default.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) {
(0, core_1.LogError)(`Failed to get page posts: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Get paginated results from Facebook
*/
async getPaginatedResults(url, params = {}, maxResults) {
const results = [];
let nextUrl = url;
const limit = params.limit || 100;
while (nextUrl) {
const response = await axios_1.default.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
*/
getPostFields() {
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
*/
normalizePost(fbPost) {
const mediaUrls = [];
// 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
*/
extractPostAnalytics(fbPost) {
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
*/
normalizeAnalytics(fbMetrics) {
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
*/
async searchPosts(params) {
// This is implemented in the search-posts.action.ts
throw new Error('Search posts is implemented in FacebookSearchPostsAction');
}
/**
* Handle Facebook-specific errors
*/
handleFacebookError(error) {
if (error.response) {
const { status, data } = error.response;
const errorData = data;
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
*/
parseRateLimitHeaders(headers) {
// 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) {
(0, core_1.LogError)(`Failed to parse Facebook rate limit headers: ${error}`);
}
}
return null;
}
};
exports.FacebookBaseAction = FacebookBaseAction;
exports.FacebookBaseAction = FacebookBaseAction = __decorate([
(0, global_1.RegisterClass)(actions_1.BaseAction, 'FacebookBaseAction')
], FacebookBaseAction);
//# sourceMappingURL=facebook-base.action.js.map